Ir para o conteúdo
Backend

Clean Architecture com TypeScript e Node.js: Estruturando Projetos que Escalam

Marcos Soares
Atualizado em 
13 minutos de leitura
Ilustracao 3D de cubo arquitetural em camadas concentricas com nucleo cristalino verde representando Clean Architecture
Ouça este artigo
0:00Clean Architecture com TypeScript e Node.js: Estruturando Projetos que Escalam--:--

Conteúdo técnico toda semana

Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.

Sem spam. Cancele a qualquer momento com 1 clique.

Neste artigo

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:

CamadaResponsabilidadeDepende deExemplos concretos
DomainEntidades, value objects, regras de negócio purasNadaOrder, Email (value object), calculateDiscount()
ApplicationCasos de uso, orquestração, ports (interfaces)DomainCreateOrderUseCase, OrderRepository (interface)
InfrastructureImplementações concretas, frameworks, I/OApplication + DomainPrismaOrderRepository, 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:

Text
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.

TypeScript
// 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
  ) {}
 
  // A fábrica recusa uma coleção vazia e define o estado inicial.
  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 confia no registro persistido neste exemplo.
  // Dados importados ou legados precisam de validação antes desta chamada.
  static reconstitute(
    id: string,
    customerId: string,
    items: OrderItem[],
    status: OrderStatus,
    createdAt: Date
  ): Order {
    return new Order(id, customerId, items, status, createdAt);
  }
 
  get items(): ReadonlyArray<OrderItem> {
    return [...this._items];
  }
 
  get status(): OrderStatus {
    return this._status;
  }
 
  get total(): number {
    return this._items.reduce(
      (sum, item) => sum + item.price * item.quantity,
      0
    );
  }
 
  cancel(): void {
    if (this._status === OrderStatus.SHIPPED) {
      throw new Error("Cannot cancel a shipped order");
    }
    this._status = OrderStatus.CANCELLED;
  }
 
  addItem(item: OrderItem): void {
    if (this._status !== OrderStatus.PENDING) {
      throw new Error("Can only add items to pending orders");
    }
    this._items.push(item);
  }
}

No TypeScript, o construtor privado direciona a criação para create() ou reconstitute(). A fábrica recusa uma coleção vazia e inicia o pedido como PENDING. Já reconstitute() recebe o estado persistido sem repetir essa validação: neste exemplo, ela permite reconstruir inclusive um pedido sem itens. A integridade dos dados carregados precisa ser conferida no repositório ou nas restrições do banco. O modificador private, sozinho, não valida o estado da entidade.

Value objects seguem a mesma lógica: validação no construtor, imutabilidade por padrão.

TypeScript
// src/domain/shared/Email.ts
export class Email {
  public readonly value: string;
 
  constructor(raw: string) {
    const trimmed = raw.trim().toLowerCase();
    // Regex simplificada para exemplo; em produção, use uma lib de validação
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) {
      throw new Error(`Invalid email: ${raw}`);
    }
    this.value = trimmed;
  }
 
  equals(other: Email): boolean {
    return this.value === other.value;
  }
}

Application: casos de uso e ports

A camada de aplicação orquestra o domínio e define interfaces (ports) para tudo que é I/O. O caso de uso não sabe se o repositório usa Prisma, Drizzle ou um arquivo JSON.

TypeScript
// src/application/order/ports/OrderRepository.ts
import { Order } from "../../../domain/order/Order";
 
export interface OrderRepository {
  save(order: Order): Promise<void>;
  findById(id: string): Promise<Order | null>;
  findByCustomerId(customerId: string): Promise<Order[]>;
}
TypeScript
// src/application/order/ports/PaymentGateway.ts
export interface PaymentGateway {
  charge(customerId: string, amountInCents: number): Promise<{ transactionId: string }>;
}

O caso de uso recebe as dependências por construtor (inversão de dependência manual, sem container mágico por enquanto):

TypeScript
// src/application/order/CreateOrderUseCase.ts
import { Order } from "../../domain/order/Order";
import { OrderItem } from "../../domain/order/OrderItem";
import { OrderRepository } from "./ports/OrderRepository";
import { PaymentGateway } from "./ports/PaymentGateway";
 
interface CreateOrderInput {
  orderId: string;
  customerId: string;
  items: Array<{ productId: string; price: number; quantity: number }>;
}
 
