Nota para desenvolvedores: Se você prefere ir direto ao código, o repositório completo com a implementação desta arquitetura está disponível aqui: github.com/carol8fml/nexus-notification-hub.
Olá, pessoal! 🤟🏾
Um tempo atrás, na empresa onde eu trabalho, recebi do gestor de engenharia a demanda de fazer uma análise de serviços externos para envio transacional de e-mail. Na época, usávamos o SendGrid em quatro aplicações web e precisávamos expandir para o aplicativo principal da empresa.
A previsão era escalar para cerca de 250.000 envios por mês. Precisávamos comparar preços e funcionalidades de outras plataformas para validar se a troca fazia sentido.
Durante a análise, avaliei mais de 10 serviços similares. Encontrei opções mais baratas e com mais funcionalidades, mas o custo real eu só descobri no final: seria a mão de obra dos desenvolvedores.
Para trocar o serviço nas quatro aplicações que já tinham integrado o SendGrid, o custo seria altíssimo. O motivo? A lógica estava totalmente acoplada aos repositórios, tanto nos que utilizavam programação funcional quanto naqueles que já eram considerados ‘quase arquitetura limpa’.
Estipulei junto com o time que essa troca levaria meses para se encaixar nas Sprints. Teríamos que tirar desenvolvedores das features principais apenas para essa refatoração.
No final, o resultado foi continuar com o SendGrid, mesmo não sendo a melhor opção financeira na hora. A empresa ficou refém daquele serviço por uma limitação técnica.
Foi dessa experiência que nasceu a ideia do Nexus Notification Hub.
Durante meus estudos para aprofundar conhecimentos em Arquitetura de Software, decidi que não queria apenas ler sobre padrões; eu queria aplicar a teoria para resolver problemas reais que enfrentei na carreira. E o objetivo dessa POC foi validar uma arquitetura que evitasse esse tipo de acoplamento, utilizando uma stack robusta (NestJS, Nx) aliada a padrões de projeto clássicos, principalmente o Adapter Pattern.
Se tivéssemos essa arquitetura desenhada na época, o cenário provavelmente seria outro:
- Eu criaria um arquivo novo (o Adapter).
- Mudaria uma linha de configuração.
- Pronto.
A seguir, detalho como implementei essa arquitetura na prática, criando uma solução onde trocar de SendGrid para AWS (ou qualquer outro provedor) leva minutos, e não meses.
A Arquitetura: Adapter Pattern na Prática
A solução se baseia em dois padrões clássicos trabalhando em conjunto: o Adapter Pattern e o Factory Pattern.
Optei pelo Adapter para evitar que a regra de negócio conhecesse detalhes de provedores externos. Já o Factory entrou para centralizar a escolha do provedor e evitar condicionais espalhadas pela aplicação.
A ideia geral é criar uma camada de abstração que isola completamente a lógica de negócio das implementações específicas de cada serviço de e-mail.
1. O Contrato: A Interface NotificationProvider
Tudo começa com uma interface que define o contrato mínimo que qualquer provedor de notificação precisa seguir:
export interface NotificationProvider {
send(to: string, content: string): Promise<void>;
}
Essa interface é o coração da arquitetura. Ela estabelece que, independente de estarmos usando SendGrid, AWS SES, Mailtrap ou qualquer outro serviço, todos precisam implementar o método send com essa assinatura exata.
Na prática: O resto do código (Controller, Factory, Use Cases) nunca precisa saber qual serviço está sendo usado. Ele trabalha apenas com esse contrato.
2. A Implementação: MailtrapProvider como Adapter
Cada serviço de e-mail possui sua própria forma de integração (API REST, SDK proprietário, SMTP, etc.). O Adapter Pattern resolve isso criando uma "tradução" entre a interface comum e a implementação específica.
Veja como ficou o adapter do Mailtrap:
@Injectable()
export class MailtrapProvider implements NotificationProvider {
private transporter: nodemailer.Transporter;
constructor(private configService: ConfigService) {
this.transporter = nodemailer.createTransport({
host: this.configService.get<string>('MAILTRAP_HOST'),
port: this.configService.get<number>('MAILTRAP_PORT'),
auth: {
user: this.configService.get<string>('MAILTRAP_USER'),
pass: this.configService.get<string>('MAILTRAP_PASS'),
},
});
}
async send(to: string, content: string): Promise<void> {
// A "tradução" acontece aqui
await this.transporter.sendMail({
from: 'noreply@nexus.com',
to,
subject: 'Notification from Nexus',
text: content,
html: `<p>${content}</p>`,
});
}
}
Aqui toda a complexidade do nodemailer e da configuração do Mailtrap fica encapsulada. Se amanhã surgir a necessidade de trocar para SendGrid, basta criar um SendGridProvider que implemente a mesma interface. O restante do sistema continua funcionando sem alteração.
3. O Factory: Centralizando a Decisão
O Factory Pattern centraliza a lógica de escolha do provedor, evitando if/else espalhados pelo código:
@Injectable()
export class NotificationFactory {
constructor(private mailtrapProvider: MailtrapProvider) {}
getProvider(type: 'email'): NotificationProvider {
switch (type) {
case 'email':
return this.mailtrapProvider;
default:
throw new Error(`Unsupported notification type: ${type}`);
}
}
}
O Factory recebe os providers via Injeção de Dependência do NestJS. Para adicionar um novo provider, basta:
- Criar a classe do Adapter.
- Injetar no Factory.
- Adicionar um
caseno switch.
4. O Controller: Trabalhando com Abstrações
No Controller, o desacoplamento fica evidente. Ele não sabe nada sobre Mailtrap ou SendGrid:
@Controller('api/notifications')
export class NotificationsController {
constructor(private notificationFactory: NotificationFactory) {}
@Post()
async sendNotification(@Body() dto: SendNotificationDto) {
const provider = this.notificationFactory.getProvider(dto.type);
await provider.send(dto.destination, dto.content);
return {
success: true,
message: `Notification sent via ${dto.type}`,
};
}
}
Nota arquitetural: Neste exemplo didático, o cliente define o tipo de notificação via DTO. Em um cenário real, essa decisão poderia ser tomada automaticamente pelo backend (usando regras de failover, custo ou disponibilidade), mantendo o cliente agnóstico quanto ao provedor.
5. A Cola: Injeção de Dependência (NestJS)
O NestJS gerencia o ciclo de vida e a injeção dessas classes através do módulo:
@Module({
controllers: [NotificationsController],
providers: [NotificationFactory, MailtrapProvider],
})
export class NotificationsModule {}
Vendo na Prática
Abaixo, uma demonstração do fluxo completo: o frontend (React) enviando uma requisição para o backend (NestJS), que utiliza o Adapter do Mailtrap para realizar o envio.
Escalabilidade na Prática
Supondo que agora seja necessário adicionar o SendGrid, quantos arquivos precisam ser alterados?
-
Criar o Adapter (
sendgrid.provider.ts) implementandoNotificationProvider. - Atualizar o Factory, adicionando o novo provider.
- Atualizar o Módulo, registrando o provider.
Resultado:
O Controller permanece intacto.
A lógica de negócio permanece intacta.
Os testes existentes continuam válidos.
Bônus: DTOs Compartilhados (Nx Monorepo)
Como estamos utilizando Nx, aproveitei para compartilhar os DTOs entre Frontend e Backend, garantindo consistência contratual entre as aplicações.
export class SendNotificationDto {
@IsEnum(['email'])
@IsNotEmpty()
type!: 'email';
@IsString()
@IsNotEmpty()
destination!: string;
// ... outros campos
}
Se o contrato for alterado, o build do Frontend e do Backend falha imediatamente, evitando inconsistências silenciosas de integração.
Conclusão: A Liberdade da Abstração
O Nexus Notification Hub surgiu como uma resposta técnica para um impasse de negócio que vivi no passado. Um problema que antes exigiria meses de refatoração passa a ser resolvido em poucas horas.
Mais do que aplicar padrões ou frameworks, a principal lição aqui foi perceber como decisões arquiteturais influenciam diretamente a capacidade de adaptação de um sistema.
Naquela época, ficamos reféns do SendGrid não porque ele era insubstituível, mas porque o código estava estruturado dessa forma. Com uma arquitetura baseada em abstrações, a decisão volta para as mãos do time e do negócio.
Ao mesmo tempo, esse tipo de abordagem também adiciona complexidade e só faz sentido quando existe uma possibilidade real de troca de fornecedor ou crescimento do sistema. Caso contrário, pode virar apenas overengineering.
Se hoje surgisse novamente a necessidade de escalar para 250.000 envios mensais trocando de fornecedor, a resposta provavelmente não seria mais "não dá". Seria algo próximo de:
"Ok, me dá uma tarde para implementar o novo Adapter."
Hoje entendo que essa diferença está menos ligada a escrever código e mais a construir soluções que conseguem evoluir com o tempo.
Código Fonte
O projeto completo, incluindo a configuração do Monorepo Nx, o frontend em React e todos os testes, está disponível no GitHub.

Top comments (0)