Pular para o conteúdo principal

Clean Architecture Backend

O que é Clean Architecture?

Imagine construir uma casa onde você pode trocar a cozinha sem afetar o quarto, ou mudar o sistema elétrico sem mexer no encanamento. Clean Architecture faz isso com código: separa o que importa (regras de negócio) do que pode mudar (tecnologia).

Problema que Resolve

Arquitetura ruim:

Regras de Negócio ← misturado com → Banco de Dados
↕ ↕
API Gateway Framework

Se mudar banco, quebra tudo! ❌

Clean Architecture:

Regras de Negócio (centro, protegido)

Adapters conectam com tecnologia (AWS, Prisma, etc)

Pode trocar tecnologia sem afetar regras! ✅

Filosofia em Uma Frase

"Lambda, Fastify, Prisma e SQS são detalhes que podem ser trocados sem afetar o que realmente importa: as regras de negócio."

Como Funciona na Prática?

Estrutura: Um Shopping com Andares Bem Definidos

🏢 PRÉDIO BACKEND BITIO

4º Andar - INTERFACES │ 🚪 "Portarias de Entrada"
│ Lambda, Worker, Cron Jobs
───────────────────────────────┼─────────────────────────────
3º Andar - APPLICATION │ 🎯 "Gerência de Casos de Uso"
│ Orquestra regras de negócio
───────────────────────────────┼─────────────────────────────
2º Andar - DOMAIN │ 💎 "Cofre das Regras"
│ Entidades, regras, validações
───────────────────────────────┼─────────────────────────────
1º Andar - INFRASTRUCTURE │ 🔧 "Subsolo Técnico"
│ Prisma, SQS, S3, APIs externas

Regra de Ouro

Dependências sempre apontam para CIMA - camadas inferiores nunca conhecem camadas superiores:

  • ✅ Application pode usar Domain
  • ✅ Infrastructure implementa interfaces do Domain
  • ❌ Domain NUNCA importa Infrastructure
  • ❌ Domain NUNCA conhece AWS, HTTP, etc

Organização por Domínio de Negócio

Cada módulo representa uma área de negócio específica do restaurante, como se fossem departamentos independentes de uma empresa:

src/modules/
├── cardapio/ # 🍽️ Departamento de Cardápio
│ ├── domain/ # ├─ Regras sobre produtos, categorias
│ ├── application/ # ├─ Casos de uso (criar produto, etc)
│ └── infrastructure/ # └─ Como salvar no banco
├── clientes/ # 👥 Departamento de Clientes
├── reservas/ # 📅 Departamento de Reservas
├── fidelidade/ # 💎 Programa de Fidelidade
├── campanhas/ # 📱 Marketing e Comunicação
├── avaliacoes/ # ⭐ Reputação e Reviews
└── publicidade/ # 📊 Anúncios e Mídia Paga

Regra de Isolamento entre Departamentos

❌ ERRADO: Departamento de Fidelidade acessando tabela de Campanhas
❌ ERRADO: Módulo de Reservas importando repositório de Clientes

✅ CORRETO: Comunicação via casos de uso públicos
✅ CORRETO: Comunicação via eventos assíncronos
✅ CORRETO: Comunicação via IDs (nunca objetos completos)

Exemplo Prático

// ❌ ERRADO - módulo fidelidade acessando diretamente
async function calcularPontos(clienteId: string) {
// Não faça isso!
const campanhas = await campanhasRepository.buscarAtivas();
}

// ✅ CORRETO - comunicação via caso de uso
async function calcularPontos(clienteId: string) {
const campanhasAtivas = await buscarCampanhasAtivasUseCase.execute();
}

// ✅ CORRETO - comunicação via evento
async function onReservaConfirmada(evento: ReservaConfirmadaEvent) {
await adicionarPontosFidelidadeUseCase.execute({
clienteId: evento.clienteId,
pontos: 100
});
}

As 4 Camadas Explicadas

1º Andar: Infrastructure (O Subsolo Técnico)

Função: Implementar conexões com tecnologias externas

