Arquitetura de Software: SOLID, Clean Architecture e DDD

[130] Arquitetura de Software: SOLID, Clean Architecture e DDD

Se houver uma frase para levar daqui, é a regra das dependências: elas apontam para dentro. O domínio não sabe que existe MongoDB nem Express, e é essa ignorância que permite testar regra de negócio sem subir banco. SOLID, as quatro camadas da Clean Architecture, entidades, casos de uso e Value Objects do DDD.
Javascript

44 min de leitura

Módulo 8 — Arquitetura e Padrões

Introdução

Você já sabe escrever código que funciona. Os módulos anteriores ensinaram as ferramentas — Node.js, React, testes, deploy, segurança, performance e padrões de projeto. Agora chegamos à camada mais abstrata e talvez mais importante: como organizar o código de forma que ele continue funcionando e seja fácil de modificar conforme o sistema cresce.

Um sistema que não foi arquitetado conscientemente tende a se transformar em uma bola de lama — código onde tudo depende de tudo, onde mudar uma coisa quebra outra aparentemente não relacionada, onde adicionar uma feature leva semanas porque ninguém entende como as peças se encaixam. Já um sistema bem arquitetado parece ter "lugares naturais" para novas funcionalidades. As regras de negócio estão claras e isoladas. Os testes são fáceis de escrever. O time consegue trabalhar em paralelo sem pisar nos pés uns dos outros.

Este artigo cobre três grandes pilares da boa arquitetura: os princípios SOLID, a Clean Architecture de Robert C. Martin e uma introdução ao Domain-Driven Design de Eric Evans. Cada um resolve o problema da organização de uma perspectiva diferente — juntos, formam uma base sólida para construir sistemas que duram.

SOLID — cinco princípios para código orientado a objetos

SOLID é um acrônimo para cinco princípios formulados por Robert C. Martin. Cada princípio resolve uma forma específica de rigidez ou fragilidade no código.

S — Single Responsibility Principle (Princípio da Responsabilidade Única)

Uma classe ou módulo deve ter apenas uma razão para mudar. Quando um módulo tem múltiplas responsabilidades, mudanças em uma podem quebrar as outras — mesmo que não haja relação aparente.

A forma mais simples de verificar se um módulo viola este princípio é perguntar: "que tipos de alteração fariam eu mudar este arquivo?" Se a resposta incluir mais de uma categoria de mudança, o módulo tem mais de uma responsabilidade.

// ❌ VIOLAÇÃO — este serviço faz três coisas distintas:
// processa lógica de negócio, formata emails E interage com o banco
class ServicoTarefa {
  async criar(usuarioId, dados) {
    // 1. Valida dados (regra de negócio)
    if (!dados.titulo || dados.titulo.length < 2) {
      throw new Error('Título muito curto.');
    }

    // 2. Salva no banco (persistência)
    const tarefa = await Tarefa.create({ ...dados, usuario: usuarioId });

    // 3. Formata e envia email (notificação)
    const html = `
      <h1>Nova Tarefa</h1>
      <p>Sua tarefa <strong>${tarefa.titulo}</strong> foi criada.</p>
      <p>Prazo: ${new Date(tarefa.prazo).toLocaleDateString('pt-BR')}</p>
    `;
    await transporter.sendMail({
      to: dados.email,
      subject: 'Tarefa criada',
      html,
    });

    return tarefa;
  }
}

// ✅ SRP — cada classe tem uma única razão para mudar

// Razão para mudar: regras de negócio de tarefas
class ValidadorTarefa {
  validar(dados) {
    const erros = [];
    if (!dados.titulo || dados.titulo.trim().length < 2) {
      erros.push('Título deve ter pelo menos 2 caracteres.');
    }
    if (dados.prazo && new Date(dados.prazo) < new Date()) {
      erros.push('Prazo não pode ser no passado.');
    }
    if (erros.length > 0) throw new Error(erros.join(' '));
  }
}

// Razão para mudar: como as tarefas são persistidas
class RepositorioTarefa {
  async criar(dados) {
    return Tarefa.create(dados);
  }

  async buscarPorId(id) {
    return Tarefa.findById(id).lean();
  }
}

// Razão para mudar: template e canal de notificação
class NotificadorTarefa {
  constructor(emailService) {
    this.emailService = emailService;
  }

  async notificarCriacao(tarefa, usuario) {
    await this.emailService.enviar({
      para: usuario.email,
      assunto: 'Tarefa criada',
      corpo: `Sua tarefa "${tarefa.titulo}" foi criada com sucesso.`,
    });
  }
}

// Orquestra as outras classes — razão para mudar: fluxo de criação
class ServicoTarefa {
  constructor(validador, repositorio, notificador) {
    this.validador = validador;
    this.repositorio = repositorio;
    this.notificador = notificador;
  }

  async criar(usuario, dados) {
    this.validador.validar(dados);
    const tarefa = await this.repositorio.criar({ ...dados, usuario: usuario._id });
    await this.notificador.notificarCriacao(tarefa, usuario);
    return tarefa;
  }
}

O — Open/Closed Principle (Princípio Aberto/Fechado)

Entidades de software devem ser abertas para extensão, mas fechadas para modificação. Quando você adiciona uma funcionalidade nova, deve ser possível fazê-lo sem modificar código que já funciona e já foi testado.

A chave para aplicar este princípio é identificar os "pontos de variação" — as partes que tendem a mudar — e abstraí-los em interfaces ou funções de ordem superior.

// ❌ VIOLAÇÃO — cada novo tipo de relatório exige modificar gerarRelatorio()

class GeradorRelatorio {
  gerar(dados, tipo) {
    if (tipo === 'csv') {
      // formata como CSV
      return dados.map((d) => `${d.id},${d.titulo},${d.status}`).join('\n');
    } else if (tipo === 'json') {
      return JSON.stringify(dados, null, 2);
    } else if (tipo === 'html') {
      const linhas = dados.map(
        (d) => `<tr><td>${d.id}</td><td>${d.titulo}</td></tr>`
      );
      return `<table>${linhas.join('')}</table>`;
    }
    // Adicionar PDF, XML, XLSX... sempre modifica esta função
    throw new Error(`Formato desconhecido: ${tipo}`);
  }
}

// ✅ OCP — adicionar novos formatos sem modificar código existente

// Contrato que todo formatador deve seguir
class FormataRelatorio {
  formatar(dados) {
    throw new Error('formatar() deve ser implementado.');
  }
}

class FormataCSV extends FormataRelatorio {
  formatar(dados) {
    const cabecalho = 'id,titulo,status';
    const linhas = dados.map((d) => `${d.id},"${d.titulo}",${d.status}`);
    return [cabecalho, ...linhas].join('\n');
  }
}

class FormataJSON extends FormataRelatorio {
  formatar(dados) {
    return JSON.stringify(dados, null, 2);
  }
}

class FormataHTML extends FormataRelatorio {
  formatar(dados) {
    const linhas = dados
      .map((d) => `<tr><td>${d.id}</td><td>${d.titulo}</td><td>${d.status}</td></tr>`)
      .join('');
    return `<table><thead><tr><th>ID</th><th>Título</th><th>Status</th></tr></thead><tbody>${linhas}</tbody></table>`;
  }
}

// O gerador está fechado para modificação — não precisa mudar para novos formatos
class GeradorRelatorio {
  constructor(formatadores = {}) {
    this.formatadores = {
      csv: new FormataCSV(),
      json: new FormataJSON(),
      html: new FormataHTML(),
      ...formatadores, // permite injetar formatadores customizados
    };
  }

  gerar(dados, tipo) {
    const formatador = this.formatadores[tipo];
    if (!formatador) throw new Error(`Formato desconhecido: ${tipo}`);
    return formatador.formatar(dados);
  }
}

// Adicionar suporte a PDF = criar uma nova classe, não modificar as existentes
class FormataPDF extends FormataRelatorio {
  formatar(dados) {
    // geração de PDF com pdfkit ou similar...
  }
}

const gerador = new GeradorRelatorio({ pdf: new FormataPDF() });

L — Liskov Substitution Principle (Princípio da Substituição de Liskov)

Subtipos devem ser substituíveis por seus tipos base sem alterar a corretude do programa. Em termos práticos: se você tem código que funciona com uma classe base, ele deve funcionar da mesma forma com qualquer subclasse, sem surpresas ou comportamentos inesperados.

// ❌ VIOLAÇÃO — RetanguloReadonly quebra o contrato de Retangulo
// porque alterar largura não funciona como esperado

class Retangulo {
  constructor(largura, altura) {
    this.largura = largura;
    this.altura = altura;
  }

  setLargura(valor) { this.largura = valor; }
  setAltura(valor) { this.altura = valor; }
  area() { return this.largura * this.altura; }
}

class RetanguloReadonly extends Retangulo {
  setLargura() { throw new Error('Imutável!'); } // quebra o contrato!
  setAltura() { throw new Error('Imutável!'); }
}

// Código que funciona com Retangulo quebra com RetanguloReadonly
function redimensionar(retangulo) {
  retangulo.setLargura(10); // lança erro inesperado com RetanguloReadonly
  retangulo.setAltura(5);
  console.log(retangulo.area()); // nunca alcança esta linha
}

