Ir para o conteúdo
Backend

Arquitetura Hexagonal com TypeScript: Guia Prático para Projetos Reais

Marcos Soares
Atualizado em 
13 minutos de leitura
Ilustracao 3D de prisma hexagonal de vidro translucido com portas luminosas representando Arquitetura Hexagonal
Ouça este artigo
0:00Arquitetura Hexagonal com TypeScript: Guia Prático para Projetos Reais--:--

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

O Problema que Ninguém Fala

Você troca o ORM e o projeto inteiro quebra. Migra de Express para Fastify e reescreve metade do código. O banco muda de PostgreSQL para MongoDB e a equipe entra em pânico.

Isso acontece porque o domínio está acoplado à infraestrutura.

A Arquitetura Hexagonal resolve exatamente isso. Ela isola a lógica de negócio de qualquer detalhe técnico. Framework, banco de dados, filas e APIs externas: tudo vira um detalhe substituível.

Alistair Cockburn formalizou esse padrão em 2005. Ele chamou de "Ports and Adapters". O nome "hexagonal" é apenas uma metáfora visual. O hexágono não tem significado matemático.

Vamos implementar do zero. Com TypeScript. Com código que funciona.

A Anatomia da Arquitetura Hexagonal

O padrão se divide em três camadas concêntricas:

Domínio (centro): entidades, value objects, regras de negócio puras. Zero dependências externas.

Portas (fronteira): interfaces que definem contratos. Existem portas de entrada (driving ports) e portas de saída (driven ports).

Adaptadores (exterior): implementações concretas das portas. Express, Prisma, Redis e SendGrid: tudo mora aqui.

A regra de ouro é simples. Dependências apontam para dentro. Sempre. O domínio nunca importa nada de fora.

Portas de Entrada vs. Portas de Saída

Portas de entrada definem o que o mundo externo pode pedir ao sistema. São os use cases. "Criar usuário", "processar pagamento", "cancelar pedido".

Portas de saída definem o que o sistema precisa do mundo externo. "Salvar no banco", "enviar email", "publicar evento".

Essa distinção é crucial. Confundir as duas destrói o benefício da arquitetura.

Setup do Projeto

Vamos construir um módulo de gerenciamento de usuários. Simples o suficiente para entender o padrão, complexo o suficiente para ser útil.

Bash
mkdir hexagonal-ts && cd hexagonal-ts
npm init -y
npm install typescript tsx @types/node -D
npm install express uuid
npm install @types/express @types/uuid -D
npx tsc --init

Configure o tsconfig.json com paths para facilitar imports:

JSON
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "baseUrl": "./src",
    "paths": {
      "@domain/*": ["domain/*"],
      "@ports/*": ["ports/*"],
      "@adapters/*": ["adapters/*"],
      "@application/*": ["application/*"]
    }
  },
  "include": ["src/**/*"]
}

A estrutura de pastas reflete as camadas:

Bash
src/
├── domain/          # Entidades e regras de negócio
│   ├── entities/
│   └── value-objects/
├── ports/           # Interfaces (contratos)
│   ├── input/       # Driving ports (use cases)
│   └── output/      # Driven ports (repositórios, serviços)
├── application/     # Implementação dos use cases
├── adapters/        # Implementações concretas
│   ├── input/       # Controllers, CLI, GraphQL
│   └── output/      # Repositórios, gateways
└── main.ts          # Composição (wiring)

Camada de Domínio: O Coração

Começamos pelo centro. Sem frameworks. Sem bibliotecas. TypeScript puro.

Value Objects

Value Objects encapsulam validações. Eles garantem que dados inválidos nunca existam no sistema.

TYPESCRIPT
// src/domain/value-objects/email.ts
export class Email {
  private readonly value: string;
 
  private constructor(value: string) {
    this.value = value;
  }
 
  static create(value: string): Email {
    const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
 
    if (!regex.test(value)) {
      throw new InvalidEmailError(value);
    }
 
    return new Email(value.toLowerCase().trim());
  }
 
  toString(): string {
    return this.value;
  }
 
  equals(other: Email): boolean {
    return this.value === other.value;
  }
}
 
export class InvalidEmailError extends Error {
  constructor(email: string) {
    super(`Email inválido: ${email}`);
    this.name = "InvalidEmailError";
  }
}
TYPESCRIPT
// src/domain/value-objects/password.ts
import { createHash } from "node:crypto";
 
export class Password {
  private readonly hash: string;
 
