Clean Architecture com TypeScript e Node.js: Estruturando Projetos que Escalam
A maioria dos projetos Node.js começa como um punhado de arquivos em src/, cresce para 200 arquivos em 6 meses e, em algum momento, ninguém sabe onde colocar a próxima feature sem quebrar três coisas. A reação natural é buscar "arquitetura" e cair em Clean Architecture. O problema: a maioria das implementações que circulam por aí adiciona 15 camadas de abstração para um CRUD de 4 entidades.
Clean Architecture funciona. Funciona quando você entende o princípio (inversão de dependência entre domínio e infraestrutura) e ignora o dogma (criar interface para tudo, inclusive para console.log). Este post mostra como estruturar um projeto Node.js com TypeScript usando as partes úteis da Clean Architecture, sem a burocracia que faz dev senior revirar os olhos.
O princípio que importa: a Regra de Dependência
Robert Martin define várias camadas concêntricas, mas o núcleo da ideia cabe em uma frase: código de negócio não importa código de infraestrutura. Nunca. A entidade Order não sabe que existe Prisma, Express ou Redis. Se você precisar trocar o banco, o domínio não muda. Se precisar trocar o framework HTTP, o domínio não muda.
Na prática, isso se traduz em três camadas para a maioria dos projetos Node.js:
| Camada | Responsabilidade | Depende de | Exemplos concretos |
|---|---|---|---|
| Domain | Entidades, value objects, regras de negócio puras | Nada | Order, Email (value object), calculateDiscount() |
| Application | Casos de uso, orquestração, ports (interfaces) | Domain | CreateOrderUseCase, OrderRepository (interface) |
| Infrastructure | Implementações concretas, frameworks, I/O | Application + Domain | PrismaOrderRepository, ExpressRouter, RedisCache |
A seta de dependência aponta sempre para dentro: Infrastructure conhece Application, Application conhece Domain, Domain não conhece ninguém.
Estrutura de pastas que reflete as camadas
Estrutura de pastas não é arquitetura, mas uma estrutura ruim sabota qualquer arquitetura boa. A organização abaixo separa por camada no primeiro nível e por módulo de domínio no segundo:
src/
├── domain/
│ ├── order/
│ │ ├── Order.ts
│ │ ├── OrderItem.ts
│ │ └── OrderStatus.ts
│ └── shared/
│ └── Email.ts
├── application/
│ ├── order/
│ │ ├── CreateOrderUseCase.ts
│ │ ├── CancelOrderUseCase.ts
│ │ └── ports/
│ │ ├── OrderRepository.ts
│ │ └── PaymentGateway.ts
│ └── shared/
│ └── UseCase.ts
├── infrastructure/
│ ├── persistence/
│ │ └── PrismaOrderRepository.ts
│ ├── http/
│ │ ├── server.ts
│ │ └── routes/
│ │ └── orderRoutes.ts
│ ├── payment/
│ │ └── StripePaymentGateway.ts
│ └── config/
│ └── container.ts
└── main.ts
Essa estrutura escala até 20-30 entidades sem virar labirinto. Acima disso, considere monorepos com Turborepo para dividir domínios em pacotes independentes.
Domain: entidades com regras, não DTOs glorificados
Uma entidade de domínio encapsula regras de negócio. Se sua entidade é um objeto com getters e setters e zero lógica, você tem um DTO disfarçado.
// src/domain/order/Order.ts
import { OrderItem } from "./OrderItem";
import { OrderStatus } from "./OrderStatus";
export class Order {
private constructor(
public readonly id: string,
public readonly customerId: string,
private _items: OrderItem[],
private _status: OrderStatus,
public readonly createdAt: Date
) {}
// Factory method garante que Order nunca nasce em estado inválido
static create(id: string, customerId: string, items: OrderItem[]): Order {
if (items.length === 0) {
throw new Error("Order must have at least one item");
}
return new Order(id, customerId, items, OrderStatus.PENDING, new Date());
}
// Reconstituição a partir do banco não valida regras de criação,
// porque o dado já foi validado quando entrou
static reconstitute(
id: string,
---
Leia o artigo completo em [https://www.vivodecodigo.com.br/backend/clean-architecture-typescript-nodejs-estrutura-projetos](https://www.vivodecodigo.com.br/backend/clean-architecture-typescript-nodejs-estrutura-projetos)