// ✅ LSP — subclasses honram o contrato da classe base

// No contexto de repositórios: diferentes implementações do mesmo contrato
class RepositorioBase {
  async buscarPorId(id) { throw new Error('Não implementado.'); }
  async listar(filtros) { throw new Error('Não implementado.'); }
  async salvar(dados) { throw new Error('Não implementado.'); }
  async deletar(id) { throw new Error('Não implementado.'); }
}

// Implementação MongoDB — honra o contrato completamente
class RepositorioMongoDB extends RepositorioBase {
  constructor(model) {
    super();
    this.model = model;
  }

  async buscarPorId(id) { return this.model.findById(id).lean(); }
  async listar(filtros) { return this.model.find(filtros).lean(); }
  async salvar(dados) { return this.model.create(dados); }
  async deletar(id) { return this.model.findByIdAndDelete(id); }
}

// Implementação em memória para testes — também honra o contrato
// O ServicoTarefa funciona com ambos sem modificação
class RepositorioEmMemoria extends RepositorioBase {
  constructor() {
    super();
    this.dados = new Map();
    this.contador = 0;
  }

  async buscarPorId(id) {
    return this.dados.get(id) || null;
  }

  async listar(filtros) {
    const itens = [...this.dados.values()];
    return Object.entries(filtros).reduce(
      (acc, [chave, valor]) => acc.filter((i) => i[chave] === valor),
      itens
    );
  }

  async salvar(dados) {
    const id = String(++this.contador);
    const item = { ...dados, _id: id, criadoEm: new Date() };
    this.dados.set(id, item);
    return item;
  }

  async deletar(id) {
    const item = this.dados.get(id);
    this.dados.delete(id);
    return item;
  }
}

I — Interface Segregation Principle (Princípio da Segregação de Interface)

Clientes não devem ser obrigados a depender de interfaces que não usam. Interfaces grandes e gordas forçam implementadores a definir métodos que nunca vão usar — criando acoplamento desnecessário.

// ❌ VIOLAÇÃO — ServicoRelatorio é obrigado a implementar métodos
// de autenticação que não têm nada a ver com relatórios

class ServicoCompleto {
  // Autenticação
  async login(email, senha) { /* ... */ }
  async logout(token) { /* ... */ }
  async verificarToken(token) { /* ... */ }

  // Relatórios
  async gerarRelatorio(tipo, filtros) { /* ... */ }
  async exportarCSV(dados) { /* ... */ }

  // Notificações
  async enviarEmail(para, assunto, corpo) { /* ... */ }
  async enviarSMS(numero, mensagem) { /* ... */ }
}

// ServicoRelatorio é forçado a "implementar" login/logout que não usa
class ServicoRelatorio extends ServicoCompleto {
  async login() { throw new Error('Não suportado.'); }   // forçado!
  async logout() { throw new Error('Não suportado.'); }  // forçado!
}

// ✅ ISP — interfaces pequenas e coesas

// Cada interface tem apenas o necessário para seu papel
class Autenticavel {
  async login(email, senha) { throw new Error('Não implementado.'); }
  async logout(token) { throw new Error('Não implementado.'); }
  async verificarToken(token) { throw new Error('Não implementado.'); }
}

class Relatorio {
  async gerar(tipo, filtros) { throw new Error('Não implementado.'); }
  async exportar(dados, formato) { throw new Error('Não implementado.'); }
}

class Notificavel {
  async enviarEmail(para, assunto, corpo) { throw new Error('Não implementado.'); }
}

// Cada serviço implementa apenas o que precisa
class ServicoAuth extends Autenticavel {
  async login(email, senha) { /* lógica de login */ }
  async logout(token) { /* lógica de logout */ }
  async verificarToken(token) { /* verifica JWT */ }
}

class ServicoRelatorio extends Relatorio {
  async gerar(tipo, filtros) { /* gera relatório */ }
  async exportar(dados, formato) { /* exporta */ }
  // Não precisa saber nada sobre login ou email
}

class ServicoEmail extends Notificavel {
  async enviarEmail(para, assunto, corpo) { /* envia email */ }
}

D — Dependency Inversion Principle (Princípio da Inversão de Dependência)

Módulos de alto nível não devem depender de módulos de baixo nível. Ambos devem depender de abstrações. Abstrações não devem depender de detalhes — detalhes devem depender de abstrações.

Em termos práticos: seu código de negócio não deve depender diretamente de mongoose, nodemailer ou bcrypt. Deve depender de interfaces que essas bibliotecas implementam. Isso torna o código testável sem banco de dados e permite trocar implementações sem tocar na lógica de negócio.

// ❌ VIOLAÇÃO — ServicoTarefa depende diretamente do Mongoose
// Impossível testar sem um banco MongoDB rodando

class ServicoTarefa {
  async criar(usuarioId, dados) {
    // Dependência direta de Mongoose (detalhe de infraestrutura)
    const tarefa = await Tarefa.create({ ...dados, usuario: usuarioId });

    // Dependência direta de Nodemailer (detalhe de infraestrutura)
    await transporter.sendMail({ to: dados.email, subject: 'Tarefa criada' });

    return tarefa;
  }
}

// ✅ DIP — depende de abstrações injetadas, não de implementações concretas

class ServicoTarefa {
  // Recebe as dependências injetadas — não as cria nem as importa
  // Não sabe se está falando com MongoDB, memória, ou um mock
  constructor(repositorio, notificador) {
    this.repositorio = repositorio;
    this.notificador = notificador;
  }

  async criar(usuario, dados) {
    const tarefa = await this.repositorio.salvar({
      ...dados,
      usuario: usuario._id,
    });

    await this.notificador.notificarCriacao(tarefa, usuario);
    return tarefa;
  }

  async concluir(tarefaId, usuarioId) {
    const tarefa = await this.repositorio.buscarPorId(tarefaId);

    if (!tarefa) throw new Error('Tarefa não encontrada.');
    if (tarefa.usuario.toString() !== usuarioId.toString()) {
      throw new Error('Sem permissão.');
    }

    return this.repositorio.atualizar(tarefaId, { status: 'concluida' });
  }
}

// Em produção — injeção das implementações reais
const servicoProd = new ServicoTarefa(
  new RepositorioMongoDB(Tarefa),
  new NotificadorEmail(transporterConfig)
);

// Em testes — injeção de implementações em memória ou mocks
// Nenhuma conexão de banco necessária, testes rodam em milissegundos
const servicoTeste = new ServicoTarefa(
  new RepositorioEmMemoria(),
  new NotificadorConsole()
);

Clean Architecture — separando o que importa do que é detalhe

Robert C. Martin (Uncle Bob) formalizou a Clean Architecture como uma forma de organizar o código em camadas concêntricas, onde as regras de negócio ficam no centro e os detalhes de infraestrutura ficam na periferia. A regra fundamental é que as dependências sempre apontam para dentro — o núcleo não sabe que o Express ou o MongoDB existem.

┌─────────────────────────────────────────────────────┐
│  Frameworks e Drivers (Express, Mongoose, React)    │
│  ┌───────────────────────────────────────────────┐  │
│  │  Interface Adapters (Controllers, Presenters) │  │
│  │  ┌─────────────────────────────────────────┐  │  │
│  │  │  Application (Use Cases / Serviços)     │  │  │
│  │  │  ┌───────────────────────────────────┐  │  │  │
│  │  │  │  Entities (Regras de Negócio)     │  │  │  │
│  │  │  └───────────────────────────────────┘  │  │  │
│  │  └─────────────────────────────────────────┘  │  │
│  └───────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────┘

Dependências: sempre para dentro (→)
O núcleo não conhece o exterior.

Vamos aplicar Clean Architecture na feature de tarefas:

src/
├── domain/              ← Núcleo — regras de negócio puras
│   ├── entities/
│   │   └── Tarefa.js    ← O que é uma tarefa? Suas regras invariantes
│   └── repositories/
│       └── ITarefaRepository.js  ← Contrato (interface)
│
├── application/         ← Casos de uso — o que a aplicação faz
│   └── useCases/
│       ├── CriarTarefa.js
│       ├── ConcluirTarefa.js
│       └── ListarTarefas.js
│
├── infrastructure/      ← Detalhes — como as coisas são feitas
│   ├── database/
│   │   └── MongoTarefaRepository.js
│   └── email/
│       └── NodemailerEmailService.js
│
└── interfaces/          ← Adaptadores — traduz entre camadas
    └── http/
        ├── controllers/
        │   └── TarefaController.js
        └── routes/
            └── tarefas.js
// ── CAMADA DE DOMÍNIO ───────────────────────────────
// src/domain/entities/Tarefa.js
// A entidade encapsula as regras de negócio invariantes
// Não tem dependências externas — é JavaScript puro