export class CreateOrderUseCase {
  constructor(
    private readonly orderRepository: OrderRepository,
    private readonly paymentGateway: PaymentGateway
  ) {}
 
  async execute(input: CreateOrderInput): Promise<Order> {
    const orderItems = input.items.map(
      (i) => new OrderItem(i.productId, i.price, i.quantity)
    );
 
    const order = Order.create(input.orderId, input.customerId, orderItems);
 
    // Cobra antes de persistir: se o pagamento falhar, o pedido não existe
    await this.paymentGateway.charge(input.customerId, order.total * 100);
 
    await this.orderRepository.save(order);
 
    return order;
  }
}

Este caso de uso ilustra a separação de dependências, não um checkout pronto para produção. Se o pagamento funcionar e a gravação falhar, haverá cobrança sem pedido persistido; repetir a operação também pode duplicar a cobrança. Um fluxo real precisa de idempotência, registro durável e conciliação com o provedor. Valores monetários devem ter representação e arredondamento explícitos: multiplicar um number por 100 não garante um inteiro exato.

Se a lógica de pagamento ficasse dentro da entidade Order, ela precisaria conhecer PaymentGateway. Isso quebraria a Regra de Dependência. O caso de uso existe para orquestrar essas interações.

Infrastructure: onde mora o Prisma, o Express e o mundo real

A camada de infraestrutura implementa os ports definidos na camada de aplicação.

TypeScript
// src/infrastructure/persistence/PrismaOrderRepository.ts
import { PrismaClient } from "@prisma/client";
import { Order } from "../../domain/order/Order";
import { OrderItem } from "../../domain/order/OrderItem";
import { OrderStatus } from "../../domain/order/OrderStatus";
import { OrderRepository } from "../../application/order/ports/OrderRepository";
 
export class PrismaOrderRepository implements OrderRepository {
  constructor(private readonly prisma: PrismaClient) {}
 
  async save(order: Order): Promise<void> {
    await this.prisma.order.upsert({
      where: { id: order.id },
      create: {
        id: order.id,
        customerId: order.customerId,
        status: order.status,
        createdAt: order.createdAt,
        items: {
          create: order.items.map((item) => ({
            productId: item.productId,
            price: item.price,
            quantity: item.quantity,
          })),
        },
      },
      update: {
        status: order.status,
        // Persiste também alterações feitas por addItem().
        items: {
          deleteMany: {},
          create: order.items.map((item) => ({
            productId: item.productId,
            price: item.price,
            quantity: item.quantity,
          })),
        },
      },
    });
  }
 
  async findById(id: string): Promise<Order | null> {
    const record = await this.prisma.order.findUnique({
      where: { id },
      include: { items: true },
    });
 
    if (!record) return null;
 
    // Usa reconstitute, não create: o dado já foi validado na entrada
    return Order.reconstitute(
      record.id,
      record.customerId,
      record.items.map(
        (i) => new OrderItem(i.productId, i.price, i.quantity)
      ),
      record.status as OrderStatus,
      record.createdAt
    );
  }
 
  async findByCustomerId(customerId: string): Promise<Order[]> {
    const records = await this.prisma.order.findMany({
      where: { customerId },
      include: { items: true },
    });
 
    return records.map((record) =>
      Order.reconstitute(
        record.id,
        record.customerId,
        record.items.map(
          (i) => new OrderItem(i.productId, i.price, i.quantity)
        ),
        record.status as OrderStatus,
        record.createdAt
      )
    );
  }
}

O update substitui os itens do pedido por meio de uma escrita aninhada do Prisma. Este recorte pressupõe uma relação um-para-muitos em que os itens pertencem exclusivamente ao pedido e não têm identificadores referenciados por outras entidades. Quando essa suposição não vale, atualize os itens por identificador e preserve suas referências. Atualizações concorrentes também exigem uma política de versão ou bloqueio; a operação aninhada, sozinha, não resolve perda de atualização.

A composição de tudo acontece no ponto de entrada:

TypeScript
// src/infrastructure/config/container.ts
import { PrismaClient } from "@prisma/client";
import { PrismaOrderRepository } from "../persistence/PrismaOrderRepository";
import { StripePaymentGateway } from "../payment/StripePaymentGateway";
import { CreateOrderUseCase } from "../../application/order/CreateOrderUseCase";
import { CancelOrderUseCase } from "../../application/order/CancelOrderUseCase";
 
