Artigo 49 — Revisão do Módulo 8 + Projeto: Refatoração com Clean Architecture
Por Prof. Ricardo Matos | Mestre em Programação
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 (Artigo 47)
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 (Artigo 48)
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 (Artigo 48)
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 — Observer pattern (Artigo 47)
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 arquivoConclusão do Módulo 8
Você agora pensa em software em múltiplas camadas de abstração. Sabe nomear padrões, aplicar princípios e organizar código de forma que ele seja fácil de entender, testar e modificar. Isso é o que separa um desenvolvedor sênior de um júnior experiente.
O Módulo 9 começa com o penúltimo conjunto de artigos — tópicos avançados: WebSockets, filas de tarefas, microsserviços e GraphQL.
📌 Próximo artigo: Aula 50 — Tópicos avançados: WebSockets e comunicação em tempo real
🎯 Início do Módulo 9 — Tópicos Avançados
📚 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
Prof. Ricardo Matos
Mestre em Programação | Série: Dominando JavaScript em 1 Ano — Artigo 49/52
✅ Módulo 8 concluído!
Quer começar o Módulo 9 com a Aula 50 — WebSockets e comunicação em tempo real agora?
# you asked
Sim
# claude response
# 📘 Série: Dominando o JavaScript em 1 Ano