Modern Angular
Adapter Pattern in Angular: Isolating APIs and Browser-Only Libraries
An adapter earns its place when Angular code starts speaking a contract the application does not own: backend DTOs, third-party callbacks, browser APIs, or widget lifecycles. The Gang of Four defined the pattern in 1994 as letting 'classes with incompatible interfaces work together'. In Angular, Angular Material's DateAdapter is canonical: an abstract class that lets CDK-based components work with Date, Moment, or Luxon implementations behind one contract.
Start where the foreign contract leaks
I reach for the Adapter Pattern in Angular when a feature starts importing someone else's vocabulary.
A backend returns snake_case fields, cents, status codes, and timestamps. A chart library asks for an imperative DOM host and emits callback payloads. Browser-only work belongs after render, not during server rendering or prerendering. None of those contracts should become the vocabulary of the page component.
In this article, the foreign contract is the shape owned outside the app; the adapter is the implementation boundary that translates that shape into an app-owned contract.
I would not wrap every dependency on principle. I would create an adapter when the external shape leaks into components, facades, templates, tests, or shared domain types. If the wrapper only renames one method and translates nothing, it is noise.
| Foreign contract | Leak in Angular code | Adapter boundary |
|---|---|---|
| Backend DTO | Components know report_id, cents, and status codes | Map DTOs into app-owned models beside the API service |
| Third-party chart | Component owns imperative DOM setup and callback payloads | Translate chart lifecycle and events in a chart adapter |
| Browser-only API | Code touches window or DOM before render | Start DOM work after render and keep browser access out of the page model |
| Custom widget | Template knows library event names and payload shape | Expose Angular-friendly inputs, outputs, or signals |
| One method with same input/output | Wrapper adds a class but no translation | Do not create the adapter yet |
The smell: one component speaks three languages
This component is not bad because it uses HttpClient, Signals, or afterNextRender. Those are normal Angular tools. The problem is that the component speaks backend DTO, chart-library API, and page state at the same time.
The result is subtle coupling. A backend field rename changes the component. A chart vendor payload change changes the component. A test for selected chart points has to know about point_id. The component even collapses every non-green status into attention, which means a backend reporting decision is now hidden in the page.
There is also a lifecycle bug hiding here: the chart can be destroyed through onCleanup and again through DestroyRef. Duplication like that is common when component code owns an imperative library it should consume through a small boundary.
@Component({
selector: 'app-sales-dashboard-page',
templateUrl: './sales-dashboard-page.html',
})
export class SalesDashboardPage {
private readonly http = inject(HttpClient);
private readonly destroyRef = inject(DestroyRef);
private readonly chartHost =
viewChild.required<ElementRef<HTMLElement>>('chartHost');
private readonly browserReady = signal(false);
readonly selectedPoint = signal<string | null>(null);
readonly report = toSignal(
this.http.get<SalesReportDto>('/api/reports/sales/current').pipe(
map((dto) => ({
id: dto.report_id,
generatedAt: new Date(dto.generated_at),
status: dto.health_code === 'GREEN' ? 'healthy' : 'attention',
totalRevenue: dto.total_revenue_cents / 100,
points: dto.points.map((point) => ({
id: String(point.point_id),
label: point.label_text,
revenue: point.revenue_cents / 100,
})),
})),
catchError(() => of(null))
),
{ initialValue: null }
);
constructor() {
afterNextRender(() => this.browserReady.set(true));
effect((onCleanup) => {
const report = this.report();
if (!this.browserReady() || !report) return;
const chart = window.acmeCharts.render(
this.chartHost().nativeElement,
report.points.map((point) => ({
key: point.id,
name: point.label,
value: point.revenue,
}))
);
chart.on('pointClick', (event) => {
this.selectedPoint.set(String(event.point_id));
});
onCleanup(() => chart.destroy());
this.destroyRef.onDestroy(() => chart.destroy());
});
}
}Adapt the backend contract once
The API service should return a model the app is willing to own. That does not mean pretending DTOs do not exist. It means DTOs stop crossing the boundary into components and feature facades.
Keep this adapter close to the API module. If the backend changes health_code or money representation, one place breaks loudly. The dashboard page should not know whether the backend stores revenue in cents, decimal strings, or some future reporting schema.
Angular's HttpClient already gives you a clear transport boundary, and DI makes replacement explicit in tests. The adapter adds value because it changes the shape of the contract.
type SalesReportStatus = 'healthy' | 'attention' | 'blocked';
interface SalesReportDto {
report_id: string;
generated_at: string;
health_code: 'GREEN' | 'YELLOW' | 'RED';
total_revenue_cents: number;
points: SalesReportPointDto[];
}
interface SalesReportPointDto {
point_id: number;
label_text: string;
revenue_cents: number;
}
interface SalesReport {
id: string;
generatedAt: Date;
status: SalesReportStatus;
totalRevenue: number;
points: SalesReportPoint[];
}
interface SalesReportPoint {
id: string;
label: string;
revenue: number;
}
@Injectable({ providedIn: 'root' })
export class SalesReportAdapter {
fromDto(dto: SalesReportDto): SalesReport {
return {
id: dto.report_id,
generatedAt: new Date(dto.generated_at),
status: this.statusFrom(dto.health_code),
totalRevenue: dto.total_revenue_cents / 100,
points: dto.points.map((point) => ({
id: String(point.point_id),
label: point.label_text.trim(),
revenue: point.revenue_cents / 100,
})),
};
}
private statusFrom(code: SalesReportDto['health_code']): SalesReportStatus {
switch (code) {
case 'GREEN':
return 'healthy';
case 'YELLOW':
return 'attention';
case 'RED':
return 'blocked';
default: {
const exhaustive: never = code;
throw new Error(`Unsupported health code: ${exhaustive}`);
}
}
}
}
@Injectable({ providedIn: 'root' })
export class SalesReportApi {
private readonly http = inject(HttpClient);
private readonly adapter = inject(SalesReportAdapter);
current() {
return this.http
.get<SalesReportDto>('/api/reports/sales/current')
.pipe(map((dto) => this.adapter.fromDto(dto)));
}
}Adapt the library at the browser boundary
Charts, editors, maps, and payment widgets often use imperative APIs because they are not Angular components. That is fine. The issue is letting each page learn the vendor's lifecycle, event names, payload fields, and teardown rules.
A chart adapter should translate from app-owned data to vendor input, and from vendor events back to app-owned events. It should not own page state. The Angular component can still decide that selected point belongs in a signal.
This keeps the render-sensitive part small. Angular's render callbacks are the right place to start manual DOM work after rendering, while the adapter owns the vendor interaction once the host element exists.
interface SalesChartSelection {
pointId: string;
label: string;
}
interface SalesChartHandle {
destroy(): void;
}
@Injectable()
export class SalesChartAdapter {
connect(
host: HTMLElement,
report: SalesReport,
onSelect: (selection: SalesChartSelection) => void
): SalesChartHandle {
const chart = window.acmeCharts.render(host, {
series: report.points.map((point) => ({
key: point.id,
name: point.label,
value: point.revenue,
})),
});
const unsubscribe = chart.on(
'pointClick',
(event: AcmeChartPointEvent) => {
onSelect({
pointId: String(event.point_id),
label: event.label ?? 'Unknown point',
});
}
);
let destroyed = false;
return {
destroy: () => {
if (destroyed) return;
destroyed = true;
unsubscribe();
chart.destroy();
},
};
}
}The page consumes app-owned contracts
After the adapters exist, the page component has a narrower job: read an app-owned report, wait until browser rendering has happened, connect the chart, and store the selected point in component state.
Providing SalesChartAdapter at the page level is deliberate. The chart instance is tied to this component tree, not to the whole application. Root providers are useful for stateless DTO adapters and API services; browser widget instances usually deserve a smaller lifetime.
This is the line I would enforce in review: components may coordinate app-owned contracts, but they should not become translators for contracts owned by vendors, browsers, or backend teams.
@Component({
selector: 'app-sales-dashboard-page',
providers: [SalesChartAdapter],
templateUrl: './sales-dashboard-page.html',
})
export class SalesDashboardPage {
private readonly api = inject(SalesReportApi);
private readonly chartAdapter = inject(SalesChartAdapter);
private readonly chartHost =
viewChild.required<ElementRef<HTMLElement>>('chartHost');
private readonly browserReady = signal(false);
readonly selectedPoint = signal<SalesChartSelection | null>(null);
readonly report = toSignal(this.api.current(), { initialValue: null });
constructor() {
afterNextRender(() => this.browserReady.set(true));
effect((onCleanup) => {
const report = this.report();
if (!this.browserReady() || !report) return;
const handle = this.chartAdapter.connect(
this.chartHost().nativeElement,
report,
(selection) => this.selectedPoint.set(selection)
);
onCleanup(() => handle.destroy());
});
}
}Do not wrap what you do not translate
The Adapter Pattern becomes harmful when it turns every dependency into a ceremony. Angular already has services, providers, pipes, directives, components, and plain functions. An adapter is only the right name when translation is the point.
The easiest review rule is to ask what contract changes at the boundary. If the answer is none, keep the code simpler.
| Logic | Better owner | Reason |
|---|---|---|
| DTO parsing and money/status normalization | Adapter beside API service | The app should own its domain model |
| HTTP URL, params, headers, and retry policy | SalesReportApi | Transport rules are not page state |
| Selected chart point | Component or feature facade | It is UI state for this screen |
| Chart setup, vendor event payload, teardown | SalesChartAdapter | The vendor contract should not leak into the page |
| Currency formatting for display | Pipe or formatter | No external dependency is being adapted |
Test the translation, not the vendor
A good adapter gives you a cheap test target. The DTO adapter test should prove that backend codes, cents, timestamps, and naming conventions do not leak into the UI model.
The chart adapter test can use a fake chart implementation if the wrapper has enough behavior to justify it. Do not run a real browser chart library only to prove a status code was mapped correctly.
The practical win is review speed. When a backend contract changes, the failing test should point at the translation boundary, not at a dashboard component that happens to render the report.
describe('SalesReportAdapter', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [SalesReportAdapter],
});
});
it('keeps backend codes and cents out of the UI model', () => {
const adapter = TestBed.inject(SalesReportAdapter);
const report = adapter.fromDto({
report_id: 'sales-2026-05',
generated_at: '2026-05-18T10:00:00.000Z',
health_code: 'YELLOW',
total_revenue_cents: 129990,
points: [
{
point_id: 42,
label_text: 'Expansion revenue ',
revenue_cents: 49990,
},
],
});
expect(report.status).toBe('attention');
expect(report.totalRevenue).toBe(1299.9);
expect(report.points[0]).toEqual({
id: '42',
label: 'Expansion revenue',
revenue: 499.9,
});
});
});Reusable artifact
Adapter review checklist
- Create an adapter only when Angular code is leaking a contract the app does not own.
- Keep DTO mapping close to the API service, and return app-owned models from that boundary.
- Keep third-party widget lifecycle, event names, payloads, and teardown inside a focused adapter.
- Let components or facades own UI state; do not move page decisions into the adapter.
- Scope browser widget adapters to the component or route when their lifetime is local.
- Reject wrappers that proxy a dependency without changing the contract.
Sources checked
- https://angular.dev/guide/di
- https://angular.dev/guide/di/dependency-injection-providers
- https://angular.dev/api/core/InjectionToken
- https://github.com/angular/components/blob/main/src/material/core/datetime/date-adapter.ts
- https://github.com/angular/components/blob/main/src/material/core/datetime/native-date-adapter.ts
- https://florimond.dev/en/posts/2018/09/consuming-apis-in-angular-the-model-adapter-pattern
- https://en.wikipedia.org/wiki/Design_Patterns
Modern Angular Playbook
This article is one play.
The Modern Angular Playbook collects the diagnostic, the adoption matrix, eleven plays, and the 30-day plan. Free, in English and Portuguese.
You get both PDFs by email, through the Dojo IA list.
André Ramosavailable for remote roles, UTC−3Get in touch →