class Tarefa {
  constructor({ id, titulo, descricao, status, prioridade, prazo, usuarioId }) {
    // Validações que sempre devem ser verdadeiras — invariantes do domínio
    if (!titulo || titulo.trim().length < 2) {
      throw new Error('Título deve ter pelo menos 2 caracteres.');
    }
    if (prazo && new Date(prazo) < new Date()) {
      throw new Error('Prazo não pode ser no passado.');
    }

    this.id = id;
    this.titulo = titulo.trim();
    this.descricao = descricao || '';
    this.status = status || 'pendente';
    this.prioridade = prioridade || 'media';
    this.prazo = prazo ? new Date(prazo) : null;
    this.usuarioId = usuarioId;
    this.criadoEm = new Date();
  }

  // Comportamentos da entidade — regras de negócio que "pertencem" à tarefa
  concluir() {
    if (this.status === 'concluida') {
      throw new Error('Tarefa já está concluída.');
    }
    if (this.status === 'cancelada') {
      throw new Error('Não é possível concluir uma tarefa cancelada.');
    }
    this.status = 'concluida';
    this.concluidaEm = new Date();
  }

  cancelar(motivo) {
    if (this.status === 'concluida') {
      throw new Error('Não é possível cancelar uma tarefa já concluída.');
    }
    this.status = 'cancelada';
    this.motivoCancelamento = motivo;
  }

  estaAtrasada() {
    return (
      this.status === 'pendente' &&
      this.prazo !== null &&
      new Date() > this.prazo
    );
  }

  pertenceAo(usuarioId) {
    return this.usuarioId.toString() === usuarioId.toString();
  }
}

module.exports = Tarefa;
// src/domain/repositories/ITarefaRepository.js
// Define o contrato — a interface que a infraestrutura deve implementar
// O domínio não sabe como os dados são persistidos

class ITarefaRepository {
  async salvar(tarefa) { throw new Error('Não implementado.'); }
  async buscarPorId(id) { throw new Error('Não implementado.'); }
  async buscarPorUsuario(usuarioId, filtros) { throw new Error('Não implementado.'); }
  async atualizar(tarefa) { throw new Error('Não implementado.'); }
  async deletar(id) { throw new Error('Não implementado.'); }
}

module.exports = ITarefaRepository;
// ── CAMADA DE APLICAÇÃO ─────────────────────────────
// src/application/useCases/CriarTarefa.js
// Um caso de uso por arquivo — cada um representa um cenário de uso do sistema

const Tarefa = require('../../domain/entities/Tarefa');

class CriarTarefa {
  // Recebe o repositório e o notificador como dependências
  // Não importa diretamente nenhuma implementação concreta
  constructor(tarefaRepository, notificador) {
    this.tarefaRepository = tarefaRepository;
    this.notificador = notificador;
  }

  // execute() é a convenção para o método principal de um caso de uso
  async execute({ titulo, descricao, prioridade, prazo, usuario }) {
    // Cria a entidade — as validações do domínio rodam aqui
    const tarefa = new Tarefa({
      titulo,
      descricao,
      prioridade,
      prazo,
      usuarioId: usuario.id,
    });

    // Persiste via repositório — não sabe se é MongoDB, PostgreSQL ou memória
    await this.tarefaRepository.salvar(tarefa);

    // Notifica — não sabe se é email, SMS ou console
    await this.notificador.notificarCriacao(tarefa, usuario);

    return tarefa;
  }
}

module.exports = CriarTarefa;
// src/application/useCases/ConcluirTarefa.js
class ConcluirTarefa {
  constructor(tarefaRepository) {
    this.tarefaRepository = tarefaRepository;
  }

  async execute({ tarefaId, usuarioId }) {
    const tarefa = await this.tarefaRepository.buscarPorId(tarefaId);

    if (!tarefa) {
      throw new Error('Tarefa não encontrada.');
    }

    // Verifica autorização — regra de negócio no domínio
    if (!tarefa.pertenceAo(usuarioId)) {
      throw new Error('Sem permissão para concluir esta tarefa.');
    }

    // A entidade valida a transição de estado — não o caso de uso
    tarefa.concluir();

    // Persiste o estado atualizado
    await this.tarefaRepository.atualizar(tarefa);

    return tarefa;
  }
}

module.exports = ConcluirTarefa;
// ── CAMADA DE INFRAESTRUTURA ────────────────────────
// src/infrastructure/database/MongoTarefaRepository.js
// Implementação concreta do repositório — usa Mongoose

const ITarefaRepository = require('../../domain/repositories/ITarefaRepository');
const TarefaModel = require('../models/TarefaModel');
const Tarefa = require('../../domain/entities/Tarefa');

class MongoTarefaRepository extends ITarefaRepository {
  // Converte documento Mongoose para entidade do domínio
  #toEntity(doc) {
    if (!doc) return null;
    return new Tarefa({
      id: doc._id.toString(),
      titulo: doc.titulo,
      descricao: doc.descricao,
      status: doc.status,
      prioridade: doc.prioridade,
      prazo: doc.prazo,
      usuarioId: doc.usuario.toString(),
    });
  }

  async salvar(tarefa) {
    const doc = await TarefaModel.create({
      titulo: tarefa.titulo,
      descricao: tarefa.descricao,
      status: tarefa.status,
      prioridade: tarefa.prioridade,
      prazo: tarefa.prazo,
      usuario: tarefa.usuarioId,
    });
    return this.#toEntity(doc);
  }

  async buscarPorId(id) {
    const doc = await TarefaModel.findById(id).lean();
    return this.#toEntity(doc);
  }

  async buscarPorUsuario(usuarioId, filtros = {}) {
    const docs = await TarefaModel
      .find({ usuario: usuarioId, ...filtros })
      .sort({ criadoEm: -1 })
      .lean();
    return docs.map((d) => this.#toEntity(d));
  }

  async atualizar(tarefa) {
    const doc = await TarefaModel.findByIdAndUpdate(
      tarefa.id,
      {
        status: tarefa.status,
        concluidaEm: tarefa.concluidaEm,
        motivoCancelamento: tarefa.motivoCancelamento,
      },
      { new: true }
    ).lean();
    return this.#toEntity(doc);
  }

  async deletar(id) {
    await TarefaModel.findByIdAndDelete(id);
  }
}

module.exports = MongoTarefaRepository;
// ── CAMADA DE INTERFACES ────────────────────────────
// src/interfaces/http/controllers/TarefaController.js
// Traduz HTTP para chamadas aos casos de uso e vice-versa

class TarefaController {
  // Recebe os casos de uso prontos — não os instancia
  constructor(criarTarefa, concluirTarefa, listarTarefas) {
    this.criarTarefa = criarTarefa;
    this.concluirTarefa = concluirTarefa;
    this.listarTarefas = listarTarefas;
  }

  async criar(req, res, next) {
    try {
      // Traduz req.body (detalhe HTTP) para dados do domínio
      const tarefa = await this.criarTarefa.execute({
        titulo: req.body.titulo,
        descricao: req.body.descricao,
        prioridade: req.body.prioridade,
        prazo: req.body.prazo,
        usuario: req.usuario, // injetado pelo middleware de auth
      });

      // Traduz entidade do domínio para resposta HTTP
      res.status(201).json(this.#serializar(tarefa));
    } catch (erro) {
      next(erro);
    }
  }

  async concluir(req, res, next) {
    try {
      const tarefa = await this.concluirTarefa.execute({
        tarefaId: req.params.id,
        usuarioId: req.usuario._id,
      });
      res.json(this.#serializar(tarefa));
    } catch (erro) {
      next(erro);
    }
  }

  // Serializa a entidade para o formato de resposta da API
  // Separa o modelo de domínio do modelo de apresentação
  #serializar(tarefa) {
    return {
      id: tarefa.id,
      titulo: tarefa.titulo,
      descricao: tarefa.descricao,
      status: tarefa.status,
      prioridade: tarefa.prioridade,
      prazo: tarefa.prazo,
      estaAtrasada: tarefa.estaAtrasada(),
      criadoEm: tarefa.criadoEm,
    };
  }
}

module.exports = TarefaController;
// ── COMPOSIÇÃO — montando as peças ─────────────────
// src/infrastructure/container.js
// Injeção de dependências manual — para projetos menores é suficiente
// Para projetos maiores, use tsyringe, awilix ou InversifyJS

const MongoTarefaRepository = require('./database/MongoTarefaRepository');
const NodemailerEmailService = require('./email/NodemailerEmailService');
const NotificadorTarefa = require('../application/services/NotificadorTarefa');
const CriarTarefa = require('../application/useCases/CriarTarefa');
const ConcluirTarefa = require('../application/useCases/ConcluirTarefa');
const TarefaController = require('../interfaces/http/controllers/TarefaController');

// Instancia a infraestrutura
const tarefaRepository = new MongoTarefaRepository();
const emailService = new NodemailerEmailService(process.env.SMTP_CONFIG);
const notificador = new NotificadorTarefa(emailService);

// Instancia os casos de uso
const criarTarefa = new CriarTarefa(tarefaRepository, notificador);
const concluirTarefa = new ConcluirTarefa(tarefaRepository);

// Instancia o controller com os casos de uso injetados
const tarefaController = new TarefaController(criarTarefa, concluirTarefa);

module.exports = { tarefaController };

Domain-Driven Design — alinhando código e negócio

O Domain-Driven Design (DDD), proposto por Eric Evans no livro de 2003, vai além da organização técnica — é uma filosofia de desenvolvimento onde o código reflete o modelo mental do domínio de negócio. Em vez de pensar em tabelas e endpoints, pensamos em termos do problema que estamos resolvendo.

Os conceitos centrais do DDD que mais influenciam o código no dia a dia são os seguintes.

Linguagem Ubíqua é o vocabulário compartilhado entre desenvolvedores e especialistas do domínio. Os nomes das classes, métodos e variáveis devem refletir o vocabulário do negócio — não o vocabulário técnico. Se o contador chama de "lançamento", o código tem Lancamento, não Transaction.

Entidades são objetos definidos pela sua identidade — um Usuario com id 42 é o mesmo usuário mesmo que seu nome mude. Duas tarefas com o mesmo título e prazo são objetos diferentes se tiverem ids diferentes.

Value Objects são objetos definidos pelos seus valores — dois endereços com a mesma rua, número e CEP são equivalentes. Value Objects são imutáveis: para "mudar" um endereço, você cria um novo.

Aggregates são clusters de entidades tratados como uma unidade. O Aggregate Root é o único ponto de entrada para modificar o agregado.

// Value Object — imutável, definido pelos seus valores
class Email {
  #valor;