É como o subsolo de um prédio - onde ficam os sistemas técnicos:

  • Gerador (banco de dados)
  • Bombas d'água (APIs externas)
  • Sistema elétrico (SQS, S3)
  • Elevadores (conexões entre sistemas)
// Como salvamos uma reserva no banco (Prisma)
export class PrismaReservaRepository implements ReservaRepository {
constructor(private readonly prisma: PrismaClient) {}

async salvar(reserva: Reserva): Promise<void> {
// ✅ Converte objeto do domínio para formato do banco
const dadosPrisma = ReservaMapper.paraPrisma(reserva);

await this.prisma.reserva.create({
data: {
...dadosPrisma,
// ✅ SEMPRE filtrar por restaurante (multi-tenant)
restauranteId: reserva.restauranteId,
}
});
}
}

// Como publicamos uma mensagem (SQS)
export class SQSPublisherMessage implements PublicadorMensagem {
async publicar(topico: string, evento: EventoDominio): Promise<void> {
await this.sqsClient.sendMessage({
QueueUrl: this.obterUrlFila(topico),
MessageBody: JSON.stringify(evento),
});
}
}

2º Andar: Domain (O Cofre das Regras)

Função: Guardar as regras de negócio mais importantes

É como o cofre do banco - onde ficam as coisas mais valiosas e protegidas:

  • Regras que nunca mudam (validações de negócio)
  • Entidades principais (Cliente, Reserva, Produto)
  • Validações críticas (não pode reservar no passado)
// Entidade Reserva com suas regras
export class Reserva {
private constructor(
private readonly _id: ReservaId,
private readonly _restauranteId: RestauranteId,
private readonly _dadosCliente: DadosCliente,
private readonly _dataHora: DataHora,
private readonly _numeroPessoas: NumeroPessoas,
private _status: StatusReserva
) {}

static criar(dados: CriarReservaInput): Reserva {
// ✅ Regras de negócio sempre validadas no domínio
if (dados.dataHora.ePassado()) {
throw new DataReservaInvalidaError("Não é possível reservar no passado");
}

if (dados.numeroPessoas > 12) {
throw new MuitasPessoasError("Máximo 12 pessoas por reserva");
}

return new Reserva(
ReservaId.gerar(),
dados.restauranteId,
dados.dadosCliente,
dados.dataHora,
dados.numeroPessoas,
StatusReserva.PENDENTE
);
}

confirmar(): void {
// ✅ Regra de negócio: só pode confirmar se estiver pendente
if (this._status !== StatusReserva.PENDENTE) {
throw new ReservaJaProcessadaError();
}
this._status = StatusReserva.CONFIRMADA;
}
}

3º Andar: Application (A Gerência)

Função: Orquestrar casos de uso e coordenar o trabalho

É como a sala da diretoria - onde as decisões são tomadas e o trabalho é coordenado:

  • Recebe solicitações
  • Coordena diferentes departamentos
  • Aplica regras de negócio
  • Controla transações
export class CriarReservaUseCase {
constructor(
private readonly repoReservas: ReservaRepository, // Interface
private readonly publicador: PublicadorMensagem, // Interface
private readonly servicoDisponibilidade: ServicoDisponibilidade // Interface
) {}

async executar(input: CriarReservaInput): Promise<Reserva> {
// ✅ 1. Verificar conflitos
const conflitos = await this.repoReservas.buscarConflitos(
input.restauranteId,
input.dataHora,
input.numeroPessoas
);

if (conflitos.length > 0) {
throw new ConflitoReservaError();
}

// ✅ 2. Verificar disponibilidade via serviço externo
const disponivel = await this.servicoDisponibilidade.verificarVaga(
input.restauranteId,
input.dataHora,
input.numeroPessoas
);

if (!disponivel) {
throw new VagaIndisponivelError();
}

// ✅ 3. Criar entidade (regras aplicadas automaticamente)
const reserva = Reserva.criar(input);

// ✅ 4. Salvar no repositório
await this.repoReservas.salvar(reserva);

// ✅ 5. Avisar outros sistemas via evento
await this.publicador.publicar(
'confirmacoes-reserva',
reserva.gerarEvento()
);

return reserva;
}
}

4º Andar: Interfaces (As Portarias)