  private constructor(hash: string) {
    this.hash = hash;
  }
 
  static create(plainText: string): Password {
    if (plainText.length < 8) {
      throw new WeakPasswordError();
    }
 
    if (!/[A-Z]/.test(plainText) || !/[0-9]/.test(plainText)) {
      throw new WeakPasswordError();
    }
 
    const hash = createHash("sha256").update(plainText).digest("hex");
    return new Password(hash);
  }
 
  static fromHash(hash: string): Password {
    return new Password(hash);
  }
 
  matches(plainText: string): boolean {
    const hash = createHash("sha256").update(plainText).digest("hex");
    return this.hash === hash;
  }
 
  toHash(): string {
    return this.hash;
  }
}
 
export class WeakPasswordError extends Error {
  constructor() {
    super("Senha deve ter 8+ caracteres, 1 maiúscula e 1 número");
    this.name = "WeakPasswordError";
  }
}

Perceba: zero imports de bibliotecas externas. Apenas node:crypto, que é runtime nativo.

Entidade User

A entidade agrega os value objects e contém regras de negócio.

TYPESCRIPT
// src/domain/entities/user.ts
import { randomUUID } from "node:crypto";
import { Email } from "../value-objects/email.js";
import { Password } from "../value-objects/password.js";
 
interface UserProps {
  id: string;
  name: string;
  email: Email;
  password: Password;
  active: boolean;
  createdAt: Date;
}
 
export class User {
  private props: UserProps;
 
  private constructor(props: UserProps) {
    this.props = props;
  }
 
  static create(name: string, email: string, password: string): User {
    if (name.trim().length < 2) {
      throw new Error("Nome deve ter pelo menos 2 caracteres");
    }
 
    return new User({
      id: randomUUID(),
      name: name.trim(),
      email: Email.create(email),
      password: Password.create(password),
      active: true,
      createdAt: new Date(),
    });
  }
 
  static reconstitute(props: {
    id: string;
    name: string;
    email: string;
    passwordHash: string;
    active: boolean;
    createdAt: Date;
  }): User {
    return new User({
      id: props.id,
      name: props.name,
      email: Email.create(props.email),
      password: Password.fromHash(props.passwordHash),
      active: props.active,
      createdAt: props.createdAt,
    });
  }
 
  deactivate(): void {
    if (!this.props.active) {
      throw new Error("Usuário já está inativo");
    }
    this.props.active = false;
  }
 
  changeEmail(newEmail: string): void {
    this.props.email = Email.create(newEmail);
  }
 
  verifyPassword(plainText: string): boolean {
    return this.props.password.matches(plainText);
  }
 
  get id(): string { return this.props.id; }
  get name(): string { return this.props.name; }
  get email(): string { return this.props.email.toString(); }
  get passwordHash(): string { return this.props.password.toHash(); }
  get active(): boolean { return this.props.active; }
  get createdAt(): Date { return this.props.createdAt; }
}

O método create é para novos usuários. O reconstitute é para hidratar dados do banco. Essa separação evita revalidar dados que já foram validados na criação.

Portas: Os Contratos

Portas são interfaces puras. Sem implementação. Sem lógica.

Porta de Saída (Driven Port)

TYPESCRIPT
// src/ports/output/user-repository.port.ts
import { User } from "../../domain/entities/user.js";
 
export interface UserRepositoryPort {
  save(user: User): Promise<void>;
  findById(id: string): Promise<User | null>;
  findByEmail(email: string): Promise<User | null>;
  findAll(): Promise<User[]>;
  delete(id: string): Promise<void>;
}
TYPESCRIPT
// src/ports/output/email-service.port.ts
export interface EmailServicePort {
  sendWelcome(to: string, name: string): Promise<void>;
}

Porta de Entrada (Driving Port)

TYPESCRIPT
// src/ports/input/create-user.port.ts
export interface CreateUserInput {
  name: string;
  email: string;
  password: string;
}
 
export interface CreateUserOutput {
  id: string;
  name: string;
  email: string;
  createdAt: Date;
}
 
export interface CreateUserPort {
  execute(input: CreateUserInput): Promise<CreateUserOutput>;
}
TYPESCRIPT
// src/ports/input/get-user.port.ts
export interface GetUserOutput {
  id: string;
  name: string;
  email: string;
  active: boolean;
  createdAt: Date;
}
 
export interface GetUserPort {
  execute(id: string): Promise<GetUserOutput>;
}

As portas de entrada definem DTOs de entrada e saída. A entidade User nunca vaza para fora do hexágono.

