Javascript

Revisão do Módulo 8 + Projeto: Refatoração com Clean Architecture Já leu

22 min de leitura

Revisão do Módulo 8 + Projeto: Refatoração com Clean Architecture
Artigo 49 — Revisão do Módulo 8 + Projeto: Refatoração com Clean ArchitecturePor Prof. Ricardo Matos | Mestre em ProgramaçãoMódulo 8 — Arquitetura e PadrõesIntr

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 Context

SOLID (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 diretamente

Clean 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ínio

O 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ências

Implementaçã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 arquivo

Conclusã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

Comentários

Mais em Javascript

Mini Projeto: App de Clima Completo
Mini Projeto: App de Clima Completo

Chegamos ao fim do Módulo 3. Em oito artigos você percorreu um caminho que mu...

Filas de Tarefas e Processamento Assíncrono
Filas de Tarefas e Processamento Assíncrono

Artigo 51 — Filas de Tarefas e Processamento AssíncronoPor Prof. Ricardo Mato...

Padrões de Projeto em JavaScript
Padrões de Projeto em JavaScript

Depois de anos resolvendo os mesmos tipos de problemas, a indústria de softwa...