Voltar para Artigos
Back-end★ Destaque11 min de leitura

Design Pattern: Adapter nos Providers da API

Como o padrão Adapter isola sua regra de negócio de bibliotecas externas. Ports & Adapters na prática: IMailProvider, IStorageProvider, ICacheProvider com implementações reais e fake para testes.

12 de agosto de 2026

Imagine que sua aplicação está enviando e-mails com nodemailer. Após 6 meses, o cliente decide migrar para AWS SES porque tem melhor deliverability. Com um código acoplado, você vai caçar todos os import nodemailer espalhados pelos Services, reescrever a lógica de envio e torcer para não quebrar nada. Com o padrão Adapter, você escreve um novo SESMailProvider, atualiza uma linha no container de injeção de dependências, e está feito.

O padrão Adapter (também chamado de Ports & Adapters ou Hexagonal Architecture quando aplicado em toda a infraestrutura) define que a regra de negócio deve depender de interfaces abstratas (Ports), nunca de implementações concretas. As implementações (Adapters) ficam na camada de infraestrutura e são intercambiáveis.

O Problema: Acoplamento de Infraestrutura

❌ Código acoplado — sem Adapter
// ❌ O Service conhece e depende diretamente do nodemailer
import nodemailer from 'nodemailer'; // Dependência de infraestrutura na regra de negócio!

export class CreateUserUseCase {
  async execute(dto: CreateUserDTO) {
    const user = await this.usersRepo.create(dto);

    // Lógica de nodemailer embutida no Use Case
    const transporter = nodemailer.createTransport({ /* ... */ });
    await transporter.sendMail({
      from: 'noreply@app.com',
      to: user.email,
      subject: 'Bem-vindo',
      html: '<p>Olá!</p>',
    });

    return user;
  }
}

// Problemas:
// 1. Migrar para AWS SES = reescrever o Use Case
// 2. Testes unitários fazem chamadas HTTP reais
// 3. Qualquer mudança de config de e-mail afeta regras de negócio

A Solução: Interface (Port) + Adapter

O padrão resolve o problema em três partes: 1) A Interface (Port) define o contrato — sendEmail(data) — sem mencionar Nodemailer ou SES. 2) Os Adapters implementam esse contrato para cada serviço concreto. 3) O Container DI decide qual adapter injetar baseado em variável de ambiente. O Use Case nunca importa nenhum adapter diretamente. O beneficio é duplo: em testes, injeta-se FakeMailProvider (sem I/O real). Em produção, injeta-se SESMailProvider. A regra de negócio não muda.

O passo 1 é definir a interface — o contrato que a regra de negócio conhece. Nenhuma menção a bibliotecas externas:

IMailProvider.ts
// Esta interface é o 'Port' — o contrato entre domínio e infraestrutura
// O domínio define o que precisa, não como é implementado

export interface SendMailDTO {
  to: string | Array<string>;
  subject: string;
  html: string;
  text?: string;     // Fallback plain text (gerado automaticamente se omitido)
  replyTo?: string;
  attachments?: Array<{
    filename: string;
    content: Buffer | string;
    contentType?: string;
  }>;
}

export interface IMailProvider {
  send(data: SendMailDTO): Promise<void>;
}
NodemailerMailProvider.ts
import nodemailer, { type Transporter } from 'nodemailer';
import { convert } from 'html-to-text';
import type { IMailProvider, SendMailDTO } from '../../../domain/providers/IMailProvider';

export class NodemailerMailProvider implements IMailProvider {
  private transporter: Transporter;

  constructor() {
    this.transporter = nodemailer.createTransport({
      host: process.env.MAIL_HOST,
      port: Number(process.env.MAIL_PORT ?? 587),
      secure: process.env.MAIL_SECURE === 'true',
      auth: {
        user: process.env.MAIL_USER,
        pass: process.env.MAIL_PASS,
      },
    });
  }

  async send(dto: SendMailDTO): Promise<void> {
    const textContent = dto.text ?? convert(dto.html, { wordwrap: 80 });

    await this.transporter.sendMail({
      from: `"${process.env.MAIL_FROM_NAME}" <${process.env.MAIL_FROM_ADDRESS}>`,
      to: Array.isArray(dto.to) ? dto.to.join(', ') : dto.to,
      subject: dto.subject,
      html: dto.html,
      text: textContent,
      replyTo: dto.replyTo,
      attachments: dto.attachments,
    });
  }
}
SESMailProvider.ts
import nodemailer from 'nodemailer';
import { SES } from '@aws-sdk/client-ses';
import { convert } from 'html-to-text';
import type { IMailProvider, SendMailDTO } from '../../../domain/providers/IMailProvider';

// Adapter alternativo: mesma interface, implementação diferente
export class SESMailProvider implements IMailProvider {
  async send(dto: SendMailDTO): Promise<void> {
    const ses = new SES({ region: process.env.AWS_REGION });
    const transporter = nodemailer.createTransport({ SES: { ses, aws: { SES } } });

    const textContent = dto.text ?? convert(dto.html, { wordwrap: 80 });

    await transporter.sendMail({
      from: `"${process.env.MAIL_FROM_NAME}" <${process.env.MAIL_FROM_ADDRESS}>`,
      to: Array.isArray(dto.to) ? dto.to.join(', ') : dto.to,
      subject: dto.subject,
      html: dto.html,
      text: textContent,
    });
  }
}