Função: Receber requisições do mundo exterior

É como as diferentes portarias de entrada do prédio:

  • Portaria principal (Lambda API)
  • Portaria de funcionários (Worker)
  • Portaria de serviços (Cron Jobs)
// Portaria HTTP (Lambda)
export const criarReservaHandler = async (event: APIGatewayEvent) => {
const container = criarContainerDI();
const casoUso = container.obter<CriarReservaUseCase>('CriarReservaUseCase');

try {
const input = EsquemaCriarReserva.parse(JSON.parse(event.body));
const reserva = await casoUso.executar(input);

return respostaSucesso(201, ReservaDTO.doDominio(reserva));
} catch (erro) {
return tratarErroDominio(erro);
}
};

// Portaria de Mensagens (Worker)
export class ConsumidorConfirmacaoReserva {
constructor(private readonly casoUso: EnviarConfirmacaoReservaUseCase) {}

async processarMensagem(mensagem: MensagemSQS): Promise<void> {
const evento = EsquemaEventoReserva.parse(mensagem.body);

await this.casoUso.executar({
reservaId: evento.reservaId,
emailCliente: evento.emailCliente,
restauranteId: evento.restauranteId,
});
}
}

Comunicação Entre Departamentos (Módulos)

Como os Departamentos Se Comunicam

SituaçãoEstratégiaExemplo
Dados Críticos em Tempo RealVia caso de uso públicoFidelidade consulta dados de Cliente
Notificações e Side EffectsVia eventos assíncronosReserva confirmada → enviar WhatsApp
Relatórios e ConsultasVia SQL diretoDashboard com dados de múltiplos módulos

Eventos de Domínio (Comunicação Assíncrona)

// ✅ Quando reserva é criada, outros módulos reagem
export class ReservaCriadaEvent {
constructor(
public readonly reservaId: string,
public readonly restauranteId: string,
public readonly clienteEmail: string,
public readonly dataHora: Date,
public readonly ocorreuEm: Date = new Date()
) {}
}

// Outros módulos ouvem e reagem:
// 📱 Módulo Messaging: envia confirmação WhatsApp
// 💎 Módulo Fidelidade: adiciona pontos ao cliente
// 📊 Módulo Analytics: registra métrica de reserva

Tratamento de Erros de Negócio

Hierarquia de Erros Clara

// Base para todos os erros de domínio
export abstract class ErroDominio extends Error {
abstract readonly codigo: string;
abstract readonly statusHTTP: number;
}

// Erros específicos do domínio de reservas
export class ConflitoReservaError extends ErroDominio {
readonly codigo = 'CONFLITO_RESERVA';
readonly statusHTTP = 409;

constructor() {
super('Já existe uma reserva para este horário');
}
}

export class DataReservaInvalidaError extends ErroDominio {
readonly codigo = 'DATA_INVALIDA';
readonly statusHTTP = 400;

constructor(mensagem: string) {
super(mensagem);
}
}

Tratamento no Handler (Interface)

function tratarErroDominio(erro: unknown): APIGatewayProxyResult {
if (erro instanceof ErroDominio) {
return {
statusCode: erro.statusHTTP,
body: JSON.stringify({
erro: erro.codigo,
mensagem: erro.message,
})
};
}

// Log de erro inesperado + resposta genérica
logger.error('Erro inesperado', { erro });
return {
statusCode: 500,
body: JSON.stringify({ erro: 'ERRO_INTERNO' })
};
}

Estratégia de Testes

Pirâmide de Testes (Foco no que Importa)

TipoFocoQuantidadeFerramentas
Unit (maioria)Domínio + casos de uso> 80%Vitest + mocks
IntegrationRepositórios + PrismaCasos críticosVitest + test DB
ContractEventos entre módulosInterfaces públicasZod + Pact
E2E (poucos)Fluxos principaisJornadas críticasPlaywright

Exemplo: Teste de Caso de Uso

