Arquitetura 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.
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 --initConfigure o tsconfig.json com paths para facilitar imports:
{
"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:
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.
// 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";
}
}// 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.
// 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)
// 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>;
}// src/ports/output/email-service.port.ts
export interface EmailServicePort {
sendWelcome(to: string, name: string): Promise<void>;
}Porta de Entrada (Driving Port)
// 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>;
}// 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.
// 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,
};
}
}// 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.
// 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
// 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
// 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.
// 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:
// 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.
npm install vitest -D// 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:
npx vitest runEsses 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:
npm install prisma @prisma/client
npx prisma init// 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.

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.