Application: Os Use Cases

Use cases orquestram o domínio. Eles dependem apenas de portas, nunca de implementações concretas.

TYPESCRIPT
// src/application/create-user.use-case.ts
import { User } from "../domain/entities/user.js";
import type { CreateUserPort, CreateUserInput, CreateUserOutput }
  from "../ports/input/create-user.port.js";
import type { UserRepositoryPort }
  from "../ports/output/user-repository.port.js";
import type { EmailServicePort }
  from "../ports/output/email-service.port.js";
 
export class CreateUserUseCase implements CreateUserPort {
  constructor(
    private readonly userRepository: UserRepositoryPort,
    private readonly emailService: EmailServicePort
  ) {}
 
  async execute(input: CreateUserInput): Promise<CreateUserOutput> {
    const existing = await this.userRepository.findByEmail(input.email);
 
    if (existing) {
      throw new Error("Email já cadastrado");
    }
 
    const user = User.create(input.name, input.email, input.password);
 
    await this.userRepository.save(user);
    await this.emailService.sendWelcome(user.email, user.name);
 
    return {
      id: user.id,
      name: user.name,
      email: user.email,
      createdAt: user.createdAt,
    };
  }
}
TYPESCRIPT
// src/application/get-user.use-case.ts
import type { GetUserPort, GetUserOutput }
  from "../ports/input/get-user.port.js";
import type { UserRepositoryPort }
  from "../ports/output/user-repository.port.js";
 
export class GetUserUseCase implements GetUserPort {
  constructor(private readonly userRepository: UserRepositoryPort) {}
 
  async execute(id: string): Promise<GetUserOutput> {
    const user = await this.userRepository.findById(id);
 
    if (!user) {
      throw new UserNotFoundError(id);
    }
 
    return {
      id: user.id,
      name: user.name,
      email: user.email,
      active: user.active,
      createdAt: user.createdAt,
    };
  }
}
 
export class UserNotFoundError extends Error {
  constructor(id: string) {
    super(`Usuário não encontrado: ${id}`);
    this.name = "UserNotFoundError";
  }
}

Repare no construtor. Ele recebe interfaces, não classes concretas. Isso é inversão de dependência em ação.

Adaptadores: O Mundo Externo

Agora sim tocamos em frameworks e bibliotecas.

Adaptador de Saída: Repositório em Memória

Perfeito para desenvolvimento e testes.

TYPESCRIPT
// src/adapters/output/in-memory-user.repository.ts
import { User } from "../../domain/entities/user.js";
import type { UserRepositoryPort }
  from "../../ports/output/user-repository.port.js";
 
export class InMemoryUserRepository implements UserRepositoryPort {
  private users: Map<string, User> = new Map();
 
  async save(user: User): Promise<void> {
    this.users.set(user.id, user);
  }
 
  async findById(id: string): Promise<User | null> {
    return this.users.get(id) ?? null;
  }
 
  async findByEmail(email: string): Promise<User | null> {
    for (const user of this.users.values()) {
      if (user.email === email) return user;
    }
    return null;
  }
 
  async findAll(): Promise<User[]> {
    return Array.from(this.users.values());
  }
 
  async delete(id: string): Promise<void> {
    this.users.delete(id);
  }
}

Adaptador de Saída: Email Fake

TYPESCRIPT
// src/adapters/output/console-email.service.ts
import type { EmailServicePort }
  from "../../ports/output/email-service.port.js";
 
export class ConsoleEmailService implements EmailServicePort {
  async sendWelcome(to: string, name: string): Promise<void> {
    console.log(`[EMAIL] Bem-vindo, ${name}! Enviado para ${to}`);
  }
}

Em produção, você trocaria por um SendGridEmailService ou SESEmailService. O use case não muda. Uma linha de alteração no wiring.

Adaptador de Entrada: Controller Express

TYPESCRIPT
// src/adapters/input/express/user.controller.ts
import { Router, Request, Response } from "express";
import type { CreateUserPort }
  from "../../../ports/input/create-user.port.js";
import type { GetUserPort }
  from "../../../ports/input/get-user.port.js";
 
