Skip to content
Angular ng patterns 4 min read

Adapter Pattern

APIs and third-party libraries rarely speak the same language as your application. Their JSON uses snake_case keys, sends dates as strings, nests fields awkwardly, or exposes nullable values your UI never wants to touch. The adapter pattern places a thin translation layer between that raw external shape and the clean domain model your components consume, so your app code stays stable even when a backend contract shifts.

Why you need an adapter

Without an adapter, the raw API shape leaks everywhere. Templates reference user.first_name, services parse created_at strings repeatedly, and a single backend rename forces edits across dozens of files. An adapter centralizes that mapping in one place: components depend on a stable User model, and only the adapter knows how the wire format looks.

The pattern also gives you a natural seam for defensive coding — coercing types, supplying defaults for missing fields, and converting raw strings into rich types like Date or value objects.

Defining the shapes

Start by modeling both sides explicitly. The DTO mirrors the API exactly; the domain model is what your app actually wants.

// The raw API contract — never used outside the adapter
export interface UserDto {
  id: number;
  first_name: string;
  last_name: string;
  email_address: string;
  created_at: string; // ISO string
  is_active: boolean | null;
}

// The clean domain model used throughout the app
export interface User {
  id: number;
  fullName: string;
  email: string;
  createdAt: Date;
  active: boolean;
}

Writing the adapter

A common Angular convention is an Adapter<T> interface with a single adapt method, implemented as an injectable service so it can be tested and reused.

import { Injectable } from '@angular/core';

export interface Adapter<TInput, TOutput> {
  adapt(input: TInput): TOutput;
}

@Injectable({ providedIn: 'root' })
export class UserAdapter implements Adapter<UserDto, User> {
  adapt(dto: UserDto): User {
    return {
      id: dto.id,
      fullName: `${dto.first_name} ${dto.last_name}`.trim(),
      email: dto.email_address,
      createdAt: new Date(dto.created_at),
      active: dto.is_active ?? false,
    };
  }
}

Note how is_active — which the backend may send as null — is coerced to a guaranteed boolean with ?? false. The rest of the app never deals with that ambiguity.

Using it in a data service

Wire the adapter into the HTTP layer so callers receive domain models, not DTOs. With modern Angular you can use inject() and RxJS map.

import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

@Injectable({ providedIn: 'root' })
export class UserService {
  private http = inject(HttpClient);
  private adapter = inject(UserAdapter);

  getUsers(): Observable<User[]> {
    return this.http
      .get<UserDto[]>('/api/users')
      .pipe(map((dtos) => dtos.map((dto) => this.adapter.adapt(dto))));
  }
}

The component consuming this never imports UserDto and never sees a snake_case key.

import { Component, inject, signal } from '@angular/core';
import { UserService } from './user.service';

@Component({
  selector: 'app-user-list',
  standalone: true,
  template: `
    @for (user of users(); track user.id) {
      <p>{{ user.fullName }} — joined {{ user.createdAt | date }}</p>
    } @empty {
      <p>No users yet.</p>
    }
  `,
})
export class UserListComponent {
  private service = inject(UserService);
  users = signal<User[]>([]);

  constructor() {
    this.service.getUsers().subscribe((u) => this.users.set(u));
  }
}

Output:

Ada Lovelace — joined Dec 10, 1815
Alan Turing — joined Jun 23, 1912

Adapting in both directions

Sometimes you must serialize back to the API on save. Adapters can expose a reverse method, keeping write mapping co-located with read mapping.

@Injectable({ providedIn: 'root' })
export class UserAdapter implements Adapter<UserDto, User> {
  adapt(dto: UserDto): User {
    return {
      id: dto.id,
      fullName: `${dto.first_name} ${dto.last_name}`.trim(),
      email: dto.email_address,
      createdAt: new Date(dto.created_at),
      active: dto.is_active ?? false,
    };
  }

  toDto(user: User): Partial<UserDto> {
    const [first_name, ...rest] = user.fullName.split(' ');
    return {
      first_name,
      last_name: rest.join(' '),
      email_address: user.email,
      is_active: user.active,
    };
  }
}

Adapter vs. inline mapping

ConcernInline map in serviceDedicated adapter
Reuse across endpointsDuplicatedSingle source
Unit testingTests the whole serviceTest mapping in isolation
Bidirectional mappingScatteredCo-located read/write
Type safety boundaryLeakyDTO stays private

Keep adapters pure — no HTTP calls, no side effects, no inject() of stateful services. A pure adapt function is trivial to unit test and reason about.

Best Practices

  • Define an explicit DTO interface that mirrors the API exactly, and never let it escape the adapter or service.
  • Make the adapter a pure function or pure injectable so it is easy to test with plain input/output assertions.
  • Coerce risky values — nulls, optional fields, date strings — into safe, rich domain types inside the adapter.
  • Co-locate read (adapt) and write (toDto) mapping so both directions evolve together.
  • Apply the adapter at the edge (the HTTP service) so the rest of the app only ever sees domain models.
  • Prefer one adapter per entity rather than a giant mapping file; keep each focused and small.
Last updated June 14, 2026
Was this helpful?