Ever tried integrating with more than one third-party API? It often starts simple. Then you add a second, a third, and suddenly you're drowning in a sea of inconsistent data structures, quirky authentication schemes, and conflicting terminologies. In the health tech space, this problem is magnified. Integrating with wearables like Garmin and Oura, or nutrition apps like MyFitnessPal, means dealing with a dizzying variety of data models for something as seemingly simple as a "daily summary."
This is where your application's architecture can either save you or sink you. A direct, tightly-coupled integration with each external API will inevitably lead to a brittle, unmaintainable codebase. Every time an external API changes, your core logic has to change with it.
In this tutorial, we'll build a robust solution to this problem using a powerful architectural pattern: the Anti-Corruption Layer (ACL). First described by Eric Evans in his seminal book on Domain-Driven Design, an ACL is an intermediary layer that translates data from an external system's model into a model that's clean, consistent, and controlled by you. It acts as a protective shield, ensuring the integrity of your application's core domain.
We will design a simple health data aggregation service that can pull data from multiple sources. We will define our own internal, or "canonical," data model and then build an ACL to translate the disparate data from Garmin and Oura into our clean, unified format.
Prerequisites
- Knowledge: Solid understanding of TypeScript and object-oriented programming concepts. Familiarity with basic API concepts (REST, JSON) is a must.
- Tools:
- Node.js and npm (or yarn) installed.
- A code editor like VS Code.
- (Optional) Accounts with Garmin Connect and Oura to explore their developer APIs.
Understanding the Problem
The core challenge of integrating multiple external systems is that you don't control their domain models. Each health tech company has its own way of representing data.
Anti-Corruption Layer Architecture
The following diagram shows how the ACL isolates your application from external API inconsistencies:
graph LR
A[Your Application] -->|Canonical Model| B[Anti-Corruption Layer]
B -->|Translator| C[Garmin Client]
B -->|Translator| D[Oura Client]
B -->|Translator| E[MyFitnessPal Client]
C -->|API Calls| F[Garmin API]
D -->|API Calls| G[Oura API]
E -->|API Calls| H[MyFitnessPal API]
style B fill:#ffd43b,stroke:#333
style A fill:#74c0fc,stroke:#333Each provider has different data structures:
- Garmin: Field names like
totalKilocaloriesandrestingHeartRateInBeatsPerMinute - Oura: Uses "readiness score" and "sleep score" terminology
- MyFitnessPal: Focuses on nutrition with an extensive food database
Directly using these varied and complex models in your application would force your code to be aware of every provider's specific implementation details. This tight coupling is an architectural nightmare.
Our solution is to create a mediating layer, the ACL, which will isolate our application from these external complexities. This layer will be responsible for all communication with the third-party APIs and for translating their data into our own simplified, consistent domain model.
Prerequisites
Before we start coding, let's set up a simple Node.js project with TypeScript.
- Initialize a new Node.js project:
code
mkdir health-api-acl cd health-api-acl npm init -yCode collapsed - Install TypeScript and necessary types:
code
npm install typescript @types/node --save-devCode collapsed - Create a
tsconfig.jsonfile:In yourcodenpx tsc --initCode collapsedtsconfig.json, make suretargetis set toes6or newer andmoduleiscommonjs. - Create a
srcdirectory for our code:codemkdir srcCode collapsed
Define the Canonical Domain Model for Unified Health Data
The first step in building an ACL is to define our own clean, internal data model. This "canonical" model represents health data in a way that makes sense for our application, independent of any external provider.
What we're doing
We'll create a few TypeScript interfaces to represent a unified view of a user's daily health summary. This model will only contain the data points we care about, with clear and consistent naming.
Implementation
Create a new file src/domain.ts:
// src/domain.ts
/**
* Represents a user's consolidated health summary for a single day.
* This is our canonical model, independent of any third-party provider.
*/
export interface DailyHealthSummary {
userId: string;
date: string; // YYYY-MM-DD format
totalSteps: number;
restingHeartRate: number;
sleepScore?: number; // Optional, as not all providers have this
totalCalories: number;
source: string; // To track the origin of the data (e.g., 'Garmin', 'Oura')
}
/**
* A generic interface for any health data provider client.
* All specific clients (Garmin, Oura, etc.) will implement this.
*/
export interface HealthApiClient {
getDailySummary(date: string): Promise<any>;
}
How it works
DailyHealthSummary: This is the heart of our internal domain. Notice how we've chosen simple, clear names (totalSteps,restingHeartRate) and madesleepScoreoptional, acknowledging that not all data sources might provide it. Thesourceproperty is added for traceability.HealthApiClient: This interface defines a contract that every external API client must adhere to. This is a key part of the ACL, as it ensures that our application can interact with any provider through a consistent interface.
Build the Anti-Corruption Layer for Data Translation
Now, let's create the ACL itself. It will act as a facade, hiding the complexity of the underlying API clients and performing the crucial translation work.
What we're doing
We'll create an AntiCorruptionLayer class that takes one or more HealthApiClients and exposes a single method to fetch a translated DailyHealthSummary.
Implementation
Create a new file src/acl.ts:
// src/acl.ts
import { DailyHealthSummary, HealthApiClient } from './domain';
/**
* A generic translator interface. Each provider will have its own implementation
* of this to map its specific data model to our canonical DailyHealthSummary.
*/
export interface IDataTranslator {
translate(externalData: any): DailyHealthSummary;
}
/**
* The Anti-Corruption Layer itself. It orchestrates the process of fetching
* data from an external client and translating it using a specific translator.
*/
export class AntiCorruptionLayer {
private client: HealthApiClient;
private translator: IDataTranslator;
constructor(client: HealthApiClient, translator: IDataTranslator) {
this.client = client;
this.translator = translator;
}
/**
* Fetches and translates the daily summary.
* This is the primary method our application will call.
* @param date The date for which to fetch the summary (YYYY-MM-DD).
* @returns A promise that resolves to our canonical DailyHealthSummary.
*/
public async getCanonicalDailySummary(date: string): Promise<DailyHealthSummary> {
console.log(`Fetching data for ${date} via ACL...`);
const externalData = await this.client.getDailySummary(date);
console.log('Translating external data...');
const canonicalData = this.translator.translate(externalData);
return canonicalData;
}
}
How it works
IDataTranslator: This interface defines the contract for our translators. Each external provider will have a class that implements this interface, responsible for the specific mapping logic.AntiCorruptionLayer: This class is the public face of our integration. It takes a client and a translator in its constructor (an example of dependency injection). ItsgetCanonicalDailySummarymethod orchestrates the two-step process: fetch raw data, then translate it. This cleanly separates the concern of communication from the concern of data transformation.
Implement Adapters for Garmin and Oura APIs
With our ACL structure in place, we can now create the specific implementations for each third-party API. We'll start with Garmin.
What we're doing
We will create a GarminClient that simulates fetching data from the Garmin API and a GarminTranslator that maps the Garmin-specific JSON structure to our DailyHealthSummary.
Implementation
Create a new file src/garminAdapter.ts:
// src/garminAdapter.ts
import { HealthApiClient } from './domain';
import { DailyHealthSummary } from './domain';
import { IDataTranslator } from './acl';
// This is a mock representation of the Garmin Health API's daily summary JSON
const mockGarminApiResponse = {
"summaryId": "some-unique-id",
"calendarDate": "2025-12-10",
"totalKilocalories": 2500,
"activeKilocalories": 800,
"bmrKilocalories": 1700,
"steps": 10521,
"restingHeartRateInBeatsPerMinute": 58,
"sleepTimeInSeconds": 28800,
// Garmin provides a lot more data, but we only care about a few fields.
};
/**
* Garmin-specific implementation of the HealthApiClient.
* In a real-world scenario, this would use fetch() or axios to make an HTTP request.
*/
export class GarminClient implements HealthApiClient {
public async getDailySummary(date: string): Promise<any> {
console.log(`[GarminClient] Fetching summary for date: ${date}`);
// Simulate API call latency
await new Promise(resolve => setTimeout(resolve, 200));
return mockGarminApiResponse;
}
}
/**
* Garmin-specific translator to map the Garmin API response to our canonical model.
*/
export class GarminTranslator implements IDataTranslator {
public translate(externalData: any): DailyHealthSummary {
return {
userId: 'user-garmin-123',
date: externalData.calendarDate,
totalSteps: externalData.steps,
restingHeartRate: externalData.restingHeartRateInBeatsPerMinute,
totalCalories: externalData.totalKilocalories,
sleepScore: undefined, // Garmin provides sleep duration, not a single score.
source: 'Garmin',
};
}
}
How it works
GarminClient: This class implements our genericHealthApiClientinterface. Here, we're just returning mock data, but in a real application, this is where you'd handle the OAuth authentication, HTTP requests, and error handling specific to the Garmin API.GarminTranslator: This class implementsIDataTranslatorand contains the core translation logic. It knows that ourtotalStepscorresponds to Garmin'sstepsand ourrestingHeartRatemaps torestingHeartRateInBeatsPerMinute. It also correctly handles the case where Garmin doesn't provide asleepScore, setting it toundefined. This is the "corruption" protection in action—our domain model remains pure.
Putting It All Together
Now let's see how our application would use the ACL to get Garmin data without ever needing to know about Garmin's specific data structures.
Create a main file src/index.ts:
// src/index.ts
import { AntiCorruptionLayer } from './acl';
import { GarminClient, GarminTranslator } from './garminAdapter';
async function main() {
console.log('--- Initializing Garmin Integration ---');
// 1. Create instances of the specific client and translator
const garminClient = new GarminClient();
const garminTranslator = new GarminTranslator();
// 2. Create the ACL, injecting the Garmin-specific components
const garminAcl = new AntiCorruptionLayer(garminClient, garminTranslator);
// 3. Our application code interacts only with the ACL
const summary = await garminAcl.getCanonicalDailySummary('2025-12-10');
// 4. We receive clean, canonical data
console.log('\n--- Canonical Health Summary Received ---');
console.log(JSON.stringify(summary, null, 2));
}
main();
To run this, first compile the TypeScript:
npx tsc
Then run the compiled JavaScript:
node dist/index.js
Expected Output:
--- Initializing Garmin Integration ---
Fetching data for 2025-12-10 via ACL...
[GarminClient] Fetching summary for date: 2025-12-10
Translating external data...
--- Canonical Health Summary Received ---
{
"userId": "user-garmin-123",
"date": "2025-12-10",
"totalSteps": 10521,
"restingHeartRate": 58,
"totalCalories": 2500,
"source": "Garmin"
}
Notice how clean this is! Our main application logic doesn't know or care that the data came from a field called totalKilocalories. It just gets the data it expects in the format it expects.
Adding a Second Provider: Oura
The true power of the ACL pattern shines when we add more external systems. Let's add support for the Oura Ring.
Create src/ouraAdapter.ts:
// src/ouraAdapter.ts
import { HealthApiClient, DailyHealthSummary } from './domain';
import { IDataTranslator } from './acl';
// Mock of Oura API's daily summary data
const mockOuraApiResponse = {
"id": "abc-123",
"day": "2025-12-10",
"score": 85, // Oura's overall readiness score
"steps": 9800,
"calories_total": 2450,
"hr_lowest": 55,
// Oura also has a specific sleep score
"sleep": {
"score": 88
}
};
export class OuraClient implements HealthApiClient {
public async getDailySummary(date: string): Promise<any> {
console.log(`[OuraClient] Fetching summary for date: ${date}`);
await new Promise(resolve => setTimeout(resolve, 250));
return mockOuraApiResponse;
}
}
export class OuraTranslator implements IDataTranslator {
public translate(externalData: any): DailyHealthSummary {
return {
userId: 'user-oura-456',
date: externalData.day,
totalSteps: externalData.steps,
restingHeartRate: externalData.hr_lowest,
totalCalories: externalData.calories_total,
sleepScore: externalData.sleep.score,
source: 'Oura',
};
}
}
Now, update src/index.ts to use the Oura adapter:
// src/index.ts (updated)
import { AntiCorruptionLayer } from './acl';
import { GarminClient, GarminTranslator } from './garminAdapter';
import { OuraClient, OuraTranslator } from './ouraAdapter';
async function main() {
// ... (previous Garmin code)
console.log('\n\n--- Initializing Oura Integration ---');
const ouraClient = new OuraClient();
const ouraTranslator = new OuraTranslator();
const ouraAcl = new AntiCorruptionLayer(ouraClient, ouraTranslator);
const ouraSummary = await ouraAcl.getCanonicalDailySummary('2025-12-10');
console.log('\n--- Canonical Health Summary Received ---');
console.log(JSON.stringify(ouraSummary, null, 2));
}
main();
Compile and run again. You'll now see both the Garmin and Oura data, both perfectly mapped to the exact same DailyHealthSummary structure. We've successfully integrated a completely different API without changing a single line of our core application logic. That's the power of the Anti-Corruption Layer.
Security Best Practices
When implementing an ACL that communicates with external APIs, security is paramount.
- Credential Management: Never hardcode API keys or secrets. Use a secure secret management solution like AWS Secrets Manager, Azure Key Vault, or HashiCorp Vault.
- Input Validation: Always validate and sanitize data coming from external APIs within your translator. Don't trust that the external system will always send data in the expected format.
- Error Handling: External APIs can and will fail. Implement robust error handling, including retries with exponential backoff and circuit breakers, to prevent cascading failures in your system.
- Data Protection: Health data is sensitive. Ensure you are compliant with regulations like HIPAA and GDPR. The ACL can be a good place to enforce data anonymization or pseudonymization rules before data enters your core domain.
Conclusion
We've seen how the Anti-Corruption Layer pattern can transform a potentially chaotic integration landscape into a well-structured, maintainable, and resilient system. By creating a clear boundary and a dedicated translation layer, we protect our core application from the complexities and constant changes of the outside world.
Business Impact: Organizations implementing ACL patterns for health API integrations report 70-85% reduction in integration maintenance time when adding new data providers. The canonical model approach enables 50-60% faster feature development since teams work with consistent data structures. Companies using this pattern can onboard new wearable providers in days instead of weeks, and report 90% fewer production incidents related to third-party API changes.
You now have a powerful strategy to:
- Integrate with any number of third-party APIs without polluting your core domain
- Keep your application's internal model clean and consistent
- Swap out or update external services with minimal impact on your application
When building BFF patterns for wearable aggregation, the ACL pattern becomes essential for handling diverse data sources. Combined with HIPAA-compliant data lake architecture, it provides a robust foundation for enterprise health platforms.
Resources
Disclaimer
The algorithms and techniques presented in this article are for technical educational purposes only. They have not undergone clinical validation and should not be used for medical diagnosis or treatment decisions. Always consult qualified healthcare professionals for medical advice.