export function createUserRouter(
  createUser: CreateUserPort,
  getUser: GetUserPort
): Router {
  const router = Router();
 
  router.post("/users", async (req: Request, res: Response) => {
    try {
      const result = await createUser.execute(req.body);
      res.status(201).json(result);
    } catch (error: any) {
      const status = error.message.includes("já cadastrado") ? 409 : 400;
      res.status(status).json({ error: error.message });
    }
  });
 
  router.get("/users/:id", async (req: Request, res: Response) => {
    try {
      const result = await getUser.execute(req.params.id);
      res.status(200).json(result);
    } catch (error: any) {
      const status = error.name === "UserNotFoundError" ? 404 : 500;
      res.status(status).json({ error: error.message });
    }
  });
 
  return router;
}

O controller recebe portas de entrada no construtor. Ele não sabe nada sobre Prisma, MongoDB ou filas. Apenas traduz HTTP para chamadas de use case.

Composição: Conectando Tudo

O main.ts é onde a mágica acontece. Aqui escolhemos quais adaptadores plugar.

TYPESCRIPT
// src/main.ts
import express from "express";
import { CreateUserUseCase } from "./application/create-user.use-case.js";
import { GetUserUseCase } from "./application/get-user.use-case.js";
import { InMemoryUserRepository }
  from "./adapters/output/in-memory-user.repository.js";
import { ConsoleEmailService }
  from "./adapters/output/console-email.service.js";
import { createUserRouter }
  from "./adapters/input/express/user.controller.js";
 
// --- Adaptadores de Saída ---
const userRepository = new InMemoryUserRepository();
const emailService = new ConsoleEmailService();
 
// --- Use Cases ---
const createUser = new CreateUserUseCase(userRepository, emailService);
const getUser = new GetUserUseCase(userRepository);
 
// --- Adaptadores de Entrada ---
const app = express();
app.use(express.json());
app.use("/api", createUserRouter(createUser, getUser));
 
app.listen(3000, () => {
  console.log("Servidor rodando em http://localhost:3000");
});

Quer trocar para Prisma? Mude duas linhas:

TYPESCRIPT
// Troca apenas o adaptador
import { PrismaUserRepository }
  from "./adapters/output/prisma-user.repository.js";
 
const userRepository = new PrismaUserRepository(prismaClient);

O resto do sistema permanece intacto. Zero refatoração.

Testes: O Grande Benefício

Aqui a arquitetura hexagonal brilha de verdade. Testes unitários sem banco, sem HTTP, sem nada externo.

Bash
npm install vitest -D
TYPESCRIPT
// src/__tests__/create-user.use-case.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import { CreateUserUseCase } from "../application/create-user.use-case.js";
import { InMemoryUserRepository }
  from "../adapters/output/in-memory-user.repository.js";
import type { EmailServicePort }
  from "../ports/output/email-service.port.js";
 
class FakeEmailService implements EmailServicePort {
  public sentEmails: Array<{ to: string; name: string }> = [];
 
  async sendWelcome(to: string, name: string): Promise<void> {
    this.sentEmails.push({ to, name });
  }
}
 
describe("CreateUserUseCase", () => {
  let useCase: CreateUserUseCase;
  let repository: InMemoryUserRepository;
  let emailService: FakeEmailService;
 
  beforeEach(() => {
    repository = new InMemoryUserRepository();
    emailService = new FakeEmailService();
    useCase = new CreateUserUseCase(repository, emailService);
  });
 
  it("deve criar um usuário com dados válidos", async () => {
    const result = await useCase.execute({
      name: "João Silva",
      email: "[email protected]",
      password: "Senha123!",
    });
 
    expect(result.name).toBe("João Silva");
    expect(result.email).toBe("[email protected]");
    expect(result.id).toBeDefined();
  });
 
  it("deve enviar email de boas-vindas", async () => {
    await useCase.execute({
      name: "Maria",
      email: "[email protected]",
      password: "Senha123!",
    });
 
    expect(emailService.sentEmails).toHaveLength(1);
    expect(emailService.sentEmails[0].to).toBe("[email protected]");
  });
 
  it("deve rejeitar email duplicado", async () => {
    const input = {
      name: "João",
      email: "[email protected]",
      password: "Senha123!",
    };
 
    await useCase.execute(input);
 
    await expect(useCase.execute(input)).rejects.toThrow("já cadastrado");
  });
 
  it("deve rejeitar senha fraca", async () => {
    await expect(
      useCase.execute({
        name: "João",
        email: "[email protected]",
        password: "123",
      })
    ).rejects.toThrow("8+ caracteres");
  });
});

Execute com:

Bash
npx vitest run

Esses testes rodam em milissegundos. Sem Docker. Sem banco. Sem servidor HTTP. Pura lógica de negócio.

Adaptador de Saída com Prisma (Produção)

