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.
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
// ❌ 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ócioA 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:
// 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>;
}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,
});
}
}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:
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:
// 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:
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.