---
title: "Adapter Pattern no Angular: isolando APIs e bibliotecas browser-only"
description: "Um guia de produção sobre Adapter Pattern no Angular: onde traduzir contratos externos, como manter componentes limpos e quando um wrapper não compensa."
deck: "Um adapter merece existir quando o código Angular começa a falar um contrato que a aplicação não controla: DTOs do backend, callbacks de terceiros, APIs do browser ou ciclo de vida de widgets. A Gang of Four definiu o pattern em 1994 como deixar 'classes com interfaces incompatíveis trabalharem juntas'. No Angular, o [`DateAdapter` do Material](https://github.com/angular/components/blob/main/src/material/core/datetime/date-adapter.ts) é canônico: classe abstrata que deixa componentes baseados em CDK trabalharem com `Date`, Moment ou Luxon atrás de um contrato único."
author: "André Ramos"
url: "https://andreramos.dev/pt/angular/adapter-pattern-in-angular/"
lang: "pt-BR"
type: "article"
---

# Adapter Pattern no Angular: isolando APIs e bibliotecas browser-only

> Um adapter merece existir quando o código Angular começa a falar um contrato que a aplicação não controla: DTOs do backend, callbacks de terceiros, APIs do browser ou ciclo de vida de widgets. A Gang of Four definiu o pattern em 1994 como deixar 'classes com interfaces incompatíveis trabalharem juntas'. No Angular, o [`DateAdapter` do Material](https://github.com/angular/components/blob/main/src/material/core/datetime/date-adapter.ts) é canônico: classe abstrata que deixa componentes baseados em CDK trabalharem com `Date`, Moment ou Luxon atrás de um contrato único.

## Comece onde o contrato externo vaza

Eu uso Adapter Pattern no Angular quando uma feature começa a importar vocabulário de fora.

Um backend retorna campos em snake_case, centavos, códigos de status e timestamps. Uma biblioteca de chart pede um host imperativo no DOM e emite callbacks com payload próprio. Trabalho browser-only pertence ao pós-render, não ao server rendering ou prerendering. Nenhum desses contratos deveria virar vocabulário do componente da página.

Neste artigo, o contrato externo é o formato controlado fora da aplicação; o adapter é o limite de implementação que traduz esse formato para um contrato da aplicação.

Eu não criaria wrapper para toda dependência por princípio. Eu criaria um adapter quando o formato externo vaza para componentes, facades, templates, testes ou tipos de domínio compartilhados. Se o wrapper só renomeia um método e não traduz nada, ele é ruído.

| Contrato externo | Vazamento no código Angular | Limite do adapter |
|---|---|---|
| DTO do backend | Componentes conhecem `report_id`, centavos e códigos de status | Mapear DTOs para modelos da aplicação perto do service de API |
| Chart de terceiro | Componente assume setup imperativo de DOM e payload dos callbacks | Traduzir ciclo de vida e eventos em um adapter do chart |
| API browser-only | Código toca `window` ou DOM antes do render | Iniciar DOM depois do render e manter acesso ao browser fora do modelo da página |
| Widget customizado | Template conhece nomes de eventos e payload da biblioteca | Expor inputs, outputs ou signals com formato Angular |
| Um método com mesmo input/output | Wrapper adiciona classe sem tradução | Ainda não crie o adapter |

## O cheiro: um componente fala três línguas

Este componente não é ruim por usar `HttpClient`, Signals ou `afterNextRender`. Essas são ferramentas normais do Angular. O problema é que ele fala DTO do backend, API da biblioteca de chart e estado da página ao mesmo tempo.

O acoplamento aparece em mudanças pequenas. Renomear um campo do backend muda o componente. Alterar o payload do vendor muda o componente. Um teste de ponto selecionado precisa conhecer `point_id`. O componente ainda transforma qualquer status não verde em `attention`, escondendo uma decisão do contrato do backend dentro da página.

Também existe um bug de ciclo de vida escondido: o chart pode ser destruído pelo `onCleanup` e depois de novo pelo `DestroyRef`. Esse tipo de duplicidade é comum quando o componente assume uma biblioteca imperativa que deveria consumir por um limite pequeno.

*Componente com vazamento*

```ts
@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());
    });
  }
}
```

## Adapte o contrato do backend uma vez

O service de API deve devolver um modelo que a aplicação aceita assumir. Isso não significa fingir que DTOs não existem. Significa impedir que DTOs atravessem o limite e cheguem a componentes e facades.

Mantenha esse adapter perto do módulo de API. Se o backend mudar `health_code` ou a representação de dinheiro, um ponto quebra de forma explícita. A página do dashboard não deveria saber se o backend guarda receita em centavos, strings decimais ou algum schema futuro de relatório.

O `HttpClient` do Angular já dá um limite claro para transporte, e DI deixa substituição explícita em testes. O adapter agrega valor porque muda o formato do contrato.

*Adapter de DTO e API service*

```ts
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)));
  }
}
```

## Adapte a biblioteca no limite do browser

Charts, editors, mapas e widgets de pagamento costumam usar APIs imperativas porque não são componentes Angular. Tudo bem. O problema é deixar cada página aprender ciclo de vida, nomes de evento, campos de payload e regra de teardown do vendor.

Um adapter de chart deve traduzir dados da aplicação para entrada do vendor, e eventos do vendor de volta para eventos da aplicação. Ele não deve assumir estado da página. O componente Angular ainda pode decidir que o ponto selecionado mora em um signal.

Isso mantém pequena a parte sensível ao render. Render callbacks do Angular são o lugar certo para iniciar trabalho manual de DOM depois do render, enquanto o adapter assume a interação com o vendor quando o elemento já existe.

*Adapter do chart*

```ts
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();
      },
    };
  }
}
```

## A página consome contratos da aplicação

Depois dos adapters, o componente da página tem uma função mais estreita: ler um relatório no formato da aplicação, esperar o render no browser, conectar o chart e guardar o ponto selecionado em estado do componente.

Fornecer `SalesChartAdapter` no nível da página é intencional. A instância do chart está ligada a essa árvore de componentes, não à aplicação inteira. Providers root são úteis para adapters stateless de DTO e services de API; instâncias de widgets browser-only normalmente merecem um ciclo de vida menor.

Esta é a linha de produção que eu cobraria em review: componentes podem coordenar contratos da aplicação, mas não devem virar tradutores de contratos controlados por vendors, browsers ou times de backend.

*Limite da página*

```ts
@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());
    });
  }
}
```

## Não envolva o que você não traduz

Adapter Pattern vira problema quando transforma toda dependência em cerimônia. Angular já tem services, providers, pipes, directives, components e funções simples. O nome adapter só faz sentido quando tradução é a função principal.

A regra de review mais fácil é perguntar qual contrato muda naquele limite. Se a resposta for nenhum, mantenha o código mais simples.

| Lógica | Dono mais adequado | Motivo |
|---|---|---|
| Parsing de DTO e normalização de dinheiro/status | Adapter perto do service de API | A aplicação deve controlar seu modelo de domínio |
| URL HTTP, params, headers e retry | `SalesReportApi` | Regras de transporte não são estado de página |
| Ponto selecionado no chart | Componente ou facade da feature | É estado de UI dessa tela |
| Setup do chart, payload de evento e teardown | `SalesChartAdapter` | O contrato do vendor não deve vazar para a página |
| Formatação de moeda para exibição | Pipe ou formatter | Nenhuma dependência externa está sendo adaptada |

## Teste a tradução, não o vendor

Um bom adapter dá ao time um alvo de teste barato. O teste do adapter de DTO deve provar que códigos do backend, centavos, timestamps e convenções de nome não vazam para o modelo da UI.

O teste do adapter de chart pode usar uma implementação fake se o wrapper tiver comportamento suficiente para justificar isso. Não rode uma biblioteca real de chart no browser só para provar que um status foi mapeado corretamente.

O ganho prático é velocidade de review. Quando um contrato de backend muda, o teste que falha deveria apontar para o limite de tradução, não para um componente de dashboard que por acaso renderiza o relatório.

*Teste do adapter*

```ts
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,
    });
  });
});
```

## Artefato reaproveitável: Checklist de review para adapter

- Crie um adapter apenas quando código Angular estiver vazando um contrato que a aplicação não controla.
- Mantenha mapeamento de DTO perto do service de API e retorne modelos da aplicação desse limite.
- Mantenha ciclo de vida, nomes de evento, payloads e teardown de widgets de terceiro dentro de um adapter focado.
- Deixe componentes ou facades assumirem estado de UI; não mova decisões da página para o adapter.
- Dê escopo de componente ou rota a adapters de widgets browser-only quando o ciclo de vida for local.
- Rejeite wrappers que fazem proxy de dependência sem mudar o contrato.

## Fontes consultadas

- 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

## Leia também

- [Facade Pattern no Angular: quando o componente sabe demais](https://andreramos.dev/pt/angular/facade-pattern-in-angular/)
- [Strategy Pattern no Angular: trocando regras sem espalhar switches](https://andreramos.dev/pt/angular/strategy-pattern-in-angular/)
- [Standalone sem transformar NgModules em vilões](https://andreramos.dev/pt/angular/standalone-without-ngmodule-dogma/)