describe('CriarReservaUseCase', () => {
let casoUso: CriarReservaUseCase;
let mockRepo: jest.Mocked<ReservaRepository>;
let mockPublicador: jest.Mocked<PublicadorMensagem>;

beforeEach(() => {
mockRepo = {
salvar: jest.fn(),
buscarConflitos: jest.fn().mockResolvedValue([]),
};

mockPublicador = {
publicar: jest.fn(),
};

casoUso = new CriarReservaUseCase(mockRepo, mockPublicador);
});

it('deve criar reserva quando não há conflitos', async () => {
// Arrange
const input = {
restauranteId: 'rest-123',
dadosCliente: dadosClienteValidos,
dataHora: addDays(new Date(), 1),
numeroPessoas: 4,
};

// Act
const reserva = await casoUso.executar(input);

// Assert
expect(reserva.status).toBe(StatusReserva.PENDENTE);
expect(mockRepo.salvar).toHaveBeenCalledWith(reserva);
expect(mockPublicador.publicar).toHaveBeenCalledWith(
'confirmacoes-reserva',
expect.any(ReservaCriadaEvent)
);
});

it('deve lançar erro quando há conflitos', async () => {
// Arrange
mockRepo.buscarConflitos.mockResolvedValue([reservaExistente]);

// Act & Assert
await expect(casoUso.executar(inputValido))
.rejects.toThrow(ConflitoReservaError);
});
});

Vantagens da Clean Architecture

Por que Essa Organização Funciona?

VantagemExplicaçãoExemplo Prático
TestabilidadeDomínio testável sem infraestruturaTesta regras de negócio sem banco rodando
FlexibilidadeTrocar tecnologia sem afetar regrasMigrar de Prisma para TypeORM = só camada infra
ManutenibilidadeResponsabilidades claras por camadaBug de validação? Está sempre no Domain
Evolução IndependenteMódulos evoluem sem se afetarMelhorar Fidelidade não quebra Reservas

Fluxo Completo de uma Funcionalidade

// 1. INTERFACE: Recebe requisição
export const criarReservaHandler = async (event: APIGatewayEvent) => {
const casoUso = container.obter<CriarReservaUseCase>('CriarReservaUseCase');
const input = validarInput(event.body);
const reserva = await casoUso.executar(input); // → Application
return respostaSucesso(201, reserva);
};

// 2. APPLICATION: Orquestra o trabalho
class CriarReservaUseCase {
async executar(input) {
const reserva = Reserva.criar(input); // → Domain
await this.repo.salvar(reserva); // → Infrastructure
await this.publicador.publicar(evento); // → Infrastructure
return reserva;
}
}

// 3. DOMAIN: Aplica regras de negócio
class Reserva {
static criar(input) {
if (input.dataHora.ePassado()) throw new ErroDataInvalida();
return new Reserva(/* ... */);
}
}

// 4. INFRASTRUCTURE: Executa operação técnica
class PrismaReservaRepository {
async salvar(reserva: Reserva) {
await this.prisma.reserva.create(/* ... */);
}
}

Checklist: Como Criar Novo Caso de Uso

  1. Modelar entidade no domain/ com suas regras
  2. Definir interfaces necessárias (repositórios, publishers)
  3. Escrever caso de uso em application/
  4. Implementar adapters em infrastructure/ (Prisma, SQS...)
  5. Configurar injeção de dependências no container
  6. Criar handler (Lambda) ou consumer (Worker) em interfaces/
  7. Garantir isolamento por restauranteId (multi-tenant)
  8. Escrever testes (domínio + integração)
  9. Emitir eventos se outros módulos precisam saber

Resumo: O Poder da Clean Architecture

A Transformação

ANTES (Código Acoplado):
Mudança no banco → quebra API → quebra frontend → quebra tudo! 💥

DEPOIS (Clean Architecture):
Mudança no banco → só troca adapter → resto funciona normal! ✅

Benefícios Reais

  • Equipe cresce mais rápido: Novos devs entendem padrões claros
  • Menos bugs: Regras centralizadas no domínio
  • Evolução segura: Pode modernizar tecnologia sem medo
  • Testes confiáveis: Domínio testado independentemente

Clean Architecture coloca o negócio no centro e torna tecnologia um detalhe intercambiável. É a base que permite ao BITIO crescer sem perder qualidade.