// Para migrar de Nodemailer para SES:
// Basta trocar a linha de registro no container de DI.
// O Use Case não é modificado.

Fake Provider: Isolamento em Testes

O benefício mais imediato do Adapter nos testes: substitua o provider real por um Fake que salva os e-mails na memória — sem chamadas HTTP, sem dependência de SMTP em CI:

FakeMailProvider.ts
import type { IMailProvider, SendMailDTO } from '../../domain/providers/IMailProvider';

export class FakeMailProvider implements IMailProvider {
  // Guarda todos os e-mails enviados na memória
  public sentMails: Array<SendMailDTO> = [];

  async send(dto: SendMailDTO): Promise<void> {
    // Não envia nada — apenas registra
    this.sentMails.push(dto);
  }

  // Helpers para asserções nos testes
  getLastEmail(): SendMailDTO | undefined {
    return this.sentMails.at(-1);
  }

  countSentEmails(): number {
    return this.sentMails.length;
  }

  clear(): void {
    this.sentMails = [];
  }
}

// Nos testes:
describe('CreateUserUseCase', () => {
  let mailProvider: FakeMailProvider;
  let useCase: CreateUserUseCase;

  beforeEach(() => {
    mailProvider = new FakeMailProvider();
    useCase = new CreateUserUseCase(usersRepo, mailProvider);
  });

  it('deve enviar e-mail de boas-vindas após criar o usuário', async () => {
    await useCase.execute({ name: 'Maria', email: 'maria@email.com', password: '123456' });

    expect(mailProvider.countSentEmails()).toBe(1);
    expect(mailProvider.getLastEmail()?.to).toBe('maria@email.com');
    expect(mailProvider.getLastEmail()?.subject).toContain('Bem-vindo');
  });
});

Aplicando o Padrão a Outros Providers

O mesmo padrão se aplica a qualquer dependência de infraestrutura. Exemplos de interfaces que todo projeto robusto deve ter:

Outros providers com interface
// Storage: local vs S3 vs GCS
export interface IStorageProvider {
  upload(filename: string, data: Buffer, mimeType: string): Promise<string>; // Retorna URL
  delete(filename: string): Promise<void>;
  getSignedUrl(filename: string, expiresInSeconds: number): Promise<string>;
}

// Cache: Redis vs InMemory vs Memcached
export interface ICacheProvider {
  set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;
  get<T>(key: string): Promise<T | null>;
  del(key: string): Promise<void>;
  delByPrefix(prefix: string): Promise<void>;
}

// Hash: bcrypt vs argon2
export interface IHashProvider {
  hash(plaintext: string): Promise<string>;
  compare(plaintext: string, hashed: string): Promise<boolean>;
  needsRehash(hashed: string): boolean;
}

// Notificação: push notification vs SMS vs email
export interface INotificationProvider {
  send(userId: string, title: string, body: string, data?: Record<string, unknown>): Promise<void>;
}

Registro no Container de DI (tsyringe)

Com tsyringe, a troca de implementação é feita em um único ponto — o arquivo de registro do container:

providers.ts
import { container } from 'tsyringe';
import type { IMailProvider } from '../../domain/providers/IMailProvider';
import type { IStorageProvider } from '../../domain/providers/IStorageProvider';
import type { ICacheProvider } from '../../domain/providers/ICacheProvider';

// Seleciona a implementação baseado no ambiente
const mailProviders = {
  nodemailer: () => import('../providers/mail/NodemailerMailProvider').then((m) => m.NodemailerMailProvider),
  ses: () => import('../providers/mail/SESMailProvider').then((m) => m.SESMailProvider),
};

const selectedMailProvider = process.env.MAIL_PROVIDER ?? 'nodemailer';

// @ts-expect-error dynamic import
mailProviders[selectedMailProvider]().then((Provider) => {
  container.registerSingleton<IMailProvider>('MailProvider', Provider);
});

const storageProviders = {
  local: () => import('../providers/storage/LocalStorageProvider').then((m) => m.LocalStorageProvider),
  s3: () => import('../providers/storage/S3StorageProvider').then((m) => m.S3StorageProvider),
};

const selectedStorageProvider = process.env.STORAGE_PROVIDER ?? 'local';

// @ts-expect-error dynamic import
storageProviders[selectedStorageProvider]().then((Provider) => {
  container.registerSingleton<IStorageProvider>('StorageProvider', Provider);
});

Defina MAIL_PROVIDER=nodemailer no .env de desenvolvimento e MAIL_PROVIDER=ses no .env.production. A troca de provedor se torna uma variável de ambiente — sem mudança de código, sem risco de regressão, totalmente auditável.

Conclusão

O padrão Adapter é a aplicação direta do Princípio da Inversão de Dependência (DIP) do SOLID: módulos de alto nível (Use Cases) não dependem de módulos de baixo nível (nodemailer, AWS S3) — ambos dependem de abstrações (IMailProvider, IStorageProvider). O resultado prático é: troca de fornecedor em uma linha, testes unitários sem mocks complexos, e regras de negócio que sobrevivem à rotatividade de bibliotecas.