const prisma = new PrismaClient();
 
// Composição manual: explícita, sem magia, fácil de debugar
const orderRepository = new PrismaOrderRepository(prisma);
const paymentGateway = new StripePaymentGateway(process.env.STRIPE_SECRET_KEY!);
 
export const createOrderUseCase = new CreateOrderUseCase(
  orderRepository,
  paymentGateway
);
 
export const cancelOrderUseCase = new CancelOrderUseCase(orderRepository);

Composição manual funciona até ~15-20 use cases. Acima disso, considere tsyringe ou awilix. Abaixo disso, um container de DI é complexidade gratuita.

O que NÃO fazer

Anti-pattern 1: entidade que importa infraestrutura

TypeScript
// ERRADO: domínio acoplado ao ORM
import { Entity, Column, PrimaryColumn } from "typeorm";
 
@Entity()
export class Order {
  @PrimaryColumn()
  id: string;
 
  @Column()
  status: string;
 
  // Agora Order depende de TypeORM.
  // Trocar para Prisma significa reescrever o domínio.
}

Isso é o oposto da Regra de Dependência. Se você usa TypeORM com migrations, mantenha as entities do ORM na camada de infraestrutura como modelos de persistência separados das entidades de domínio.

Anti-pattern 2: use case que faz HTTP

TypeScript
// ERRADO: caso de uso acoplado ao Express
import { Request, Response } from "express";
 
export class CreateOrderUseCase {
  async execute(req: Request, res: Response) {
    const { items } = req.body;
    // ...lógica misturada com HTTP
    res.status(201).json(order);
  }
}

O caso de uso recebe dados primitivos ou DTOs simples e retorna dados de domínio. Quem traduz HTTP para DTO e DTO para HTTP é o controller, que vive na infraestrutura.

Anti-pattern 3: interface para absolutamente tudo

TypeScript
// ERRADO: abstração sem motivo
export interface Logger {
  info(message: string): void;
  error(message: string): void;
}
 
export interface DateProvider {
  now(): Date;
}
 
export interface UuidGenerator {
  generate(): string;
}

Criar interface para Date.now() e crypto.randomUUID() é burocracia. Crie interface quando existir chance real de trocar a implementação ou quando precisar substituir em testes. Logger? Talvez. UUID? Quase nunca.

Regra prática: se você consegue imaginar duas implementações reais (não mocks de teste) para aquela interface, ela se justifica. Se a segunda implementação é "mock para teste", provavelmente não precisa de interface: use vi.mock() ou jest.mock() direto.

Quando Clean Architecture é demais

Clean Architecture não é a resposta para todo projeto. A decisão depende de dois fatores: complexidade de domínio e expectativa de vida do código.

CenárioRecomendação
API com 3-5 endpoints CRUD, sem regra de negócioColoque tudo em controllers com o ORM direto. Arquitetura limpa aqui é over-engineering.
Serviço com regras de negócio moderadas, 6-20 entidadesUse as camadas Domain e Application. Infraestrutura pode ser mais simples.
Sistema com domínio complexo, múltiplas integrações, equipe de 4+ devsClean Architecture completa se paga.
Microsserviço de integração (recebe webhook, transforma, envia)Não tem domínio. Use um pipeline simples.

Se o projeto é um pipeline de processamento assíncrono com BullMQ, a complexidade está na orquestração de filas, não no domínio. Clean Architecture não ajuda ali.

Se o projeto é uma API Gateway com rate limiting e caching, a lógica é quase toda infraestrutural. Forçar camadas de domínio em algo que é proxy de requests é criar código sem propósito.

Testabilidade como efeito colateral

A separação de camadas não existe para satisfazer diagramas bonitos. O benefício concreto e mensurável é testabilidade. Quando o domínio não depende de infraestrutura, você testa regras de negócio sem banco, sem HTTP, sem nada externo:

TypeScript
// tests/domain/order/Order.test.ts
import { describe, it, expect } from "vitest";
import { Order } from "../../../src/domain/order/Order";
import { OrderItem } from "../../../src/domain/order/OrderItem";
 