Para completar o ciclo, veja como seria o adaptador real:

Bash
npm install prisma @prisma/client
npx prisma init
TYPESCRIPT
// src/adapters/output/prisma-user.repository.ts
import { PrismaClient } from "@prisma/client";
import { User } from "../../domain/entities/user.js";
import type { UserRepositoryPort }
  from "../../ports/output/user-repository.port.js";
 
export class PrismaUserRepository implements UserRepositoryPort {
  constructor(private readonly prisma: PrismaClient) {}
 
  async save(user: User): Promise<void> {
    await this.prisma.user.upsert({
      where: { id: user.id },
      update: {
        name: user.name,
        email: user.email,
        passwordHash: user.passwordHash,
        active: user.active,
      },
      create: {
        id: user.id,
        name: user.name,
        email: user.email,
        passwordHash: user.passwordHash,
        active: user.active,
        createdAt: user.createdAt,
      },
    });
  }
 
  async findById(id: string): Promise<User | null> {
    const data = await this.prisma.user.findUnique({ where: { id } });
    if (!data) return null;
 
    return User.reconstitute({
      id: data.id,
      name: data.name,
      email: data.email,
      passwordHash: data.passwordHash,
      active: data.active,
      createdAt: data.createdAt,
    });
  }
 
  async findByEmail(email: string): Promise<User | null> {
    const data = await this.prisma.user.findUnique({ where: { email } });
    if (!data) return null;
 
    return User.reconstitute({
      id: data.id,
      name: data.name,
      email: data.email,
      passwordHash: data.passwordHash,
      active: data.active,
      createdAt: data.createdAt,
    });
  }
 
  async findAll(): Promise<User[]> {
    const records = await this.prisma.user.findMany();
    return records.map((r) =>
      User.reconstitute({
        id: r.id,
        name: r.name,
        email: r.email,
        passwordHash: r.passwordHash,
        active: r.active,
        createdAt: r.createdAt,
      })
    );
  }
 
  async delete(id: string): Promise<void> {
    await this.prisma.user.delete({ where: { id } });
  }
}

Mesma interface. Implementação diferente. O use case nem percebe a troca.

Erros Comuns que Você Vai Cometer

1. Vazar a entidade para o controller. Sempre retorne DTOs nas portas de entrada. A entidade tem métodos e estado interno que não devem ser expostos via API.

2. Colocar lógica de negócio no controller. Se você tem um if que decide algo de negócio no controller, ele está no lugar errado. Mova para o use case.

3. Criar abstrações prematuras. Nem todo projeto precisa de arquitetura hexagonal. Um CRUD simples com 3 endpoints? Use Express direto. Não complique sem necessidade.

4. Injetar implementação concreta no use case. Se o construtor do use case recebe PrismaUserRepository em vez de UserRepositoryPort, você perdeu o benefício inteiro.

Minha Opinião Sincera

Arquitetura Hexagonal é a melhor escolha para projetos TypeScript de médio e grande porte. Ponto.

Mas vou ser honesto sobre as alternativas.

Clean Architecture (Uncle Bob) adiciona camadas demais. Entities, Use Cases, Interface Adapters, Frameworks & Drivers: quatro camadas onde três bastam. Na prática com TypeScript, a distinção entre "Interface Adapters" e "Frameworks" é artificial. Hexagonal é mais pragmática.

Onion Architecture é quase idêntica à hexagonal. A diferença é acadêmica. Escolha a que sua equipe entende melhor.

NestJS com módulos resolve parte do problema com injeção de dependência automática. Mas o framework te prende. Você não está fazendo hexagonal só porque usa @Injectable(). Vi muitos projetos NestJS com domínio totalmente acoplado ao TypeORM.

O custo real: hexagonal adiciona mais arquivos e mais indireção. Um CRUD que seria 1 arquivo vira 6. Para microsserviços pequenos ou MVPs, isso é overengineering. Eu uso hexagonal quando o projeto tem regras de negócio complexas, múltiplas fontes de dados ou previsão de troca de infraestrutura.

O benefício real: testabilidade. Não é a troca de banco que você vai fazer uma vez a cada 5 anos. São os testes unitários que rodam em 200ms em vez de 30 segundos com banco real. Isso muda a velocidade de desenvolvimento diariamente.

Se seu projeto tem mais lógica de negócio do que CRUD, use hexagonal. Se é um proxy de API ou um BFF simples, não use. A arquitetura deve servir o problema, nunca o contrário.

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.