Módulo 8 — Arquitetura e Padrões
Introdução
Nos últimos dois artigos você aprendeu os padrões de projeto do GoF e os princípios de arquitetura — SOLID, Clean Architecture e DDD. São conceitos densos que precisam ser vistos em ação para realmente sedimentar.
Este artigo final do Módulo 8 tem um objetivo concreto: pegar a API de tarefas que construímos no Módulo 5 — funcional, mas estruturada de forma plana e acoplada — e refatorá-la aplicando Clean Architecture, SOLID e os padrões que aprendemos. Você vai ver antes e depois lado a lado, com explicações sobre cada decisão.
Refatorar é uma habilidade tão importante quanto construir do zero. Na maioria dos projetos profissionais, você herda código existente e precisa melhorá-lo gradualmente, sem quebrar o que funciona. É exatamente o que vamos praticar aqui.
Revisão — Módulo 8
Antes de começar o projeto, uma revisão rápida dos conceitos.
Padrões de Projeto
Padrões são soluções reutilizáveis para problemas recorrentes. Eles fornecem um vocabulário compartilhado e evitam que cada desenvolvedor reinvente a roda de forma diferente.
Criacionais — como criar objetos:
Singleton → uma instância global; módulos Node.js já são singletons
Factory → cria objetos sem expor a implementação concreta
Builder → constrói objetos complexos passo a passo com API fluente
Estruturais — como compor objetos:
Adapter → compatibiliza interfaces incompatíveis (ex: múltiplos gateways de pagamento)
Decorator → adiciona comportamento (cache, log) sem modificar a classe original
Comportamentais — como objetos se comunicam:
Observer → notificações desacopladas; emite evento, ouvintes reagem
Strategy → algoritmos intercambiáveis em tempo de execução
Command → encapsula operações como objetos; suporte a desfazer/refazer
React:
Compound Components → componentes que compartilham estado implicitamente via ContextSOLID
S — Single Responsibility: um módulo, uma razão para mudar
→ Separar validação, persistência, notificação em classes distintas
O — Open/Closed: aberto para extensão, fechado para modificação
→ Novos formatos de relatório = nova classe, não modificar código existente
L — Liskov Substitution: subclasses substituem a classe base sem surpresas
→ RepositorioEmMemoria substitui MongoRepositorio nos testes
I — Interface Segregation: interfaces pequenas e coesas
→ ServicoRelatorio não implementa métodos de autenticação
D — Dependency Inversion: depender de abstrações, não de implementações
→ ServicoTarefa recebe repositório injetado, não importa Mongoose diretamenteClean Architecture e DDD
Clean Architecture — camadas com dependências para dentro:
Domain → entidades e interfaces (puro JavaScript, zero dependências externas)
Application → casos de uso (orquestra o domínio)
Infrastructure → implementações (Mongoose, Nodemailer, bcrypt)
Interfaces → adaptadores HTTP (controllers, routes)
DDD — código que fala a língua do negócio:
Entidade → identidade única; regras de negócio encapsuladas no objeto
Value Object → imutável; igualdade por valor (Email, Dinheiro, Endereço)
Linguagem Ubíqua → nomes do código refletem o vocabulário do domínioO projeto — refatoração da API de tarefas
A API original foi construída no Módulo 5 de forma direta e funcional. Ela funciona, tem testes, tem validação — mas tudo está misturado nos controllers e routes. Vamos refatorá-la em camadas.
A estratégia de refatoração que vamos adotar é a seguinte: não jogar tudo fora e começar do zero. Em vez disso, extraímos gradualmente as responsabilidades para seus devidos lugares, mantendo os testes passando a cada passo. Esta é a forma profissional de refatorar.
Antes — estrutura plana (Módulo 5)
Esta é a estrutura que tínhamos. Funcional, mas tudo acoplado.
src/
├── middlewares/
│ ├── auth.js ← verifica JWT
│ ├── validar.js ← schemas Zod
│ └── erros.js ← handler de erros
├── models/
│ └── Tarefa.js ← schema Mongoose + toda a lógica
├── routes/
│ └── tarefas.js ← validação + lógica + queries + respostas
└── index.js// routes/tarefas.js — ANTES
// Controller, lógica de negócio e acesso ao banco todos no mesmo lugar
router.post('/', autenticar, validar(schemaCriar), async (req, res, next) => {
try {
// Lógica de negócio misturada com infraestrutura
if (req.body.prazo && new Date(req.body.prazo) < new Date()) {
return res.status(422).json({ erro: 'Prazo não pode ser no passado.' });
}
// Acesso direto ao Mongoose — acoplamento forte
const tarefa = await Tarefa.create({
...req.body,
usuario: req.usuario._id,
});
// Lógica de notificação no controller
await mailer.sendMail({
to: req.usuario.email,
subject: 'Tarefa criada',
text: `Sua tarefa "${tarefa.titulo}" foi criada.`,
});
// Invalidação de cache no controller
cache.del(`stats:${req.usuario._id}`);
res.status(201).json(tarefa);
} catch (erro) {
next(erro);
}
});Depois — estrutura em camadas (Clean Architecture)
src/
├── domain/
│ ├── entities/
│ │ └── Tarefa.js ← regras invariantes do domínio
│ ├── repositories/
│ │ └── ITarefaRepository.js ← contrato de persistência
│ └── events/
│ └── EventosDominio.js ← constantes dos eventos de domínio
│
├── application/
│ ├── useCases/
│ │ ├── CriarTarefa.js
│ │ ├── ListarTarefas.js
│ │ ├── ConcluirTarefa.js
│ │ ├── AtualizarTarefa.js
│ │ └── RemoverTarefa.js
│ └── services/
│ └── EstatisticasTarefa.js
│
├── infrastructure/
│ ├── database/
│ │ ├── models/
│ │ │ └── TarefaModel.js ← schema Mongoose
│ │ └── MongoTarefaRepository.js
│ ├── cache/
│ │ └── NodeCacheAdapter.js
│ ├── email/
│ │ └── NodemailerAdapter.js
│ └── events/
│ └── EventEmitter.js
│
├── interfaces/
│ └── http/
│ ├── controllers/
│ │ └── TarefaController.js
│ ├── middlewares/
│ │ ├── auth.js
│ │ └── erros.js
│ ├── validators/
│ │ └── tarefaSchemas.js
│ └── routes/
│ └── tarefas.js
│
└── container.js ← composição de todas as dependênciasImplementação camada por camada
Domínio
O domínio é o coração da aplicação. Não tem nenhuma dependência externa — é JavaScript puro que pode ser testado instantaneamente.
// src/domain/entities/Tarefa.js
// A entidade encapsula tudo que é verdadeiro sobre uma Tarefa
// independentemente de como ela é armazenada ou transmitida
class Tarefa {
#id;
#titulo;
#descricao;
#status;
#prioridade;
#prazo;
#usuarioId;
#criadoEm;
#atualizadoEm;
#concluidaEm;
constructor({
id = null,
titulo,
descricao = '',
status = 'pendente',
prioridade = 'media',
prazo = null,
usuarioId,
criadoEm = new Date(),
atualizadoEm = new Date(),
concluidaEm = null,
}) {
// Validações invariantes — sempre verdadeiras para qualquer Tarefa válida
this.#validarTitulo(titulo);
if (prazo) this.#validarPrazo(new Date(prazo), status);
this.#id = id;
this.#titulo = titulo.trim();
this.#descricao = descricao?.trim() || '';
this.#status = status;
this.#prioridade = prioridade;
this.#prazo = prazo ? new Date(prazo) : null;
this.#usuarioId = usuarioId;
this.#criadoEm = new Date(criadoEm);
this.#atualizadoEm = new Date(atualizadoEm);
this.#concluidaEm = concluidaEm ? new Date(concluidaEm) : null;
}
// ── Getters — leitura dos campos privados ────────
get id() { return this.#id; }
get titulo() { return this.#titulo; }
get descricao() { return this.#descricao; }
get status() { return this.#status; }
get prioridade() { return this.#prioridade; }
get prazo() { return this.#prazo; }
get usuarioId() { return this.#usuarioId; }
get criadoEm() { return this.#criadoEm; }
get atualizadoEm() { return this.#atualizadoEm; }
get concluidaEm() { return this.#concluidaEm; }
// ── Comportamentos — ações que a entidade pode executar ──
// Conclui a tarefa — com todas as regras de transição de estado
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();
this.#atualizadoEm = new Date();
}
cancelar() {
if (this.#status === 'concluida') {
throw new Error('Não é possível cancelar uma tarefa concluída.');
}
this.#status = 'cancelada';
this.#atualizadoEm = new Date();
}
iniciar() {
if (this.#status !== 'pendente') {
throw new Error('Apenas tarefas pendentes podem ser iniciadas.');
}
this.#status = 'em_progresso';
this.#atualizadoEm = new Date();
}
atualizarDados({ titulo, descricao, prioridade, prazo }) {
if (titulo !== undefined) {
this.#validarTitulo(titulo);
this.#titulo = titulo.trim();
}
if (descricao !== undefined) this.#descricao = descricao.trim();
if (prioridade !== undefined) this.#prioridade = prioridade;
if (prazo !== undefined) {
if (prazo) this.#validarPrazo(new Date(prazo), this.#status);
this.#prazo = prazo ? new Date(prazo) : null;
}
this.#atualizadoEm = new Date();
}
// ── Consultas — perguntas sobre o estado ────────
estaAtrasada() {
return (
this.#prazo !== null &&
this.#status !== 'concluida' &&
this.#status !== 'cancelada' &&
new Date() > this.#prazo
);
}
pertenceAo(usuarioId) {
return String(this.#usuarioId) === String(usuarioId);
}
estaAtiva() {
return this.#status === 'pendente' || this.#status === 'em_progresso';
}
// ── Validações privadas ──────────────────────────
#validarTitulo(titulo) {
if (!titulo || typeof titulo !== 'string') {
throw new Error('Título é obrigatório.');
}
if (titulo.trim().length < 2) {
throw new Error('Título deve ter pelo menos 2 caracteres.');
}
if (titulo.trim().length > 200) {
throw new Error('Título não pode ter mais de 200 caracteres.');
}
}
#validarPrazo(prazo, status) {
// Só valida prazo futuro para tarefas novas (sem id)
// Tarefas existentes podem ter prazo passado — não invalidamos
if (!this.#id && prazo < new Date()) {
throw new Error('Prazo não pode ser no passado para novas tarefas.');
}
}
// Serialização — converte para objeto plano
toJSON() {
return {
id: this.#id,
titulo: this.#titulo,
descricao: this.#descricao,
status: this.#status,
prioridade: this.#prioridade,
prazo: this.#prazo,
estaAtrasada: this.estaAtrasada(),
usuarioId: this.#usuarioId,
criadoEm: this.#criadoEm,
atualizadoEm: this.#atualizadoEm,
concluidaEm: this.#concluidaEm,
};
}
}
module.exports = Tarefa;// src/domain/events/EventosDominio.js
// Constantes dos eventos — evita strings mágicas espalhadas pelo código
// Se o nome de um evento mudar, muda em um só lugar
const EVENTOS = Object.freeze({
TAREFA_CRIADA: 'tarefa:criada',
TAREFA_CONCLUIDA: 'tarefa:concluida',
TAREFA_CANCELADA: 'tarefa:cancelada',
TAREFA_ATRASADA: 'tarefa:atrasada',
TAREFA_REMOVIDA: 'tarefa:removida',
});
module.exports = EVENTOS;Aplicação — casos de uso
Cada caso de uso representa um cenário de negócio. São os únicos que conhecem o domínio e coordenam a infraestrutura através das interfaces.
// src/application/useCases/CriarTarefa.js
const Tarefa = require('../../domain/entities/Tarefa');
const EVENTOS = require('../../domain/events/EventosDominio');
class CriarTarefa {
constructor(tarefaRepository, eventEmitter) {
this.tarefaRepository = tarefaRepository;
this.eventEmitter = eventEmitter;
}
async execute({ titulo, descricao, prioridade, prazo, usuario }) {
// Cria a entidade — validações do domínio rodam aqui
// Se o título for inválido, a entidade lança erro antes de qualquer I/O
const tarefa = new Tarefa({
titulo,
descricao,
prioridade,
prazo,
usuarioId: usuario.id || usuario._id,
});
// Persiste — não sabe se é MongoDB, PostgreSQL ou memória
const tarefaSalva = await this.tarefaRepository.salvar(tarefa);
// Emite evento — os ouvintes decidem o que fazer (email, cache, log)
// O caso de uso não sabe e não precisa saber quem vai reagir
await this.eventEmitter.emit(EVENTOS.TAREFA_CRIADA, {
tarefa: tarefaSalva,
usuario,
});
return tarefaSalva;
}
}
module.exports = CriarTarefa;// src/application/useCases/ConcluirTarefa.js
const EVENTOS = require('../../domain/events/EventosDominio');
class ConcluirTarefa {
constructor(tarefaRepository, eventEmitter) {
this.tarefaRepository = tarefaRepository;
this.eventEmitter = eventEmitter;
}
async execute({ tarefaId, usuario }) {
const tarefa = await this.tarefaRepository.buscarPorId(tarefaId);
if (!tarefa) {
const erro = new Error('Tarefa não encontrada.');
erro.statusCode = 404;
throw erro;
}
// Autorização — regra de negócio na entidade
if (!tarefa.pertenceAo(usuario.id || usuario._id)) {
const erro = new Error('Sem permissão para modificar esta tarefa.');
erro.statusCode = 403;
throw erro;
}
// A entidade valida a transição de estado
// Se a tarefa já estiver concluída, lança erro com mensagem clara
tarefa.concluir();
// Persiste o novo estado
const tarefaAtualizada = await this.tarefaRepository.atualizar(tarefa);
await this.eventEmitter.emit(EVENTOS.TAREFA_CONCLUIDA, {
tarefa: tarefaAtualizada,
usuario,
});
return tarefaAtualizada;
}
}
module.exports = ConcluirTarefa;// src/application/useCases/ListarTarefas.js
class ListarTarefas {
constructor(tarefaRepository) {
this.tarefaRepository = tarefaRepository;
}
async execute({ usuarioId, filtros = {}, paginacao = {} }) {
const { status, prioridade, busca } = filtros;
const { pagina = 1, porPagina = 10, ordenar = 'criadoEm' } = paginacao;
// Constrói os filtros de domínio — sem lógica de banco aqui
const criterios = {
usuarioId,
...(status && { status }),
...(prioridade && { prioridade }),
...(busca && { busca }),
};
return this.tarefaRepository.buscarComPaginacao(criterios, {
pagina: Math.max(1, Number(pagina)),
porPagina: Math.min(50, Math.max(1, Number(porPagina))),
ordenar,
});
}
}
module.exports = ListarTarefas;// src/application/useCases/AtualizarTarefa.js
class AtualizarTarefa {
constructor(tarefaRepository) {
this.tarefaRepository = tarefaRepository;
}
async execute({ tarefaId, usuario, dados }) {
const tarefa = await this.tarefaRepository.buscarPorId(tarefaId);
if (!tarefa) {
const erro = new Error('Tarefa não encontrada.');
erro.statusCode = 404;
throw erro;
}
if (!tarefa.pertenceAo(usuario.id || usuario._id)) {
const erro = new Error('Sem permissão para modificar esta tarefa.');
erro.statusCode = 403;
throw erro;
}
// A entidade atualiza seus próprios dados com suas próprias validações
tarefa.atualizarDados(dados);
return this.tarefaRepository.atualizar(tarefa);
}
}
module.exports = AtualizarTarefa;// src/application/useCases/RemoverTarefa.js
const EVENTOS = require('../../domain/events/EventosDominio');
class RemoverTarefa {
constructor(tarefaRepository, eventEmitter) {
this.tarefaRepository = tarefaRepository;
this.eventEmitter = eventEmitter;
}
async execute({ tarefaId, usuario }) {
const tarefa = await this.tarefaRepository.buscarPorId(tarefaId);
if (!tarefa) {
const erro = new Error('Tarefa não encontrada.');
erro.statusCode = 404;
throw erro;
}
if (!tarefa.pertenceAo(usuario.id || usuario._id)) {
const erro = new Error('Sem permissão para remover esta tarefa.');
erro.statusCode = 403;
throw erro;
}
await this.tarefaRepository.deletar(tarefaId);
await this.eventEmitter.emit(EVENTOS.TAREFA_REMOVIDA, {
tarefaId,
usuario,
});
return { mensagem: `Tarefa "${tarefa.titulo}" removida com sucesso.` };
}
}
module.exports = RemoverTarefa;Infraestrutura — implementações concretas
// src/infrastructure/database/MongoTarefaRepository.js
// Implementa o contrato do domínio usando Mongoose
const Tarefa = require('../../domain/entities/Tarefa');
const TarefaModel = require('./models/TarefaModel');
class MongoTarefaRepository {
// Converte documento MongoDB para entidade do domínio
// Este mapeamento isola o resto do código dos detalhes do banco
#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(),
criadoEm: doc.criadoEm,
atualizadoEm: doc.atualizadoEm,
concluidaEm: doc.concluidaEm,
});
}
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 buscarComPaginacao(criterios, opcoes) {
const { usuarioId, status, prioridade, busca } = criterios;
const { pagina, porPagina, ordenar } = opcoes;
// Traduz critérios do domínio para filtros do Mongoose
const filtros = { usuario: usuarioId };
if (status) filtros.status = status;
if (prioridade) filtros.prioridade = prioridade;
if (busca) filtros.$text = { $search: busca };
const skip = (pagina - 1) * porPagina;
// Mapeia campo de ordenação do domínio para notação Mongoose
const mapaOrdenacao = {
criadoEm: { criadoEm: -1 },
prazo: { prazo: 1 },
prioridade: { prioridade: -1 },
};
const sort = mapaOrdenacao[ordenar] || { criadoEm: -1 };
const [docs, total] = await Promise.all([
TarefaModel.find(filtros).sort(sort).skip(skip).limit(porPagina).lean(),
TarefaModel.countDocuments(filtros),
]);
return {
dados: docs.map((d) => this.#toEntity(d)),
paginacao: {
total,
pagina,
porPagina,
totalPaginas: Math.ceil(total / porPagina),
},
};
}
async atualizar(tarefa) {
const doc = await TarefaModel.findByIdAndUpdate(
tarefa.id,
{
titulo: tarefa.titulo,
descricao: tarefa.descricao,
status: tarefa.status,
prioridade: tarefa.prioridade,
prazo: tarefa.prazo,
atualizadoEm: tarefa.atualizadoEm,
concluidaEm: tarefa.concluidaEm,
},
{ new: true }
).lean();
return this.#toEntity(doc);
}
async deletar(id) {
await TarefaModel.findByIdAndDelete(id);
}
}
module.exports = MongoTarefaRepository;// src/infrastructure/events/EventEmitter.js
// Implementação do event emitter — o padrão Observer, visto no artigo anterior
class EventEmitter {
#handlers = new Map();
on(evento, handler) {
if (!this.#handlers.has(evento)) {
this.#handlers.set(evento, new Set());
}
this.#handlers.get(evento).add(handler);
// Retorna função de remoção para facilitar cleanup
return () => this.#handlers.get(evento)?.delete(handler);
}
async emit(evento, dados) {
const handlers = this.#handlers.get(evento);
if (!handlers || handlers.size === 0) return;
// Executa todos os handlers em paralelo
// allSettled garante que um erro não cancela os outros
const resultados = await Promise.allSettled(
[...handlers].map((h) => Promise.resolve(h(dados)))
);
// Loga erros nos handlers sem lançar exceção no fluxo principal
resultados.forEach((r, i) => {
if (r.status === 'rejected') {
console.error(
`[EventEmitter] Handler #${i} de "${evento}" falhou:`,
r.reason?.message
);
}
});
}
}
module.exports = EventEmitter;Container — composição de todas as dependências
O container é onde as peças se encaixam. É o único lugar do sistema onde implementações concretas são referenciadas diretamente. Todo o resto depende de abstrações.
// src/container.js
// Composição manual de dependências — o ponto de montagem da aplicação
// Tudo que é concreto está aqui; o resto trabalha com abstrações
const NodeCache = require('node-cache');
// ── Infraestrutura ───────────────────────────────────
const MongoTarefaRepository = require('./infrastructure/database/MongoTarefaRepository');
const EventEmitter = require('./infrastructure/events/EventEmitter');
// ── Casos de uso ─────────────────────────────────────
const CriarTarefa = require('./application/useCases/CriarTarefa');
const ListarTarefas = require('./application/useCases/ListarTarefas');
const ConcluirTarefa = require('./application/useCases/ConcluirTarefa');
const AtualizarTarefa = require('./application/useCases/AtualizarTarefa');
const RemoverTarefa = require('./application/useCases/RemoverTarefa');
// ── Interfaces ───────────────────────────────────────
const TarefaController = require('./interfaces/http/controllers/TarefaController');
// ── Constantes ───────────────────────────────────────
const EVENTOS = require('./domain/events/EventosDominio');
// Instância do event emitter — compartilhada por toda a aplicação
const eventEmitter = new EventEmitter();
// Cache compartilhado — 5 minutos de TTL padrão
const cache = new NodeCache({ stdTTL: 300 });
// Registra os ouvintes de eventos
// Cada ouvinte tem uma responsabilidade clara e pode ser adicionado
// ou removido sem tocar nos casos de uso
eventEmitter.on(EVENTOS.TAREFA_CRIADA, ({ usuario }) => {
// Invalida o cache de estatísticas do usuário quando uma tarefa é criada
cache.del(`stats:${usuario.id || usuario._id}`);
});
eventEmitter.on(EVENTOS.TAREFA_CONCLUIDA, ({ usuario }) => {
cache.del(`stats:${usuario.id || usuario._id}`);
});
eventEmitter.on(EVENTOS.TAREFA_REMOVIDA, ({ usuario }) => {
cache.del(`stats:${usuario.id || usuario._id}`);
});
// Instancia o repositório
const tarefaRepository = new MongoTarefaRepository();
// Instancia os casos de uso com suas dependências injetadas
const criarTarefa = new CriarTarefa(tarefaRepository, eventEmitter);
const listarTarefas = new ListarTarefas(tarefaRepository);
const concluirTarefa = new ConcluirTarefa(tarefaRepository, eventEmitter);
const atualizarTarefa = new AtualizarTarefa(tarefaRepository);
const removerTarefa = new RemoverTarefa(tarefaRepository, eventEmitter);
// Instancia o controller com os casos de uso injetados
const tarefaController = new TarefaController(
criarTarefa,
listarTarefas,
concluirTarefa,
atualizarTarefa,
removerTarefa,
cache
);
module.exports = { tarefaController };Controller e rotas
// src/interfaces/http/controllers/TarefaController.js
// O controller traduz HTTP para domínio e vice-versa
// Não tem lógica de negócio — apenas orquestra e adapta
class TarefaController {
constructor(
criarTarefa,
listarTarefas,
concluirTarefa,
atualizarTarefa,
removerTarefa,
cache
) {
this.criarTarefa = criarTarefa;
this.listarTarefas = listarTarefas;
this.concluirTarefa = concluirTarefa;
this.atualizarTarefa = atualizarTarefa;
this.removerTarefa = removerTarefa;
this.cache = cache;
// Vincula o contexto para poder usar como middleware do Express
// Sem isso, 'this' seria undefined quando Express chamar os métodos
this.listar = this.listar.bind(this);
this.criar = this.criar.bind(this);
this.concluir = this.concluir.bind(this);
this.atualizar = this.atualizar.bind(this);
this.remover = this.remover.bind(this);
this.estatisticas = this.estatisticas.bind(this);
}
async listar(req, res, next) {
try {
const resultado = await this.listarTarefas.execute({
usuarioId: req.usuario._id,
filtros: {
status: req.query.status,
prioridade: req.query.prioridade,
busca: req.query.busca,
},
paginacao: {
pagina: req.query.pagina,
porPagina: req.query.por_pagina,
ordenar: req.query.ordenar,
},
});
res.json({
dados: resultado.dados.map((t) => t.toJSON()),
paginacao: resultado.paginacao,
});
} catch (erro) {
next(erro);
}
}
async criar(req, res, next) {
try {
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,
});
res.status(201).json(tarefa.toJSON());
} catch (erro) {
next(erro);
}
}
async concluir(req, res, next) {
try {
const tarefa = await this.concluirTarefa.execute({
tarefaId: req.params.id,
usuario: req.usuario,
});
res.json(tarefa.toJSON());
} catch (erro) {
next(erro);
}
}
async atualizar(req, res, next) {
try {
const tarefa = await this.atualizarTarefa.execute({
tarefaId: req.params.id,
usuario: req.usuario,
dados: {
titulo: req.body.titulo,
descricao: req.body.descricao,
prioridade: req.body.prioridade,
prazo: req.body.prazo,
},
});
res.json(tarefa.toJSON());
} catch (erro) {
next(erro);
}
}
async remover(req, res, next) {
try {
const resultado = await this.removerTarefa.execute({
tarefaId: req.params.id,
usuario: req.usuario,
});
res.json(resultado);
} catch (erro) {
next(erro);
}
}
async estatisticas(req, res, next) {
try {
const usuarioId = req.usuario._id.toString();
const chave = `stats:${usuarioId}`;
const emCache = this.cache.get(chave);
if (emCache) return res.json(emCache);
// Aggregation — poderia estar em um caso de uso separado
// deixado no controller para simplicidade neste exemplo
const TarefaModel = require('../../../infrastructure/database/models/TarefaModel');
const mongoose = require('mongoose');
const stats = await TarefaModel.aggregate([
{ $match: { usuario: new mongoose.Types.ObjectId(usuarioId) } },
{
$group: {
_id: '$status',
total: { $sum: 1 },
},
},
]);
const resposta = {
porStatus: stats,
geradoEm: new Date().toISOString(),
};
this.cache.set(chave, resposta);
res.json(resposta);
} catch (erro) {
next(erro);
}
}
}
module.exports = TarefaController;// src/interfaces/http/routes/tarefas.js
// As rotas são simples — apenas conectam endpoints a métodos do controller
// Toda a lógica está nos casos de uso, não aqui
const express = require('express');
const { z } = require('zod');
const { autenticar } = require('../middlewares/auth');
const { validar } = require('../middlewares/validar');
const { tarefaController } = require('../../../container');
const router = express.Router();
// Schemas de validação — apenas formato e tipo, não regras de negócio
const schemaCriar = z.object({
titulo: z.string().min(2).max(200).trim(),
descricao: z.string().max(2000).trim().optional(),
prioridade: z.enum(['baixa', 'media', 'alta']).default('media'),
prazo: z.string().datetime({ offset: true }).optional(),
});
const schemaAtualizar = schemaCriar.partial();
// Todas as rotas exigem autenticação
router.use(autenticar);
router.get('/', tarefaController.listar);
router.get('/estatisticas', tarefaController.estatisticas);
router.post('/', validar(schemaCriar), tarefaController.criar);
router.patch('/:id', validar(schemaAtualizar), tarefaController.atualizar);
router.patch('/:id/concluir', tarefaController.concluir);
router.delete('/:id', tarefaController.remover);
module.exports = router;Testes — o maior benefício da arquitetura
A melhor prova de que a arquitetura está correta é a facilidade de testar. Com Clean Architecture, os testes de domínio e de casos de uso não precisam de banco de dados, de servidor HTTP ou de qualquer infraestrutura — são JavaScript puro rodando em milissegundos.
// tests/domain/Tarefa.test.js
// Testa a entidade — zero dependências externas
const Tarefa = require('../../src/domain/entities/Tarefa');
describe('Entidade Tarefa', () => {
function criarTarefaValida(overrides = {}) {
return new Tarefa({
titulo: 'Estudar Clean Architecture',
prioridade: 'alta',
usuarioId: 'usuario-123',
...overrides,
});
}
describe('Criação', () => {
it('cria uma tarefa válida com status pendente por padrão', () => {
const tarefa = criarTarefaValida();
expect(tarefa.status).toBe('pendente');
expect(tarefa.titulo).toBe('Estudar Clean Architecture');
});
it('lança erro para título muito curto', () => {
expect(() => criarTarefaValida({ titulo: 'A' }))
.toThrow('Título deve ter pelo menos 2 caracteres.');
});
it('lança erro para título vazio', () => {
expect(() => criarTarefaValida({ titulo: '' }))
.toThrow('Título é obrigatório.');
});
it('aceita prazo no futuro', () => {
const amanha = new Date(Date.now() + 86400000);
expect(() => criarTarefaValida({ prazo: amanha })).not.toThrow();
});
});
describe('concluir()', () => {
it('muda o status para concluida', () => {
const tarefa = criarTarefaValida();
tarefa.concluir();
expect(tarefa.status).toBe('concluida');
expect(tarefa.concluidaEm).toBeInstanceOf(Date);
});
it('lança erro ao concluir tarefa já concluída', () => {
const tarefa = criarTarefaValida();
tarefa.concluir();
expect(() => tarefa.concluir()).toThrow('Tarefa já está concluída.');
});
it('lança erro ao concluir tarefa cancelada', () => {
const tarefa = criarTarefaValida();
tarefa.cancelar();
expect(() => tarefa.concluir())
.toThrow('Não é possível concluir uma tarefa cancelada.');
});
});
describe('estaAtrasada()', () => {
it('retorna true para tarefa pendente com prazo passado', () => {
const ontem = new Date(Date.now() - 86400000);
// Cria com id para contornar validação de prazo futuro em novas tarefas
const tarefa = new Tarefa({
id: 'tarefa-existente',
titulo: 'Tarefa atrasada',
prazo: ontem,
usuarioId: 'u-1',
});
expect(tarefa.estaAtrasada()).toBe(true);
});
it('retorna false para tarefa concluída mesmo com prazo passado', () => {
const ontem = new Date(Date.now() - 86400000);
const tarefa = new Tarefa({
id: 'tarefa-existente',
titulo: 'Tarefa',
prazo: ontem,
status: 'concluida',
usuarioId: 'u-1',
});
expect(tarefa.estaAtrasada()).toBe(false);
});
});
});// tests/application/CriarTarefa.test.js
// Testa o caso de uso com repositório e event emitter em memória
// Zero I/O — roda em milissegundos
const CriarTarefa = require('../../src/application/useCases/CriarTarefa');
const EVENTOS = require('../../src/domain/events/EventosDominio');
// Repositório em memória — implementa o mesmo contrato do MongoDB
class RepositorioEmMemoria {
constructor() { this.itens = []; this.contador = 0; }
async salvar(tarefa) {
const id = String(++this.contador);
// Simula o que o banco faria — adiciona um id
const salvo = Object.assign(Object.create(Object.getPrototypeOf(tarefa)), tarefa);
Object.defineProperty(salvo, '_id_simulado', { value: id });
// Retorna uma versão com id
const { Tarefa } = require('../../src/domain/entities/Tarefa');
this.itens.push({ ...tarefa.toJSON(), id });
return tarefa;
}
async buscarPorId(id) { return this.itens.find((i) => i.id === id) || null; }
}
// Event emitter que registra as emissões para verificação
class EventEmitterMock {
constructor() { this.emissoes = []; }
async emit(evento, dados) { this.emissoes.push({ evento, dados }); }
foiEmitido(evento) { return this.emissoes.some((e) => e.evento === evento); }
dadosDoEvento(evento) { return this.emissoes.find((e) => e.evento === evento)?.dados; }
}
describe('CriarTarefa', () => {
let repositorio;
let eventEmitter;
let casoDeUso;
const usuarioFake = { id: 'usuario-1', email: 'teste@teste.com', nome: 'Teste' };
beforeEach(() => {
repositorio = new RepositorioEmMemoria();
eventEmitter = new EventEmitterMock();
casoDeUso = new CriarTarefa(repositorio, eventEmitter);
});
it('cria uma tarefa com dados válidos', async () => {
const tarefa = await casoDeUso.execute({
titulo: 'Implementar Clean Architecture',
prioridade: 'alta',
usuario: usuarioFake,
});
expect(tarefa.titulo).toBe('Implementar Clean Architecture');
expect(tarefa.status).toBe('pendente');
expect(tarefa.usuarioId).toBe('usuario-1');
});
it('emite o evento TAREFA_CRIADA após criar', async () => {
await casoDeUso.execute({
titulo: 'Tarefa de teste',
usuario: usuarioFake,
});
expect(eventEmitter.foiEmitido(EVENTOS.TAREFA_CRIADA)).toBe(true);
const dados = eventEmitter.dadosDoEvento(EVENTOS.TAREFA_CRIADA);
expect(dados.usuario.id).toBe('usuario-1');
});
it('lança erro para título inválido — sem chamar o repositório', async () => {
await expect(
casoDeUso.execute({ titulo: 'A', usuario: usuarioFake })
).rejects.toThrow('Título deve ter pelo menos 2 caracteres.');
// O repositório não foi chamado — a entidade rejeitou antes
expect(repositorio.itens).toHaveLength(0);
// O evento não foi emitido
expect(eventEmitter.foiEmitido(EVENTOS.TAREFA_CRIADA)).toBe(false);
});
});O que a refatoração ganhou
Antes e depois lado a lado, em termos quantitativos e qualitativos.
ANTES DEPOIS
─────────────────────────────────────────────────────────
Arquivo principal routes/tarefas.js 5 camadas separadas
Linhas por arquivo ~150 ~50 por arquivo
Razões para mudar Muitas Uma por arquivo (SRP)
Testável sem banco Não Domínio e casos de uso: sim
Trocar MongoDB por SQL Reescreve rotas Troca só o repositório
Adicionar notificação Modifica controller Adiciona ouvinte no container
Lógica de negócio Espalhada Centralizada nas entidades
Novo caso de uso Modifica rota Cria novo arquivoO que muda ao fim deste módulo não é o que você consegue fazer, é a distância de onde você olha: o mesmo problema passa a ter uma versão em código, uma em objeto e uma em camada, e escolher a altura certa vira parte da decisão. A refatoração do projeto mostra o custo dessa escolha — arquitetura em excesso sai tão caro quanto arquitetura de menos, e o que separa uma da outra é o tempo que o sistema ainda tem pela frente.
Fontes e Referências
- Clean Architecture — Robert C. Martin (Pearson)
- Domain-Driven Design — Eric Evans (Addison-Wesley)
- Refactoring — Martin Fowler (Addison-Wesley)
- Working Effectively with Legacy Code — Michael Feathers (Pearson)
- Khalil Stemmler — Clean Architecture com Node.js: https://khalilstemmler.com
- Herberto Graça — DDD, Hexagonal, Onion, Clean Architecture: https://herbertograca.com
- roadmap.sh — Software Design: https://roadmap.sh/software-design-architecture
✅ Módulo 8 concluído!
Exercícios
Exercício 1
Criar uma tarefa levava 40 ms. Depois que um handler de email foi registrado no evento, passou a levar 2,3 segundos. O emit está correto?
// no caso de uso
const tarefa = await this.repo.salvar(nova);
await this.eventos.emit('tarefa:criada', tarefa);
return tarefa;
// no emit
const resultados = await Promise.allSettled(
[...handlers].map((h) => Promise.resolve(h(dados)))
);
Ver resposta
✓ Resposta: O emit está bem escrito — o allSettled impede que um handler com erro derrube os outros, e os erros são registrados sem contaminar o fluxo. O problema é quem espera por ele. Com o await antes do return, a resposta ao usuário só sai depois que todos os handlers terminarem, e um deles abre conexão SMTP e espera um servidor de email. O tempo de resposta da API passou a depender de um serviço externo que não tem nada a ver com criar a tarefa. Há duas saídas, e a escolha depende da garantia desejada. A rápida é não esperar: disparar sem await, com um .catch para não gerar rejeição não tratada — a tarefa é criada, a resposta sai em 40 ms e o email vai quando for. O preço é que, se o processo morrer nesse intervalo, o email se perde em silêncio, e ninguém fica sabendo. A saída robusta é a fila: o handler apenas enfileira um job, o que leva milissegundos, e um worker separado faz o envio com retentativa e registro — é exatamente o assunto do artigo de filas. A distinção que orienta a decisão é esta: o que é essencial para responder fica no caminho da requisição; o que é consequência sai dele.
Exercício 2
O email de "tarefa criada" chega ao usuário, mas a tarefa não aparece no sistema. Isso acontece raramente e de forma imprevisível. Por quê?
async executar(dados) {
const tarefa = Tarefa.criar(dados);
await this.eventos.emit('tarefa:criada', tarefa);
await this.repo.salvar(tarefa);
return tarefa;
}
Ver resposta
✓ Resposta: O evento é emitido antes da persistência. Na maior parte das vezes o salvar logo em seguida funciona e ninguém nota; quando ele falha — validação, conexão caída, violação de índice único —, o email já saiu anunciando algo que não existe. A ordem correta é persistir primeiro e emitir depois: só anuncie fato consumado. Isso resolve o caso comum, mas não o caso difícil, que vale conhecer porque aparece em qualquer sistema com efeitos externos: mesmo na ordem certa, o processo pode morrer entre o commit e o emit, e aí a tarefa existe e o email nunca sai. Não há solução perfeita, e o que existe são dois desenhos conhecidos. O mais simples é aceitar a falha e ter um processo de reconciliação — uma varredura periódica que encontra registros sem a notificação correspondente. O mais robusto é o padrão outbox: gravar o evento numa tabela na mesma transação do dado, e deixar um worker ler essa tabela e publicar. Como as duas escritas são atômicas, ou ambas acontecem ou nenhuma acontece, e o evento nunca se perde. O preço é o processamento passar a ser assíncrono e, possivelmente, repetido — o que exige que o handler seja idempotente.
Exercício 3
Você vai refatorar um serviço de 400 linhas sem testes para Clean Architecture. Qual o primeiro passo — e por que não é criar as pastas?
// src/services/tarefaService.js — 400 linhas
// valida, calcula, grava no Mongo, envia email, gera PDF, atualiza cache
class TarefaService {
async criar(usuarioId, dados) { /* ... 120 linhas ... */ }
async atualizar(id, dados) { /* ... 90 linhas ... */ }
// ...
}
Ver resposta
✓ Resposta: O primeiro passo é escrever testes de caracterização — testes que capturam o comportamento atual, inclusive o que parece errado. Sem eles não existe refatoração, existe reescrita às cegas: refatorar significa, por definição, mudar a estrutura sem mudar o comportamento, e só há como afirmar isso se o comportamento estiver registrado em algum lugar. O nome vem daí — eles não descrevem o que o código deveria fazer, e sim o que ele faz hoje, que é o contrato de fato com quem já usa o sistema. Um detalhe importante: se durante a escrita você descobrir comportamento claramente errado, registre-o assim mesmo, com um comentário, e corrija depois, num passo separado. Misturar correção com refatoração é o que torna impossível saber qual das duas quebrou. Como esse serviço faz de tudo, o caminho prático é começar pelas bordas — testar o método pela entrada e pela saída, com dublês para email, PDF e cache — e só então extrair as camadas uma de cada vez, rodando a bateria a cada extração. E vale a advertência sobre a ordem que a pergunta sugere: criar a estrutura de pastas primeiro leva ao pior dos mundos, que é código antigo espalhado por diretórios novos, com a aparência de arquitetura e nenhuma das garantias.
Exercício 4
O projeto tem um container.js onde tudo concreto é montado. Como verificar, de forma automática, que nenhuma outra parte do código instancia implementação concreta?
// container.js — o único lugar que pode conhecer o concreto
const repo = new MongoTarefaRepository();
const eventos = new EventEmitter();
const criarTarefa = new CriarTarefa({ repo, eventos });
Ver resposta
✓ Resposta: Transformando a regra em verificação automática, porque convenção sozinha não sobrevive a seis meses de prazo apertado. O caminho mais barato é o ESLint com no-restricted-imports, configurado por pasta: proibir que qualquer arquivo fora de infrastructure/ e do container.js importe mongoose, o cliente de email ou os repositórios concretos. A violação passa a ser erro de lint, aparece no editor e barra o commit. Para o grafo completo existe o dependency-cruiser, que valida regras de camada no CI — e ainda gera o diagrama, útil para mostrar a arquitetura real, que quase nunca é igual ao desenho no quadro. Uma verificação manual rápida, que serve para diagnóstico: grep -rn "new Mongo\|require('mongoose')" src/ --exclude-dir=infrastructure. Se voltar vazio, o desenho está de pé. Vale reter o princípio por trás do container: ele é o ponto de composição, o único lugar que sabe quais peças concretas existem, e é justamente por concentrar esse conhecimento que todo o resto consegue ignorá-lo. Se implementações concretas começarem a ser instanciadas espalhadas pelo código, o container vira decoração — as camadas continuam no diagrama e deixam de existir no programa.
Exercício 5
Os handlers são guardados num Set, e o on devolve uma função de remoção. Que dois problemas isso resolve?
on(evento, handler) {
if (!this.#handlers.has(evento)) this.#handlers.set(evento, new Set());
this.#handlers.get(evento).add(handler);
return () => this.#handlers.get(evento)?.delete(handler);
}
Ver resposta
✓ Resposta: O Set resolve o registro duplicado: se o mesmo handler for inscrito duas vezes — o que acontece quando um módulo é carregado de novo, quando um efeito roda outra vez, ou quando a inicialização é chamada em mais de um lugar —, o Set o mantém uma vez só, e o evento não dispara em dobro. Com um array, cada registro se acumularia. A função de remoção devolvida resolve o segundo problema, que é a remoção correta: para desinscrever é preciso ter a mesma referência usada no registro, e guardá-la é justamente o que as pessoas esquecem — tanto que passar uma arrow nova para o off é um erro clássico, no DOM e aqui. Devolvendo o cancelador, o chamador não precisa guardar nada: basta chamar o que recebeu. É o mesmo formato que o useEffect do React usa para a limpeza, e a conveniência é a razão pela qual ele é seguido. Duas observações práticas: convém o cancelador ser idempotente, já que chamá-lo duas vezes é comum e com Set.delete isso é inofensivo; e quando o handler for um método de objeto, é preciso cuidar do this — servico.enviar passado solto perde o contexto, e ou se usa bind, ou uma arrow, guardando a referência que o bind devolveu.