Artigo 48 — Arquitetura de Software: SOLID, Clean Architecture e DDD
Por Prof. Ricardo Matos | Mestre em Programação
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('
');
} 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('
');
}
}
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çasConclusão
Neste artigo você aprendeu:
- Single Responsibility: uma classe, uma razão para mudar — separando validação, persistência e notificação
- Open/Closed: extensível sem modificação — adicionando formatadores sem tocar no código existente
- Liskov Substitution: subclasses que honram os contratos — repositórios em memória substituíveis por MongoDB
- Interface Segregation: interfaces coesas e pequenas — cada serviço implementa apenas o que usa
- Dependency Inversion: depender de abstrações, não de implementações — código testável sem banco de dados
- Clean Architecture: as quatro camadas — domínio, aplicação, infraestrutura e interfaces — e a regra das dependências
- A entidade de domínio com regras de negócio encapsuladas, a interface do repositório e sua implementação MongoDB
- Os casos de uso como orquestradores da lógica de aplicação
- O container de injeção de dependências como ponto de composição do sistema
- Value Objects imutáveis para modelar conceitos do domínio com precisão — Email, Dinheiro, Preço
- A Linguagem Ubíqua do DDD — código que fala a língua do negócio
No próximo artigo vamos encerrar o Módulo 8 com a revisão completa e o projeto final de arquitetura — aplicando SOLID, Clean Architecture e DDD na refatoração de uma feature real.
📌 Próximo artigo: Aula 49 — Revisão do Módulo 8 + Projeto: Refatoração com Clean Architecture
📚 Fontes e Referências
- Clean Architecture — Robert C. Martin (Pearson)
- Domain-Driven Design — Eric Evans (Addison-Wesley)
- Implementing Domain-Driven Design — Vaughn Vernon (Addison-Wesley)
- Agile Software Development: Principles, Patterns and Practices — Robert C. Martin
- Refactoring Guru — SOLID: https://refactoring.guru/pt-br/solid-principles
- Martin Fowler — DDD: https://martinfowler.com/tags/domain%20driven%20design.html
- Khalil Stemmler — DDD com TypeScript: https://khalilstemmler.com/articles/domain-driven-design-intro
- roadmap.sh — Software Design: https://roadmap.sh/software-design-architecture
Prof. Ricardo Matos
Mestre em Programação | Série: Dominando JavaScript em 1 Ano — Artigo 48/52
Quer a Aula 49 — Revisão do Módulo 8 + Projeto: Refatoração com Clean Architecture agora?
# you asked
Sim
# claude response
# 📘 Série: Dominando o JavaScript em 1 Ano