  constructor(email) {
    const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    if (!regex.test(email)) {
      throw new Error(`Email inválido: ${email}`);
    }
    // Normaliza na criação — letras minúsculas, sem espaços
    this.#valor = email.trim().toLowerCase();
    Object.freeze(this); // garante imutabilidade
  }

  toString() { return this.#valor; }

  // Value Objects têm igualdade por valor, não por referência
  equals(outro) {
    return outro instanceof Email && outro.toString() === this.#valor;
  }

  get dominio() {
    return this.#valor.split('@')[1];
  }
}

// Value Object para dinheiro — evita erros de ponto flutuante
class Dinheiro {
  #centavos;
  #moeda;

  constructor(valor, moeda = 'BRL') {
    if (valor < 0) throw new Error('Valor não pode ser negativo.');
    // Armazena em centavos para evitar problemas de ponto flutuante
    this.#centavos = Math.round(valor * 100);
    this.#moeda = moeda;
    Object.freeze(this);
  }

  get valor() { return this.#centavos / 100; }
  get moeda() { return this.#moeda; }

  // Operações retornam novos Value Objects — imutabilidade
  somar(outro) {
    if (outro.moeda !== this.#moeda) {
      throw new Error('Não é possível somar moedas diferentes.');
    }
    return new Dinheiro((this.#centavos + outro.#centavos) / 100, this.#moeda);
  }

  subtrair(outro) {
    const resultado = this.#centavos - outro.#centavos;
    if (resultado < 0) throw new Error('Resultado negativo não permitido.');
    return new Dinheiro(resultado / 100, this.#moeda);
  }

  multiplicar(fator) {
    return new Dinheiro((this.#centavos * fator) / 100, this.#moeda);
  }

  equals(outro) {
    return outro instanceof Dinheiro &&
      outro.#centavos === this.#centavos &&
      outro.#moeda === this.#moeda;
  }

  toString() {
    return this.valor.toLocaleString('pt-BR', {
      style: 'currency',
      currency: this.#moeda,
    });
  }
}

// Usando Value Objects para tornar o domínio expressivo
class Pedido {
  constructor(id, clienteId) {
    this.id = id;
    this.clienteId = clienteId;
    this.itens = [];
    this.status = 'rascunho';
  }

  adicionarItem(produto, quantidade, precoUnitario) {
    const preco = new Dinheiro(precoUnitario);
    this.itens.push({
      produtoId: produto.id,
      quantidade,
      precoUnitario: preco,
      subtotal: preco.multiplicar(quantidade),
    });
  }

  get total() {
    return this.itens.reduce(
      (acc, item) => acc.somar(item.subtotal),
      new Dinheiro(0)
    );
  }

  finalizar() {
    if (this.itens.length === 0) {
      throw new Error('Pedido sem itens não pode ser finalizado.');
    }
    if (this.total.valor < 10) {
      throw new Error('Pedido mínimo de R$ 10,00.');
    }
    this.status = 'finalizado';
    this.finalizadoEm = new Date();
  }
}

Tarefa para você

Aplique Clean Architecture na feature de produtos da aplicação:

// 1. Crie a estrutura de pastas:
//    src/domain/entities/Produto.js
//    src/domain/repositories/IProdutoRepository.js
//    src/application/useCases/CriarProduto.js
//    src/application/useCases/AtualizarEstoque.js
//    src/infrastructure/database/MongoProdutoRepository.js
//    src/interfaces/http/controllers/ProdutoController.js

// 2. Implemente a entidade Produto com as regras invariantes:
//    - preço não pode ser negativo
//    - estoque não pode ser negativo
//    - método reservarEstoque(quantidade) que lança erro se insuficiente
//    - método reporEstoque(quantidade)

// 3. Crie um Value Object Preco que:
//    - Valida que o valor é positivo
//    - Formata como "R$ 99,99"
//    - Implementa equals(), somar(), aplicarDesconto(percentual)

// 4. Escreva testes unitários para a entidade Produto e para o Value Object Preco
//    Esses testes não precisam de banco de dados — testam JavaScript puro

// 5. Implemente o caso de uso AtualizarEstoque:
//    - Busca o produto via repositório
//    - Verifica que o usuário é admin
//    - Chama produto.reporEstoque() ou produto.reservarEstoque()
//    - Persiste via repositório
//    - Emite evento 'produto:estoque-baixo' se estoque < 5

// 6. Monte o container.js conectando todas as peças
Ver solução — a feature de produtos em Clean Architecture — entidade, Value Object, casos de uso e container
// CLEAN ARCHITECTURE na feature de produtos.
//
// A regra de dependência aponta sempre para dentro: domain/ não importa nada,
// application/ importa domain/, infrastructure/ e interfaces/ importam os dois,
// e só o container.js conhece todo mundo.
//
//   1 estrutura + a porta  → src/domain/repositories/IProdutoRepository.js
//   2 entidade Produto     → src/domain/entities/Produto.js
//   3 Value Object Preco   → src/domain/valueObjects/Preco.js
//   4 testes de unidade    → tests/dominio.test.js  (sem banco, sem mock)
//   5 AtualizarEstoque     → src/application/useCases/AtualizarEstoque.js
//   6 container            → src/container.js
//
// 26 testes verdes: 12 de domínio puro e 14 costurando HTTP → caso de uso →
// repositório → Mongo.

// ---- src/domain/errors.js
// Erro de domínio tem nome próprio: é o que permite à borda HTTP traduzir sem
// olhar a mensagem. `catch (e) { if (e.message.includes("estoque")) ... }` é a
// alternativa, e ela quebra na primeira vez que alguém melhora o texto.
class ErroDeDominio extends Error {
  constructor(mensagem) {
    super(mensagem);
    this.name = new.target.name;
  }
}

module.exports = { ErroDeDominio };

// ---- src/domain/valueObjects/Preco.js
// 3 — VALUE OBJECT: imutável, comparado por valor, sem identidade.
// Guarda CENTAVOS inteiros. Dinheiro em float é erro de domínio, não de estilo:
// 0.1 + 0.2 === 0.30000000000000004.
const formatador = new Intl.NumberFormat("pt-BR", {
  style: "currency",
  currency: "BRL",
});

class Preco {
  constructor(centavos) {
    if (!Number.isInteger(centavos)) {
      throw new TypeError("Preco recebe centavos inteiros — use Preco.deReais()");
    }
    if (centavos <= 0) {
      throw new RangeError("preço tem de ser positivo");
    }
    this.centavos = centavos;
    Object.freeze(this);
  }

  static deReais(reais) {
    if (typeof reais !== "number" || !Number.isFinite(reais)) {
      throw new TypeError("valor inválido para Preco");
    }
    return new Preco(Math.round(reais * 100));
  }

  get reais() {
    return this.centavos / 100;
  }

  formatar() {
    // O Intl separa o símbolo com ESPAÇO NÃO SEPARÁVEL (U+00A0). Comparar com
    // "R$ 99,99" digitado no teclado falha. Normalizamos — e escrevemos o escape
    // \u00A0 em vez do caractere, que é invisível no editor e some no copiar-colar.
    return formatador.format(this.reais).replace(/\u00A0/g, " ");
  }

  equals(outro) {
    return outro instanceof Preco && outro.centavos === this.centavos;
  }

  somar(outro) {
    if (!(outro instanceof Preco)) throw new TypeError("somar espera um Preco");
    return new Preco(this.centavos + outro.centavos);
  }

  aplicarDesconto(percentual) {
    if (percentual < 0 || percentual >= 100) {
      throw new RangeError("desconto tem de estar entre 0 e 100 (exclusive)");
    }
    // arredonda para o centavo mais próximo, em inteiros: nada de 89.991
    const desconto = Math.round((this.centavos * percentual) / 100);
    return new Preco(this.centavos - desconto);
  }

  toJSON() {
    return { centavos: this.centavos, formatado: this.formatar() };
  }
}

module.exports = { Preco };

// ---- src/domain/entities/Produto.js
// 2 — ENTIDADE: tem identidade e protege as próprias invariantes.
// Não conhece Mongoose, Express nem HTTP.
const { Preco } = require("../valueObjects/Preco");
const { ErroDeDominio } = require("../errors");

class EstoqueInsuficiente extends ErroDeDominio {
  constructor(pedido, disponivel) {
    super(`estoque insuficiente: pedidos ${pedido}, disponíveis ${disponivel}`);
    this.pedido = pedido;
    this.disponivel = disponivel;
  }
}

class Produto {
  constructor({ id, nome, preco, estoque = 0 }) {
    if (!nome || !nome.trim()) throw new ErroDeDominio("produto exige nome");
    if (!(preco instanceof Preco)) throw new TypeError("preco tem de ser um Preco");
    if (!Number.isInteger(estoque) || estoque < 0) {
      throw new RangeError("estoque não pode ser negativo");
    }
    this.id = id;
    this.nome = nome.trim();
    this.preco = preco;
    this.estoque = estoque;
  }

  reservarEstoque(quantidade) {
    this.#exigirQuantidade(quantidade);
    if (quantidade > this.estoque) {
      throw new EstoqueInsuficiente(quantidade, this.estoque);
    }
    this.estoque -= quantidade;
    return this.estoque;
  }

  reporEstoque(quantidade) {
    this.#exigirQuantidade(quantidade);
    this.estoque += quantidade;
    return this.estoque;
  }

  get estoqueBaixo() {
    return this.estoque < 5;
  }

  #exigirQuantidade(quantidade) {
    if (!Number.isInteger(quantidade) || quantidade <= 0) {
      throw new RangeError("quantidade tem de ser inteiro positivo");
    }
  }
}

module.exports = { Produto, EstoqueInsuficiente };

// ---- src/domain/repositories/IProdutoRepository.js
// 1 — A PORTA. O domínio declara o que precisa; a infraestrutura obedece.
// Em JavaScript não há interface, então a classe abstrata cumpre dois papéis:
// documenta o contrato e falha alto se alguém esquecer de implementar um método.
class IProdutoRepository {
  async porId(_id) {
    throw new Error("não implementado: porId");
  }
  async salvar(_produto) {
    throw new Error("não implementado: salvar");
  }
  async listar(_filtro) {
    throw new Error("não implementado: listar");
  }
}

module.exports = { IProdutoRepository };

// ---- src/application/useCases/CriarProduto.js
const { Produto } = require("../../domain/entities/Produto");
const { Preco } = require("../../domain/valueObjects/Preco");

class CriarProduto {
  constructor({ produtoRepository }) {
    this.repo = produtoRepository;
  }

  // Recebe dado cru (vindo do HTTP) e devolve entidade. A conversão para os
  // tipos do domínio acontece AQUI, na borda — não dentro da entidade.
  async executar({ nome, precoEmReais, estoque = 0 }) {
    const produto = new Produto({
      nome,
      preco: Preco.deReais(precoEmReais),
      estoque,
    });
    return this.repo.salvar(produto);
  }
}

module.exports = { CriarProduto };

// ---- src/application/useCases/AtualizarEstoque.js
class NaoAutorizado extends Error {
  constructor() {
    super("apenas administradores alteram estoque");
    this.name = "NaoAutorizado";
  }
}
class ProdutoNaoEncontrado extends Error {
  constructor(id) {
    super(`produto ${id} não encontrado`);
    this.name = "ProdutoNaoEncontrado";
  }
}

const ESTOQUE_BAIXO = "produto:estoque-baixo";

// 5 — CASO DE USO: orquestra. Não tem regra de negócio (isso é da entidade),
// não conhece Express nem Mongoose (isso é da borda).
class AtualizarEstoque {
  constructor({ produtoRepository, eventos }) {
    this.repo = produtoRepository;
    this.eventos = eventos;
  }

  async executar({ produtoId, quantidade, operacao, usuario }) {
    if (!usuario || usuario.papel !== "admin") throw new NaoAutorizado();

    const produto = await this.repo.porId(produtoId);
    if (!produto) throw new ProdutoNaoEncontrado(produtoId);

    if (operacao === "repor") produto.reporEstoque(quantidade);
    else if (operacao === "reservar") produto.reservarEstoque(quantidade);
    else throw new RangeError(`operação desconhecida: ${operacao}`);

    const salvo = await this.repo.salvar(produto);

    // depois de persistir, nunca antes: avisar sobre um estoque que o banco
    // recusou é pior do que não avisar.
    if (salvo.estoqueBaixo) {
      this.eventos.emit(ESTOQUE_BAIXO, {
        produtoId: salvo.id,
        nome: salvo.nome,
        estoque: salvo.estoque,
      });
    }
    return salvo;
  }
}

module.exports = { AtualizarEstoque, NaoAutorizado, ProdutoNaoEncontrado, ESTOQUE_BAIXO };

// ---- src/infrastructure/database/MongoProdutoRepository.js
const mongoose = require("mongoose");
const { IProdutoRepository } = require("../../domain/repositories/IProdutoRepository");
const { Produto } = require("../../domain/entities/Produto");
const { Preco } = require("../../domain/valueObjects/Preco");

const produtoSchema = new mongoose.Schema({
  nome: { type: String, required: true },
  // centavos, inteiro: o schema do banco acompanha a decisão do domínio
  precoEmCentavos: { type: Number, required: true, min: 1 },
  estoque: { type: Number, required: true, min: 0, default: 0 },
});

const ProdutoModel =
  mongoose.models.ProdutoCA || mongoose.model("ProdutoCA", produtoSchema, "produtos_ca");

// 1 — O ADAPTADOR. Traduz documento ↔ entidade nos dois sentidos. Devolver o
// documento do Mongoose direto seria vazar a infraestrutura para dentro do
// domínio — e junto com ela a ausência das invariantes.
class MongoProdutoRepository extends IProdutoRepository {
  static paraDominio(doc) {
    if (!doc) return null;
    return new Produto({
      id: String(doc._id),
      nome: doc.nome,
      preco: new Preco(doc.precoEmCentavos),
      estoque: doc.estoque,
    });
  }

  async porId(id) {
    if (!mongoose.isValidObjectId(id)) return null;
    return MongoProdutoRepository.paraDominio(await ProdutoModel.findById(id));
  }

  async salvar(produto) {
    const dados = {
      nome: produto.nome,
      precoEmCentavos: produto.preco.centavos,
      estoque: produto.estoque,
    };
    const doc = produto.id
      // `new: true` ainda funciona, mas o Mongoose 9 avisa que está deprecado
      // em favor de `returnDocument`.
      ? await ProdutoModel.findByIdAndUpdate(produto.id, dados, { returnDocument: "after", runValidators: true })
      : await ProdutoModel.create(dados);
    return MongoProdutoRepository.paraDominio(doc);
  }

  async listar(filtro = {}) {
    const docs = await ProdutoModel.find(filtro);
    return docs.map(MongoProdutoRepository.paraDominio);
  }
}

module.exports = { MongoProdutoRepository, ProdutoModel };

// ---- src/infrastructure/database/ProdutoRepositoryEmMemoria.js
const { IProdutoRepository } = require("../../domain/repositories/IProdutoRepository");

// O mesmo contrato, sem banco: é isto que torna o caso de uso testável em
// milissegundos. Se o teste do caso de uso precisa de Mongo, a dependência
// invertida não foi invertida.
class ProdutoRepositoryEmMemoria extends IProdutoRepository {
  constructor(iniciais = []) {
    super();
    this.itens = new Map(iniciais.map((p) => [p.id, p]));
    this.proximoId = iniciais.length + 1;
  }
  async porId(id) {
    return this.itens.get(id) ?? null;
  }
  async salvar(produto) {
    if (!produto.id) produto.id = String(this.proximoId++);
    this.itens.set(produto.id, produto);
    return produto;
  }
  async listar() {
    return [...this.itens.values()];
  }
}

module.exports = { ProdutoRepositoryEmMemoria };

// ---- src/interfaces/http/controllers/ProdutoController.js
const {
  NaoAutorizado,
  ProdutoNaoEncontrado,
} = require("../../../application/useCases/AtualizarEstoque");
const { EstoqueInsuficiente } = require("../../../domain/entities/Produto");
const { ErroDeDominio } = require("../../../domain/errors");

// A borda HTTP não decide nada: converte requisição em comando, erro de
// domínio em status, e entidade em JSON.
const paraJSON = (p) => ({
  id: p.id,
  nome: p.nome,
  preco: p.preco.toJSON(),
  estoque: p.estoque,
  estoqueBaixo: p.estoqueBaixo,
});

const STATUS = new Map([
  [NaoAutorizado, 403],
  [ProdutoNaoEncontrado, 404],
  [EstoqueInsuficiente, 409], // antes de ErroDeDominio: a ordem do Map decide
  [ErroDeDominio, 422],
  [RangeError, 422],
  [TypeError, 422],
]);

// O que NÃO está no mapa vira 500 — e foi assim que o teste achou o erro de
// "nome vazio" saindo como falha do servidor: ele era um Error solto.

function statusDe(erro) {
  for (const [Classe, status] of STATUS) if (erro instanceof Classe) return status;
  return 500;
}

class ProdutoController {
  constructor({ criarProduto, atualizarEstoque }) {
    this.criarProduto = criarProduto;
    this.atualizarEstoque = atualizarEstoque;
  }

  criar = async (req, res) => {
    try {
      const produto = await this.criarProduto.executar(req.body);
      res.status(201).json(paraJSON(produto));
    } catch (erro) {
      res.status(statusDe(erro)).json({ erro: erro.message });
    }
  };

  ajustarEstoque = async (req, res) => {
    try {
      const produto = await this.atualizarEstoque.executar({
        produtoId: req.params.id,
        quantidade: Number(req.body.quantidade),
        operacao: req.body.operacao,
        usuario: req.usuario,
      });
      res.json(paraJSON(produto));
    } catch (erro) {
      res.status(statusDe(erro)).json({ erro: erro.message });
    }
  };
}

module.exports = { ProdutoController, paraJSON, statusDe };

// ---- src/container.js
// 6 — O CONTAINER: o único arquivo que conhece todo mundo. Trocar Mongo por
// Postgres é trocar UMA linha aqui; nada em application/ ou domain/ muda.
const express = require("express");
const { EventEmitter } = require("node:events");

const { CriarProduto } = require("./application/useCases/CriarProduto");
const { AtualizarEstoque, ESTOQUE_BAIXO } = require("./application/useCases/AtualizarEstoque");
const { MongoProdutoRepository } = require("./infrastructure/database/MongoProdutoRepository");
const { ProdutoController } = require("./interfaces/http/controllers/ProdutoController");

function montarContainer({ produtoRepository, eventos = new EventEmitter(), logger = console } = {}) {
  const repo = produtoRepository ?? new MongoProdutoRepository();

  eventos.on(ESTOQUE_BAIXO, ({ nome, estoque }) =>
    logger.warn(`[estoque] "${nome}" está em ${estoque} unidades`)
  );

  const criarProduto = new CriarProduto({ produtoRepository: repo });
  const atualizarEstoque = new AtualizarEstoque({ produtoRepository: repo, eventos });
  const controller = new ProdutoController({ criarProduto, atualizarEstoque });

  return { repo, eventos, criarProduto, atualizarEstoque, controller };
}

// autenticação de mentira, só para a demonstração: em produção seria o JWT
// do artigo de API REST com autenticação
const autenticar = (req, _res, proximo) => {
  req.usuario = req.get("x-papel") ? { id: "u1", papel: req.get("x-papel") } : null;
  proximo();
};

function criarApp(dependencias = {}) {
  const container = montarContainer(dependencias);
  const app = express();
  app.use(express.json());
  app.use(autenticar);
  app.post("/produtos", container.controller.criar);
  app.patch("/produtos/:id/estoque", container.controller.ajustarEstoque);
  app.locals.container = container;
  return app;
}

module.exports = { montarContainer, criarApp };

// ---- tests/dominio.test.js
// 4 — TESTES DE DOMÍNIO: JavaScript puro. Sem banco, sem HTTP, sem mock.
const { Produto, EstoqueInsuficiente } = require("../src/domain/entities/Produto");
const { Preco } = require("../src/domain/valueObjects/Preco");

describe("Value Object Preco", () => {
  test("formata em pt-BR — e o Intl usa espaço NÃO separável", () => {
    const bruto = new Intl.NumberFormat("pt-BR", { style: "currency", currency: "BRL" }).format(99.99);
    expect(bruto).not.toBe("R$ 99,99"); // o teste ingênuo falha aqui
    expect(bruto.charCodeAt(2)).toBe(0x00a0); // U+00A0, invisível
    expect(Preco.deReais(99.99).formatar()).toBe("R$ 99,99"); // já normalizado
  });

  test("guarda centavos inteiros — dinheiro em float não fecha a conta", () => {
    expect(0.1 + 0.2).not.toBe(0.3); // o motivo de tudo isto
    const dez = Preco.deReais(0.1);
    const vinte = Preco.deReais(0.2);
    expect(dez.somar(vinte).equals(Preco.deReais(0.3))).toBe(true);
    expect(dez.somar(vinte).centavos).toBe(30);
  });

  test("recusa valor não positivo e valor não numérico", () => {
    expect(() => Preco.deReais(0)).toThrow(RangeError);
    expect(() => Preco.deReais(-1)).toThrow(/positivo/);
    expect(() => Preco.deReais("99,99")).toThrow(TypeError);
    expect(() => new Preco(99.9)).toThrow(/centavos inteiros/);
  });

  test("equals compara por valor, não por identidade", () => {
    const a = Preco.deReais(10);
    const b = Preco.deReais(10);
    expect(a === b).toBe(false);
    expect(a.equals(b)).toBe(true);
    expect(a.equals(Preco.deReais(10.01))).toBe(false);
    expect(a.equals(1000)).toBe(false);
  });

  test("é imutável: somar e aplicarDesconto devolvem outro Preco", () => {
    const original = Preco.deReais(100);
    const comDesconto = original.aplicarDesconto(10);
    expect(original.centavos).toBe(10000);
    expect(comDesconto.centavos).toBe(9000);
    expect(Object.isFrozen(original)).toBe(true);
    expect(() => {
      "use strict";
      original.centavos = 1;
    }).toThrow(TypeError);
  });

  test("desconto arredonda no centavo, em inteiro", () => {
    expect(99.99 * 0.9).toBe(89.991); // o que o float faria
    expect(Preco.deReais(99.99).aplicarDesconto(10).formatar()).toBe("R$ 89,99");
    // 50% de 3 centavos = 1,5. Math.round arredonda o DESCONTO para cima (2),
    // e sobra 1 centavo: o meio centavo vai para o cliente. É uma decisão, não
    // um acidente — inverter para Math.floor entrega o meio centavo à loja.
    expect(Preco.deReais(0.03).aplicarDesconto(50).centavos).toBe(1);
    expect(() => Preco.deReais(10).aplicarDesconto(100)).toThrow(RangeError);
  });
});

describe("Entidade Produto", () => {
  const novo = (estoque = 10) =>
    new Produto({ id: "p1", nome: "Teclado", preco: Preco.deReais(349.9), estoque });

  test("exige nome e um Preco de verdade — não um número solto", () => {
    expect(() => new Produto({ nome: "", preco: Preco.deReais(1) })).toThrow(/nome/);
    expect(() => new Produto({ nome: "x", preco: 349.9 })).toThrow(TypeError);
  });

  test("estoque negativo é recusado na construção", () => {
    expect(() => novo(-1)).toThrow(RangeError);
    expect(() => novo(1.5)).toThrow(RangeError);
  });

  test("reservarEstoque desconta e devolve o saldo", () => {
    const p = novo(10);
    expect(p.reservarEstoque(3)).toBe(7);
    expect(p.estoque).toBe(7);
  });

  test("reservar mais do que existe lança, e NÃO deixa o estoque alterado", () => {
    const p = novo(2);
    expect(() => p.reservarEstoque(5)).toThrow(EstoqueInsuficiente);
    expect(p.estoque).toBe(2);
    try {
      p.reservarEstoque(5);
    } catch (e) {
      expect(e).toMatchObject({ pedido: 5, disponivel: 2 });
    }
  });

  test("reporEstoque soma e recusa quantidade inválida", () => {
    const p = novo(1);
    expect(p.reporEstoque(4)).toBe(5);
    for (const q of [0, -2, 1.5, "3"]) expect(() => p.reporEstoque(q)).toThrow(RangeError);
    expect(p.estoque).toBe(5);
  });

  test("estoqueBaixo é regra do domínio, não um if espalhado pelo código", () => {
    expect(novo(4).estoqueBaixo).toBe(true);
    expect(novo(5).estoqueBaixo).toBe(false);
  });
});

// ---- tests/casosDeUso.test.js
const { EventEmitter } = require("node:events");
const mongoose = require("mongoose");
const request = require("supertest");
const { MongoMemoryServer } = require("mongodb-memory-server");

const { Produto, EstoqueInsuficiente } = require("../src/domain/entities/Produto");
const { Preco } = require("../src/domain/valueObjects/Preco");
const { IProdutoRepository } = require("../src/domain/repositories/IProdutoRepository");
const {
  AtualizarEstoque,
  NaoAutorizado,
  ProdutoNaoEncontrado,
  ESTOQUE_BAIXO,
} = require("../src/application/useCases/AtualizarEstoque");
const { CriarProduto } = require("../src/application/useCases/CriarProduto");
const { ProdutoRepositoryEmMemoria } = require("../src/infrastructure/database/ProdutoRepositoryEmMemoria");
const { MongoProdutoRepository, ProdutoModel } = require("../src/infrastructure/database/MongoProdutoRepository");
const { criarApp } = require("../src/container");

const admin = { id: "u1", papel: "admin" };
const comum = { id: "u2", papel: "usuario" };
const produtoCom = (estoque) =>
  new Produto({ id: "p1", nome: "Teclado", preco: Preco.deReais(349.9), estoque });

describe("5 · AtualizarEstoque — sem banco, sem HTTP", () => {
  const montar = (estoque) => {
    const repo = new ProdutoRepositoryEmMemoria([produtoCom(estoque)]);
    const eventos = new EventEmitter();
    const ouvinte = jest.fn();
    eventos.on(ESTOQUE_BAIXO, ouvinte);
    return { repo, eventos, ouvinte, caso: new AtualizarEstoque({ produtoRepository: repo, eventos }) };
  };

  test("repor soma, persiste e não avisa quando o estoque fica saudável", async () => {
    const { caso, repo, ouvinte } = montar(3);
    const salvo = await caso.executar({ produtoId: "p1", quantidade: 10, operacao: "repor", usuario: admin });
    expect(salvo.estoque).toBe(13);
    expect((await repo.porId("p1")).estoque).toBe(13);
    expect(ouvinte).not.toHaveBeenCalled();
  });

  test("reservar até abaixo de 5 emite produto:estoque-baixo", async () => {
    const { caso, ouvinte } = montar(6);
    await caso.executar({ produtoId: "p1", quantidade: 2, operacao: "reservar", usuario: admin });
    expect(ouvinte).toHaveBeenCalledWith({ produtoId: "p1", nome: "Teclado", estoque: 4 });
  });

  test("usuário comum recebe NaoAutorizado antes de o repositório ser tocado", async () => {
    const { caso, repo } = montar(10);
    const espia = jest.spyOn(repo, "porId");
    await expect(
      caso.executar({ produtoId: "p1", quantidade: 1, operacao: "repor", usuario: comum })
    ).rejects.toThrow(NaoAutorizado);
    expect(espia).not.toHaveBeenCalled();
  });

  test("id inexistente vira ProdutoNaoEncontrado", async () => {
    const { caso } = montar(10);
    await expect(
      caso.executar({ produtoId: "nao-existe", quantidade: 1, operacao: "repor", usuario: admin })
    ).rejects.toThrow(ProdutoNaoEncontrado);
  });

  test("a regra de estoque é da ENTIDADE — o caso de uso só deixa o erro subir", async () => {
    const { caso, repo, ouvinte } = montar(2);
    const espiaSalvar = jest.spyOn(repo, "salvar");
    await expect(
      caso.executar({ produtoId: "p1", quantidade: 5, operacao: "reservar", usuario: admin })
    ).rejects.toThrow(EstoqueInsuficiente);
    expect(espiaSalvar).not.toHaveBeenCalled(); // nada foi persistido
    expect(ouvinte).not.toHaveBeenCalled();
  });

  test("operação desconhecida não chega ao repositório", async () => {
    const { caso } = montar(10);
    await expect(
      caso.executar({ produtoId: "p1", quantidade: 1, operacao: "zerar", usuario: admin })
    ).rejects.toThrow(/operação desconhecida: zerar/);
  });
});

describe("1 · a porta e o adaptador", () => {
  let mongo;
  beforeAll(async () => {
    mongo = await MongoMemoryServer.create();
    await mongoose.connect(mongo.getUri());
  });
  afterAll(async () => {
    await mongoose.disconnect();
    await mongo.stop();
  });
  beforeEach(() => ProdutoModel.deleteMany({}));

  test("a interface falha alto se um método não for implementado", async () => {
    class Meia extends IProdutoRepository {
      async porId() {
        return null;
      }
    }
    await expect(new Meia().salvar({})).rejects.toThrow("não implementado: salvar");
  });

  test("o repositório devolve ENTIDADE, não documento do Mongoose", async () => {
    const repo = new MongoProdutoRepository();
    const salvo = await repo.salvar(new Produto({ nome: "Mouse", preco: Preco.deReais(120), estoque: 3 }));
    const lido = await repo.porId(salvo.id);
    expect(lido).toBeInstanceOf(Produto);
    expect(lido.preco).toBeInstanceOf(Preco);
    expect(lido.save).toBeUndefined();
    expect(lido.estoqueBaixo).toBe(true);
    expect(lido.preco.formatar()).toBe("R$ 120,00");
    // e as invariantes continuam valendo depois da volta do banco
    expect(() => lido.reservarEstoque(9)).toThrow(EstoqueInsuficiente);
  });

  test("o banco guarda centavos inteiros, e não 349.9", async () => {
    const repo = new MongoProdutoRepository();
    const salvo = await repo.salvar(new Produto({ nome: "Teclado", preco: Preco.deReais(349.9), estoque: 1 }));
    const cru = await ProdutoModel.findById(salvo.id).lean();
    expect(cru.precoEmCentavos).toBe(34990);
    expect(Number.isInteger(cru.precoEmCentavos)).toBe(true);
  });

  test("id malformado devolve null em vez de estourar CastError", async () => {
    expect(await new MongoProdutoRepository().porId("isto-não-é-um-ObjectId")).toBeNull();
  });
});

describe("6 · o container costurado, do HTTP ao banco", () => {
  let mongo, app, logger;
  beforeAll(async () => {
    mongo = await MongoMemoryServer.create();
    await mongoose.connect(mongo.getUri());
  });
  afterAll(async () => {
    await mongoose.disconnect();
    await mongo.stop();
  });
  beforeEach(async () => {
    await ProdutoModel.deleteMany({});
    logger = { warn: jest.fn() };
    app = criarApp({ logger });
  });

  test("POST /produtos cria e devolve o preço formatado", async () => {
    const r = await request(app)
      .post("/produtos")
      .set("x-papel", "admin")
      .send({ nome: "Monitor", precoEmReais: 1299.9, estoque: 8 })
      .expect(201);
    expect(r.body).toMatchObject({
      nome: "Monitor",
      estoque: 8,
      estoqueBaixo: false,
      preco: { centavos: 129990, formatado: "R$ 1.299,90" },
    });
  });

  test("erro de domínio vira status HTTP — 422, 403, 404 e 409", async () => {
    const criado = await request(app)
      .post("/produtos")
      .set("x-papel", "admin")
      .send({ nome: "Monitor", precoEmReais: 1299.9, estoque: 3 });

    await request(app).post("/produtos").set("x-papel", "admin").send({ nome: "", precoEmReais: 10 }).expect(422);
    await request(app).post("/produtos").set("x-papel", "admin").send({ nome: "X", precoEmReais: -1 }).expect(422);

    const id = criado.body.id;
    await request(app).patch(`/produtos/${id}/estoque`).send({ quantidade: 1, operacao: "repor" }).expect(403);
    await request(app)
      .patch(`/produtos/${new mongoose.Types.ObjectId()}/estoque`)
      .set("x-papel", "admin")
      .send({ quantidade: 1, operacao: "repor" })
      .expect(404);
    await request(app)
      .patch(`/produtos/${id}/estoque`)
      .set("x-papel", "admin")
      .send({ quantidade: 99, operacao: "reservar" })
      .expect(409);
  });

  test("o aviso de estoque baixo chega ao ouvinte registrado no container", async () => {
    const criado = await request(app)
      .post("/produtos")
      .set("x-papel", "admin")
      .send({ nome: "Cadeira", precoEmReais: 900, estoque: 6 })
      .expect(201);
    await request(app)
      .patch(`/produtos/${criado.body.id}/estoque`)
      .set("x-papel", "admin")
      .send({ quantidade: 3, operacao: "reservar" })
      .expect(200);
    expect(logger.warn).toHaveBeenCalledWith('[estoque] "Cadeira" está em 3 unidades');
  });

  test("trocar o repositório é trocar UMA linha — o resto do sistema não percebe", async () => {
    const semBanco = criarApp({ produtoRepository: new ProdutoRepositoryEmMemoria(), logger });
    const r = await request(semBanco)
      .post("/produtos")
      .set("x-papel", "admin")
      .send({ nome: "Sem banco", precoEmReais: 50, estoque: 2 })
      .expect(201);
    expect(r.body.preco.formatado).toBe("R$ 50,00");
    await request(semBanco)
      .patch(`/produtos/${r.body.id}/estoque`)
      .set("x-papel", "admin")
      .send({ quantidade: 1, operacao: "repor" })
      .expect(200);
  });
});

A parte que mais dá errado não é a estrutura de pastas: é o dinheiro. Preco guarda centavos inteiros porque 0.1 + 0.2 !== 0.3, e formata com um detalhe que só aparece quando o teste roda: o Intl.NumberFormat separa o "R$" do número com U+00A0, o espaço não separável. O enunciado pede "R$ 99,99"; comparar com essa string digitada no teclado falha, e o desenvolvedor passa meia hora olhando para dois textos idênticos na tela. A segunda armadilha é de arquitetura: o repositório tem de devolver entidade, não documento do Mongoose — devolver o documento vaza a infraestrutura para dentro do domínio e, junto com ela, some a garantia de que estoque nunca fica negativo. E a terceira apareceu sozinha durante os testes: erro de domínio que não está no mapa da borda HTTP vira 500. O "nome vazio" saía como falha do servidor até virar ErroDeDominio.

Se houver uma frase para levar daqui, é a regra das dependências: elas apontam para dentro. O domínio não sabe que existe MongoDB, nem Express, nem React — e é essa ignorância deliberada que torna possível testar a regra de negócio sem subir banco nenhum e trocar a infraestrutura sem reescrever o que interessa. SOLID e DDD são maneiras diferentes de sustentar a mesma ideia, e ambas cobram um preço em arquivos e indireção que só compensa quando o sistema tem vida longa pela frente.

Fontes e Referências

Exercícios

Exercício 1

O relatório financeiro fecha com dois centavos de diferença. Os valores individuais estão corretos. Onde está o erro?

const itens = [19.99, 19.99, 19.99, 0.1, 0.2];

const total = itens.reduce((soma, v) => soma + v, 0);
console.log(total.toFixed(2));

// e no domínio:
class Pedido {
  constructor(valor) { this.valor = valor; } // number
}
Ver resposta

✓ Resposta: O erro é usar ponto flutuante para dinheiro. Números em JavaScript são binários de precisão dupla, e frações decimais como 0,1 e 0,2 não têm representação exata em binário — daí o clássico 0.1 + 0.2 resultar 0.30000000000000004, e somar 0,1 dez vezes dar 0.9999999999999999. Cada operação carrega um resto minúsculo, e num relatório com milhares de linhas esses restos se acumulam até virar centavos visíveis. Pior: o toFixed(2) esconde o problema na exibição enquanto o valor guardado continua errado, o que faz a diferença aparecer só no fechamento. A solução padrão da indústria é trabalhar em centavos, com inteiros — guardar 1999 em vez de 19.99 e dividir por 100 apenas na hora de mostrar. É exatamente o caso de um Value Object Dinheiro: ele encapsula o inteiro, oferece somar e multiplicar corretos, impede somar reais com dólares e centraliza a formatação. Quando os valores passam da casa dos quatrilhões de centavos, ou há divisão com arredondamento fiscal, o caminho é BigInt ou uma biblioteca de decimal. E no banco: Decimal128 no MongoDB, NUMERIC no PostgreSQL — nunca double.

Exercício 2

Como descobrir, em trinta segundos e sem ler o código todo, se a regra de dependência da Clean Architecture está sendo respeitada?

src/
├── dominio/        ← entidades e regras de negócio
├── aplicacao/      ← casos de uso
├── infra/          ← MongoDB, Express, envio de email
└── interfaces/     ← controllers, rotas
Ver resposta

✓ Resposta: Procurando imports para fora nas camadas de dentro. A regra é que as dependências apontam para o centro: o domínio não conhece ninguém, a aplicação conhece o domínio, e infra e interfaces conhecem as duas. Então basta um grep -rE "require|from" src/dominio/ e ver o que aparece: se houver mongoose, express, axios ou qualquer caminho ../infra, a regra foi quebrada. Um domínio saudável importa pouquíssimo — no limite, apenas outros arquivos do próprio domínio. Essa verificação vale mais do que parece porque a erosão arquitetural é gradual: ninguém decide quebrar o desenho, alguém só precisa de um campo e importa o model direto no caso de uso, e seis meses depois as camadas existem apenas no diagrama. Duas formas de tornar a verificação automática, que é o que de fato sustenta a arquitetura ao longo do tempo: a regra no-restricted-imports do ESLint, configurada por pasta, transforma a violação em erro de lint; e ferramentas como dependency-cruiser validam o grafo inteiro no CI e ainda desenham o diagrama. Sem isso, a regra depende de disciplina — e disciplina é justamente o que falta na semana da entrega.

Exercício 3

Testar este caso de uso exige subir um MongoDB. Que princípio do SOLID resolve, e como fica o teste depois?

const Tarefa = require('../infra/models/Tarefa');

class CriarTarefa {
  async executar(usuarioId, dados) {
    if (!dados.titulo) throw new Error('Título obrigatório.');
    return Tarefa.create({ ...dados, usuario: usuarioId });
  }
}
Ver resposta

✓ Resposta: A inversão de dependência, o D do SOLID. Hoje a classe importa o model diretamente e fica presa a ele: para testar a regra "título é obrigatório" — que não tem nada a ver com banco — é preciso ter um MongoDB de pé, o que torna o teste lento, dependente de ambiente e sujeito a falhar por motivos alheios ao que se quer verificar. Invertendo, o caso de uso passa a receber um repositório pelo construtor e a depender apenas do contrato dele: constructor(repo) { this.repo = repo; } e depois this.repo.salvar(...). O teste então injeta um repositório em memória, um objeto com um array dentro, e roda em milissegundos, sem infraestrutura. Vale uma observação honesta sobre JavaScript: não existe interface para garantir o contrato — o que existe é convenção, e é o teste do repositório real que assegura que ele cumpre o mesmo acordo do dublê. Daí a prática de escrever uma bateria de testes de contrato e rodá-la contra as duas implementações. E o alerta que acompanha: o repositório em memória tende a ser mais permissivo que o real — não valida tipo, não tem índice único, não reclama de campo obrigatório —, então testes que passam com ele e falham em produção são um risco conhecido, que se cobre mantendo alguns testes de integração contra o banco de verdade.

Exercício 4

Uma equipe de dois vai construir um CRUD interno que será usado por quinze pessoas. O arquiteto propõe Clean Architecture completa. Quando isso é a decisão certa, e quando não é?

# a proposta
src/dominio/entidades, src/dominio/repositorios, src/aplicacao/casos-de-uso,
src/infra/mongo, src/infra/http, src/interfaces/controllers, container.js
# 7 camadas, ~40 arquivos para um CRUD de 4 entidades
Ver resposta

✓ Resposta: Provavelmente não é. Arquitetura é um investimento: paga-se indireção agora para ganhar flexibilidade depois — e, como todo investimento, só compensa se houver um "depois" longo o bastante. Num CRUD interno de quatro entidades, com dois desenvolvedores e quinze usuários, o custo é imediato e visível (quarenta arquivos, três saltos para entender um fluxo de dez linhas, gente nova demorando a produzir) enquanto o benefício é hipotético. Os sinais que de fato justificam o investimento são concretos: regra de negócio complexa e cheia de casos, mais de um consumidor da mesma lógica — API, CLI, worker, fila —, expectativa de trocar peça de infraestrutura, equipe grande precisando trabalhar em paralelo, ou vida útil medida em anos. Nenhum deles é "é a forma correta de fazer". Vale acrescentar duas coisas. A primeira é que o erro oposto também existe e costuma ser mais caro no longo prazo: sistema que nasceu simples, cresceu sem estrutura e virou a bola de lama que o artigo descreve — a diferença é que esse custo chega devagar, e o do excesso chega na primeira semana. A segunda é que a escolha não é binária: separar a regra de negócio do framework é barato e vale quase sempre; contêiner de injeção, repositório abstrato e sete camadas é que devem esperar o problema aparecer.

Exercício 5

O código abaixo passa em todos os testes e respeita a assinatura da classe pai. Ainda assim viola um princípio do SOLID. Qual, e por quê?

class RepositorioTarefa {
  async salvar(tarefa) { /* grava e devolve a tarefa */ }
}

class RepositorioSomenteLeitura extends RepositorioTarefa {
  async salvar(tarefa) {
    throw new Error('Este repositório não permite escrita.');
  }
}
Ver resposta

✓ Resposta: Viola o princípio da substituição de Liskov, o L. A regra diz que um objeto da subclasse deve poder substituir o da classe base sem quebrar quem usa — e aqui não pode: qualquer código que receba um RepositorioTarefa e chame salvar funciona com a classe base e estoura com a filha. A assinatura foi respeitada, o que engana; o contrato, não — e contrato inclui o que a função promete fazer, não apenas quais argumentos aceita. O sintoma clássico dessa violação é o código cliente precisar perguntar com quem está falando, com um if (repo instanceof RepositorioSomenteLeitura), o que derrota o propósito do polimorfismo. A correção passa pelo I do SOLID, a segregação de interfaces: em vez de uma interface grande com leitura e escrita, duas menores — RepositorioLeitura com buscar e listar, e RepositorioEscrita estendendo-a com salvar e remover. Quem só lê recebe a primeira e nem tem como chamar o que não existe. Vale o alerta de que a herança é a fonte mais comum dessa armadilha: a relação "é um tipo de" parece natural — um repositório somente leitura parece um tipo de repositório — e ainda assim quebra a substituição. O teste mental que resolve é outro: pergunte se a subclasse faz tudo o que a base promete, não se ela se parece com a base.

Comentários

Mais em Javascript

Git avançado e fluxo de trabalho em equipe
Git avançado e fluxo de trabalho em equipe

Saber add, commit e push é o mínimo. O que aparece em projeto com mais gente é…

O que é Programação Assíncrona? O Event Loop Explicado
O que é Programação Assíncrona? O Event Loop Explicado

Uma linguagem que faz uma coisa de cada vez não deveria conseguir esperar três…

Express.js: o framework web do Node
Express.js: o framework web do Node

O mesmo endpoint que ocupava trinta linhas de if e regex cabe em quatro com…