DEV Community

Cover image for Construindo um hub de notificações desacoplado com NestJS, Nx e Design Patterns
Carolina Gonçalves
Carolina Gonçalves

Posted on

Construindo um hub de notificações desacoplado com NestJS, Nx e Design Patterns

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:

  1. Eu criaria um arquivo novo (o Adapter).
  2. Mudaria uma linha de configuração.
  3. 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>;
}
Enter fullscreen mode Exit fullscreen mode

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>`,
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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}`);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

O Factory recebe os providers via Injeção de Dependência do NestJS. Para adicionar um novo provider, basta:

  1. Criar a classe do Adapter.
  2. Injetar no Factory.
  3. Adicionar um case no 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}`,
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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.

Demonstração do envio de email: Clique para assistir ao vídeo em alta resolução


Escalabilidade na Prática

Supondo que agora seja necessário adicionar o SendGrid, quantos arquivos precisam ser alterados?

  1. Criar o Adapter (sendgrid.provider.ts) implementando NotificationProvider.
  2. Atualizar o Factory, adicionando o novo provider.
  3. 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
}
Enter fullscreen mode Exit fullscreen mode

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.

github.com/carol8fml/nexus-notification-hub. 🤟🏾

Top comments (0)