describe("Order", () => {
  it("rejects creation without items", () => {
    expect(() => Order.create("ord-1", "cust-1", [])).toThrow(
      "Order must have at least one item"
    );
  });
 
  it("calculates total from items", () => {
    const items = [
      new OrderItem("prod-1", 100, 2),
      new OrderItem("prod-2", 50, 1),
    ];
    const order = Order.create("ord-1", "cust-1", items);
 
    expect(order.total).toBe(250);
  });
 
  it("prevents cancellation of shipped orders", () => {
    // Reconstitute permite criar Order em qualquer estado para teste
    const order = Order.reconstitute(
      "ord-1",
      "cust-1",
      [new OrderItem("prod-1", 100, 1)],
      "SHIPPED" as any,
      new Date()
    );
 
    expect(() => order.cancel()).toThrow("Cannot cancel a shipped order");
  });
});

Esses testes rodam em milissegundos. Sem Docker, sem banco de teste, sem setup de ambiente. Quando o domínio concentra as regras e os testes unitários cobrem o domínio, a confiança no deploy sobe de forma desproporcional ao esforço. Se você pratica zero-downtime migrations, ter testes de domínio rápidos e confiáveis é o que permite deploy contínuo sem medo.

Posição final

Clean Architecture é uma ferramenta de gestão de dependência, não um framework de pastas. O valor está em uma regra simples: código de negócio não importa código de infraestrutura. Se você aplicar só isso, com disciplina, já terá 80% do benefício.

Os outros 20% (ports formais, containers de DI, separação rígida de DTOs por camada) se pagam em projetos com domínio complexo e equipe grande. Para o resto, são cerimônia que atrasa entrega sem entregar valor.

Comece com a regra de dependência. Adicione formalismo quando a dor aparecer, não antes.

FAQ

Preciso de um container de injeção de dependência?

Se o projeto tem menos de 15 use cases, composição manual no arquivo container.ts é suficiente e mais fácil de debugar. Acima disso, tsyringe ou awilix reduzem o boilerplate de instanciação. O critério é prático: quando o arquivo de composição ultrapassar 100 linhas, considere um container.

Clean Architecture funciona com NestJS?

NestJS já impõe uma estrutura de módulos, providers e controllers que cobre parte do que Clean Architecture propõe. O risco é duplicar abstrações: o @Injectable() do Nest já faz inversão de dependência via decorators. Se usar Nest, aproveite o sistema de módulos dele e aplique a Regra de Dependência dentro dos módulos, sem criar uma segunda camada de ports por cima dos providers.

Devo separar entidade de domínio e modelo de persistência?

Depende do ORM. Com Prisma, a separação é natural: o Prisma gera tipos a partir do schema, e você mapeia para entidades de domínio no repositório. Com TypeORM ou MikroORM, os decorators de entity vivem na classe. Se usar decorators direto na entidade de domínio, você acopla domínio ao ORM. A saída é manter modelos de persistência separados e mapear no repositório, como mostrado no PrismaOrderRepository.

Qual a diferença entre Clean Architecture e Hexagonal Architecture?

A diferença é mais terminológica que prática. Hexagonal (Ports and Adapters) foca em ports (interfaces) e adapters (implementações). Clean Architecture adiciona a separação explícita entre entities e use cases. Na implementação com TypeScript mostrada aqui, as duas convergem: ports na camada de aplicação, adapters na infraestrutura, entidades no domínio.

Como lidar com validação de input do usuário vs. regras de domínio?

Validação de formato (email válido, campo obrigatório, tamanho máximo) pertence à borda: controller ou middleware HTTP. Regras de negócio (pedido precisa de pelo menos um item, não pode cancelar pedido já enviado) pertencem ao domínio. A validação de formato impede dados malformados de chegarem ao use case. A regra de domínio impede estados inválidos de existirem no sistema.

Marcos Soares

Escrito por

Marcos Soares

Fullstack Developer · CEO da Agência Poti

Fullstack Developer e CEO da Agência Poti. Mais de 20 anos construindo arquiteturas cloud-native com React, Next.js e sistemas distribuídos. Parceiro comercial do estúdio iellou design. Fundador do Vivo de Código.

Comentários

Participe da discussão

Seja o primeiro a comentar!

Continue Aprofundando

Conteúdo técnico toda semana

Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.

Sem spam. Cancele a qualquer momento com 1 clique.