1. 왜 아키텍처 얘기를 또 해야 하는가
"지난번에 클린 아키텍처 글 봤는데, 헥사고날은 또 다른 거야?"
맞아. 다르면서도 닮았어. 클린 아키텍처가 "의존성은 안쪽으로만"이라는 큰 원칙을 얘기한다면, 헥사고날(Hexagonal Architecture)은 그 원칙을 좀 더 실용적이고 구체적인 모양으로 구현하는 방법을 제시해.
Alistair Cockburn이 2005년에 처음 제안한 이 패턴의 공식 이름은 Ports and Adapters야. "헥사고날"은 Cockburn이 다이어그램을 육각형으로 그렸기 때문에 붙은 별명이고, 사실 6이라는 숫자에는 별 의미가 없어. 그냥 "여러 방향으로 연결될 수 있다"는 걸 시각화한 거야.
핵심 아이디어는 딱 하나야:
"애플리케이션의 핵심(Core)은 외부 세계가 어떻게 생겼는지 몰라야 한다."
DB가 MySQL이든 MongoDB든, HTTP 요청이 REST든 gRPC든, UI가 React든 CLI든 — 코어 로직은 그런 거 신경 안 써야 해.
2. 전통적인 레이어드 아키텍처의 문제
먼저 우리가 흔히 쓰는 3계층 아키텍처(Layered Architecture)를 보자.
[Presentation Layer] → [Business Layer] → [Data Layer]
Controller → Service → Repository → Database
이게 나쁜 건 아냐. 하지만 시간이 지나면 이런 일이 생겨:
// 전형적인 레이어드 아키텍처의 비즈니스 로직
class UserService {
async registerUser(email: string, password: string) {
// DB 직접 의존
const existing = await db.query(
'SELECT * FROM users WHERE email = ?', [email]
);
if (existing.rows.length > 0) {
throw new Error('이미 존재하는 이메일');
}
// 이메일 전송 라이브러리 직접 의존
await nodemailer.sendMail({
to: email,
subject: '환영합니다',
text: '가입을 축하합니다',
});
// 외부 결제 SDK 직접 의존
await stripe.customers.create({ email });
return db.query(
'INSERT INTO users (email, password) VALUES (?, ?)',
[email, hashedPassword]
);
}
}
UserService 안에 db, nodemailer, stripe가 직접 들어와 있어. 이 서비스를 테스트하려면?
- 실제 DB가 필요하거나 복잡한 mock을 만들어야 해
- 실제 이메일이 날아가거나 nodemailer를 mock해야 해
- Stripe 테스트 계정이 필요하거나 또 mock
그리고 만약 MySQL → PostgreSQL로 갈아타면? 이메일 서비스를 SendGrid로 바꾸면? 결제를 Toss로 바꾸면?
UserService 코드를 직접 손대야 해. 비즈니스 로직과 인프라가 뒤엉켜 있기 때문이야.
3. 헥사고날의 핵심 개념: 포트와 어댑터
포트(Port): 계약서
포트는 **인터페이스(Interface)**야. 코어가 "나는 이런 기능이 필요해"라고 선언하는 계약서.
포트에는 두 종류가 있어:
- 인바운드 포트 (Driving Port): 외부에서 코어를 호출하는 진입점. "이 서비스는 이런 기능을 제공한다"는 선언.
- 아웃바운드 포트 (Driven Port): 코어가 외부에 요청하는 출구. "나는 이런 기능을 필요로 한다"는 선언.
// 아웃바운드 포트: 코어가 필요로 하는 것들
interface UserRepository {
findByEmail(email: string): Promise<User | null>;
save(user: User): Promise<void>;
}
interface EmailService {
sendWelcomeEmail(to: string): Promise<void>;
}
interface PaymentService {
createCustomer(email: string): Promise<string>; // customerId 반환
}
이 인터페이스들은 UserService와 **같은 패키지(코어)**에 있어야 해. 구현체(PostgreSQL adapter, SendGrid adapter)는 바깥에 있고.
어댑터(Adapter): 구현체
어댑터는 포트 인터페이스를 구현하는 실제 코드야. 외부 세계와의 번역기 역할을 해.
// PostgreSQL 어댑터: UserRepository 포트 구현
class PostgresUserRepository implements UserRepository {
constructor(private db: Pool) {}
async findByEmail(email: string): Promise<User | null> {
const result = await this.db.query(
'SELECT * FROM users WHERE email = $1', [email]
);
if (result.rows.length === 0) return null;
return this.mapToDomain(result.rows[0]);
}
async save(user: User): Promise<void> {
await this.db.query(
'INSERT INTO users (id, email, password_hash) VALUES ($1, $2, $3)',
[user.id, user.email, user.passwordHash]
);
}
private mapToDomain(row: any): User {
return new User(row.id, row.email, row.password_hash);
}
}
// SendGrid 어댑터: EmailService 포트 구현
class SendGridEmailService implements EmailService {
constructor(private client: MailService) {}
async sendWelcomeEmail(to: string): Promise<void> {
await this.client.send({
to,
from: 'noreply@myapp.com',
subject: '환영합니다!',
text: '가입을 환영합니다.',
});
}
}
4. 코어 도메인: 아무것도 몰라도 되는 곳
이제 진짜 핵심이야. UserService를 다시 써보자:
// core/domain/User.ts — 순수한 도메인 엔티티
class User {
private constructor(
public readonly id: string,
public readonly email: string,
private readonly passwordHash: string,
) {}
static create(id: string, email: string, passwordHash: string): User {
if (!email.includes('@')) {
throw new Error('유효하지 않은 이메일');
}
return new User(id, email, passwordHash);
}
verifyPassword(plain: string): boolean {
return bcrypt.compareSync(plain, this.passwordHash);
}
}
// core/ports/outbound.ts — 아웃바운드 포트들
interface UserRepository {
findByEmail(email: string): Promise<User | null>;
save(user: User): Promise<void>;
}
interface EmailService {
sendWelcomeEmail(to: string): Promise<void>;
}
interface PaymentService {
createCustomer(email: string): Promise<string>;
}
// core/ports/inbound.ts — 인바운드 포트
interface RegisterUserUseCase {
execute(command: RegisterUserCommand): Promise<RegisterUserResult>;
}
interface RegisterUserCommand {
email: string;
password: string;
}
interface RegisterUserResult {
userId: string;
}
// core/application/RegisterUserService.ts — 순수한 비즈니스 로직
class RegisterUserService implements RegisterUserUseCase {
constructor(
private userRepository: UserRepository, // 인터페이스만 알아
private emailService: EmailService, // 인터페이스만 알아
private paymentService: PaymentService, // 인터페이스만 알아
) {}
async execute(command: RegisterUserCommand): Promise<RegisterUserResult> {
// 비즈니스 로직 1: 중복 이메일 체크
const existing = await this.userRepository.findByEmail(command.email);
if (existing) {
throw new DomainError('EMAIL_ALREADY_EXISTS', '이미 존재하는 이메일입니다');
}
// 비즈니스 로직 2: 유저 생성
const id = generateId();
const passwordHash = bcrypt.hashSync(command.password, 10);
const user = User.create(id, command.email, passwordHash);
// 비즈니스 로직 3: 저장, 이메일, 결제 고객 생성 (순서 중요)
await this.userRepository.save(user);
await this.emailService.sendWelcomeEmail(user.email);
await this.paymentService.createCustomer(user.email);
return { userId: user.id };
}
}
RegisterUserService는 PostgreSQL을 모르고, SendGrid를 모르고, Stripe를 몰라. 오직 인터페이스만 알아. 이게 의존성 역전(Dependency Inversion)이야.
5. 레이어드 vs 헥사고날 비교표
| 항목 | 레이어드 아키텍처 | 헥사고날 아키텍처 |
|---|---|---|
| 의존성 방향 | Controller → Service → Repository → DB | 항상 코어를 향함 |
| DB 교체 | Service 코드 수정 필요 | Adapter만 교체 |
| 단위 테스트 | 실제 DB/외부 서비스 mock 복잡 | 인터페이스 mock 간단 |
| 비즈니스 로직 위치 | 여러 레이어에 분산 가능 | 코어에 집중 |
| 코드 복잡도 | 낮음 (초기) | 높음 (초기), 유지보수에서 역전 |
| 학습 곡선 | 낮음 | 중간~높음 |
| 적합한 규모 | 소규모 CRUD | 복잡한 도메인 로직 |
6. 인바운드 어댑터: 외부에서 코어 호출하기
코어를 호출하는 쪽도 어댑터가 필요해. HTTP Controller, CLI, 메시지 큐 컨슈머 같은 것들이 인바운드 어댑터야.
// adapters/inbound/http/UserController.ts
class UserController {
constructor(
private registerUserUseCase: RegisterUserUseCase // 인바운드 포트
) {}
async register(req: Request, res: Response): Promise<void> {
try {
// HTTP 요청을 도메인 커맨드로 변환
const command: RegisterUserCommand = {
email: req.body.email,
password: req.body.password,
};
const result = await this.registerUserUseCase.execute(command);
res.status(201).json({ userId: result.userId });
} catch (error) {
if (error instanceof DomainError) {
res.status(400).json({ code: error.code, message: error.message });
} else {
res.status(500).json({ message: '서버 오류' });
}
}
}
}
// adapters/inbound/cli/RegisterUserCommand.ts — CLI 인바운드 어댑터
class RegisterUserCLI {
constructor(private registerUserUseCase: RegisterUserUseCase) {}
async run(args: string[]): Promise<void> {
const [email, password] = args;
const result = await this.registerUserUseCase.execute({ email, password });
console.log(`유저 생성 완료: ${result.userId}`);
}
}
같은 코어 로직이 HTTP로도, CLI로도 호출될 수 있어.
7. 의존성 조립: Composition Root
모든 어댑터를 코어에 연결하는 건 한 곳에서 해야 해. 이걸 Composition Root라고 불러.
// main.ts — Composition Root
async function bootstrap() {
// 인프라 초기화
const dbPool = new Pool({ connectionString: process.env.DATABASE_URL });
const sendgridClient = new MailService();
sendgridClient.setApiKey(process.env.SENDGRID_API_KEY!);
const stripeClient = new Stripe(process.env.STRIPE_SECRET_KEY!);
// 아웃바운드 어댑터 인스턴스화
const userRepository = new PostgresUserRepository(dbPool);
const emailService = new SendGridEmailService(sendgridClient);
const paymentService = new StripePaymentService(stripeClient);
// 코어 서비스 인스턴스화 (의존성 주입)
const registerUserService = new RegisterUserService(
userRepository,
emailService,
paymentService,
);
// 인바운드 어댑터 인스턴스화
const userController = new UserController(registerUserService);
// Express 설정
const app = express();
app.post('/users', (req, res) => userController.register(req, res));
app.listen(3000);
}
bootstrap();
의존성의 흐름:
UserController→ (인바운드 포트) →RegisterUserServiceRegisterUserService→ (아웃바운드 포트) →PostgresUserRepository
화살표가 항상 코어를 향해. 코어는 어댑터를 몰라.
8. 테스트가 얼마나 쉬워지는가
헥사고날의 가장 큰 실용적 이점이 여기 있어.
// tests/RegisterUserService.test.ts
describe('RegisterUserService', () => {
// 인터페이스를 구현하는 인메모리 어댑터 (테스트용)
const mockUserRepository: UserRepository = {
users: new Map<string, User>(),
async findByEmail(email: string) {
for (const user of this.users.values()) {
if (user.email === email) return user;
}
return null;
},
async save(user: User) {
this.users.set(user.id, user);
},
};
const mockEmailService: EmailService = {
sentEmails: [] as string[],
async sendWelcomeEmail(to: string) {
this.sentEmails.push(to);
},
};
const mockPaymentService: PaymentService = {
async createCustomer(email: string) {
return `mock_customer_${email}`;
},
};
let service: RegisterUserService;
beforeEach(() => {
// 테스트마다 초기화
mockUserRepository.users.clear();
mockEmailService.sentEmails = [];
service = new RegisterUserService(
mockUserRepository,
mockEmailService,
mockPaymentService,
);
});
it('새 유저 등록 성공', async () => {
const result = await service.execute({
email: 'test@example.com',
password: 'password123',
});
expect(result.userId).toBeDefined();
expect(mockEmailService.sentEmails).toContain('test@example.com');
});
it('중복 이메일로 등록 실패', async () => {
await service.execute({ email: 'dup@example.com', password: 'pw1' });
await expect(
service.execute({ email: 'dup@example.com', password: 'pw2' })
).rejects.toThrow('이미 존재하는 이메일입니다');
});
});
DB 없어도 돼. 네트워크 없어도 돼. 테스트가 빠르고, 결정론적이고, 외부 의존성이 없어.
9. 폴더 구조 예시
src/
├── core/ # 코어 도메인 (외부 의존 없음)
│ ├── domain/
│ │ ├── User.ts # 도메인 엔티티
│ │ └── DomainError.ts
│ ├── ports/
│ │ ├── inbound/
│ │ │ └── RegisterUserUseCase.ts
│ │ └── outbound/
│ │ ├── UserRepository.ts
│ │ ├── EmailService.ts
│ │ └── PaymentService.ts
│ └── application/
│ └── RegisterUserService.ts
│
├── adapters/ # 어댑터 (외부 세계와의 연결)
│ ├── inbound/
│ │ ├── http/
│ │ │ └── UserController.ts
│ │ └── cli/
│ │ └── RegisterUserCLI.ts
│ └── outbound/
│ ├── postgres/
│ │ └── PostgresUserRepository.ts
│ ├── sendgrid/
│ │ └── SendGridEmailService.ts
│ └── stripe/
│ └── StripePaymentService.ts
│
└── main.ts # Composition Root
이 구조에서 core/ 폴더는 node_modules에서 DB 클라이언트나 HTTP 프레임워크를 import하는 코드가 없어야 해.
10. 언제 써야 하고, 언제 안 써도 되는가
써야 할 때
- 도메인 로직이 복잡한 경우: 비즈니스 규칙이 많고, 그게 오래 지속될 때
- 인프라 교체 가능성이 높은 경우: DB, 이메일 서비스, 결제 등을 나중에 바꿀 수 있을 때
- 테스트 커버리지가 중요한 경우: 빠른 단위 테스트가 필요할 때
- DDD(Domain-Driven Design)와 함께 쓸 때: 헥사고날은 DDD의 자연스러운 구현 패턴
안 써도 되는 때
- 단순 CRUD 앱: 비즈니스 로직이 거의 없고, DB를 바꿀 일도 없으면 오버엔지니어링
- 프로토타입 / MVP: 빠르게 검증하는 게 목표라면 레이어드로 시작해도 돼
- 팀이 패턴에 익숙하지 않을 때: 억지로 도입하면 코드가 더 복잡해져
마무리
헥사고날 아키텍처의 핵심을 한 문장으로 요약하면:
"코어는 포트(인터페이스)를 통해 세상과 대화하고, 어댑터가 실제 세상과 연결한다."
처음엔 파일도 많아지고 인터페이스도 많아져서 복잡하게 느껴질 수 있어. 하지만 6개월 뒤에 "결제를 Toss로 바꿔야 해" 소리를 들었을 때, StripePaymentService 파일 하나만 새로 만들면 끝나는 경험을 해보면 생각이 달라져.
코어를 지키는 게 곧 비즈니스 로직을 지키는 거야.
Hexagonal Architecture (Ports & Adapters): Inverting Dependency Direction
1. Why Are We Talking About Architecture Again?
"I already read the Clean Architecture post. Is Hexagonal just another name for the same thing?"
Similar, but distinct. Clean Architecture gives you the grand principle — "dependencies point inward." Hexagonal Architecture (or Ports and Adapters) is a more concrete, practical shape for realizing that principle.
Alistair Cockburn introduced this pattern in 2005. The official name is Ports and Adapters. "Hexagonal" is just the nickname — Cockburn drew it as a hexagon to visualize "many connection points." The number six has no special significance.
The core idea is one sentence:
"The application core must not know what the outside world looks like."
Whether the database is MySQL or MongoDB, whether traffic arrives via REST or gRPC, whether the UI is React or a terminal — the core should not care.
2. The Problem with Traditional Layered Architecture
Consider the classic 3-tier layout:
[Presentation] → [Business] → [Data Layer]
This is fine until it isn't. Here's what typical service code looks like after a few months:
class UserService {
async registerUser(email: string, password: string) {
// Direct dependency on db
const existing = await db.query(
'SELECT * FROM users WHERE email = ?', [email]
);
if (existing.rows.length > 0) {
throw new Error('Email already exists');
}
// Direct dependency on nodemailer
await nodemailer.sendMail({
to: email,
subject: 'Welcome',
text: 'Thanks for signing up',
});
// Direct dependency on Stripe SDK
await stripe.customers.create({ email });
return db.query(
'INSERT INTO users (email, password) VALUES (?, ?)',
[email, hashedPassword]
);
}
}
db, nodemailer, and stripe are wired directly into the service. To test this:
- You need a real DB or complex mocking
- You need to stub nodemailer so emails don't actually send
- You need a Stripe test account or yet another mock
And when you switch MySQL to PostgreSQL? Or replace nodemailer with SendGrid? You touch UserService directly. Business logic and infrastructure are entangled.
3. Core Concepts: Ports and Adapters
Port: The Contract
A port is an interface. It's the core declaring "I need this capability" or "I provide this capability."
Two kinds:
- Inbound Port (Driving Port): Entry points where the outside calls into the core — "this service provides these operations."
- Outbound Port (Driven Port): Exit points where the core calls outward — "this service needs these capabilities."
// Outbound ports — what the core needs
interface UserRepository {
findByEmail(email: string): Promise<User | null>;
save(user: User): Promise<void>;
}
interface EmailService {
sendWelcomeEmail(to: string): Promise<void>;
}
interface PaymentService {
createCustomer(email: string): Promise<string>; // returns customerId
}
These interfaces live in the core package. Implementations (PostgresUserRepository, SendGridEmailService) live outside.
Adapter: The Implementation
An adapter implements a port interface. It translates between the core's language and the outside world's language.
// PostgreSQL adapter
class PostgresUserRepository implements UserRepository {
constructor(private db: Pool) {}
async findByEmail(email: string): Promise<User | null> {
const result = await this.db.query(
'SELECT * FROM users WHERE email = $1', [email]
);
if (result.rows.length === 0) return null;
return this.mapToDomain(result.rows[0]);
}
async save(user: User): Promise<void> {
await this.db.query(
'INSERT INTO users (id, email, password_hash) VALUES ($1, $2, $3)',
[user.id, user.email, user.passwordHash]
);
}
private mapToDomain(row: any): User {
return new User(row.id, row.email, row.password_hash);
}
}
// SendGrid adapter
class SendGridEmailService implements EmailService {
constructor(private client: MailService) {}
async sendWelcomeEmail(to: string): Promise<void> {
await this.client.send({
to,
from: 'noreply@myapp.com',
subject: 'Welcome!',
text: 'Thanks for signing up.',
});
}
}
4. The Core Domain: The Place That Knows Nothing
Now rewrite UserService with this approach:
// core/domain/User.ts — pure domain entity
class User {
private constructor(
public readonly id: string,
public readonly email: string,
private readonly passwordHash: string,
) {}
static create(id: string, email: string, passwordHash: string): User {
if (!email.includes('@')) {
throw new Error('Invalid email format');
}
return new User(id, email, passwordHash);
}
verifyPassword(plain: string): boolean {
return bcrypt.compareSync(plain, this.passwordHash);
}
}
// core/application/RegisterUserService.ts
class RegisterUserService implements RegisterUserUseCase {
constructor(
private userRepository: UserRepository, // knows only the interface
private emailService: EmailService, // knows only the interface
private paymentService: PaymentService, // knows only the interface
) {}
async execute(command: RegisterUserCommand): Promise<RegisterUserResult> {
const existing = await this.userRepository.findByEmail(command.email);
if (existing) {
throw new DomainError('EMAIL_ALREADY_EXISTS', 'Email already exists');
}
const id = generateId();
const passwordHash = bcrypt.hashSync(command.password, 10);
const user = User.create(id, command.email, passwordHash);
await this.userRepository.save(user);
await this.emailService.sendWelcomeEmail(user.email);
await this.paymentService.createCustomer(user.email);
return { userId: user.id };
}
}
RegisterUserService knows nothing about PostgreSQL, SendGrid, or Stripe. It only knows interfaces. This is Dependency Inversion in practice.
5. Layered vs Hexagonal Comparison
| Aspect | Layered Architecture | Hexagonal Architecture |
|---|---|---|
| Dependency direction | Controller → Service → Repository → DB | Always toward the core |
| Swap the database | Must edit Service code | Replace only the adapter |
| Unit testing | Complex mocks for DB/external services | Simple interface mocks |
| Business logic location | Can spread across layers | Concentrated in the core |
| Initial complexity | Low | Higher upfront |
| Maintenance complexity | Grows fast | Stays manageable |
| Best for | Simple CRUD | Rich domain logic |
6. Inbound Adapters: Calling into the Core
The callers of the core also need adapters — HTTP controllers, CLI handlers, message queue consumers.
// adapters/inbound/http/UserController.ts
class UserController {
constructor(private registerUserUseCase: RegisterUserUseCase) {}
async register(req: Request, res: Response): Promise<void> {
try {
const command: RegisterUserCommand = {
email: req.body.email,
password: req.body.password,
};
const result = await this.registerUserUseCase.execute(command);
res.status(201).json({ userId: result.userId });
} catch (error) {
if (error instanceof DomainError) {
res.status(400).json({ code: error.code, message: error.message });
} else {
res.status(500).json({ message: 'Internal server error' });
}
}
}
}
// adapters/inbound/cli/RegisterUserCLI.ts
class RegisterUserCLI {
constructor(private registerUserUseCase: RegisterUserUseCase) {}
async run(args: string[]): Promise<void> {
const [email, password] = args;
const result = await this.registerUserUseCase.execute({ email, password });
console.log(`User created: ${result.userId}`);
}
}
The same core logic is reachable via HTTP or CLI without changing a line in the core.
7. The Composition Root
Wire everything together in one place.
// main.ts — Composition Root
async function bootstrap() {
// Infrastructure
const dbPool = new Pool({ connectionString: process.env.DATABASE_URL });
const sendgridClient = new MailService();
sendgridClient.setApiKey(process.env.SENDGRID_API_KEY!);
const stripeClient = new Stripe(process.env.STRIPE_SECRET_KEY!);
// Outbound adapters
const userRepository = new PostgresUserRepository(dbPool);
const emailService = new SendGridEmailService(sendgridClient);
const paymentService = new StripePaymentService(stripeClient);
// Core service (dependency injection)
const registerUserService = new RegisterUserService(
userRepository,
emailService,
paymentService,
);
// Inbound adapter
const userController = new UserController(registerUserService);
const app = express();
app.post('/users', (req, res) => userController.register(req, res));
app.listen(3000);
}
bootstrap();
The dependency flow:
UserController→ (inbound port) →RegisterUserServiceRegisterUserService→ (outbound port) →PostgresUserRepository
Arrows always point toward the core. The core never imports an adapter.
8. Testing Gets Remarkably Simple
This is the biggest practical payoff.
describe('RegisterUserService', () => {
const mockUserRepository: UserRepository = {
users: new Map<string, User>(),
async findByEmail(email: string) {
for (const user of this.users.values()) {
if (user.email === email) return user;
}
return null;
},
async save(user: User) {
this.users.set(user.id, user);
},
};
const mockEmailService: EmailService = {
sentEmails: [] as string[],
async sendWelcomeEmail(to: string) {
this.sentEmails.push(to);
},
};
const mockPaymentService: PaymentService = {
async createCustomer(email: string) {
return `mock_customer_${email}`;
},
};
beforeEach(() => {
mockUserRepository.users.clear();
(mockEmailService as any).sentEmails = [];
});
it('successfully registers a new user', async () => {
const service = new RegisterUserService(
mockUserRepository, mockEmailService, mockPaymentService
);
const result = await service.execute({
email: 'test@example.com',
password: 'password123',
});
expect(result.userId).toBeDefined();
expect((mockEmailService as any).sentEmails).toContain('test@example.com');
});
it('rejects duplicate email', async () => {
const service = new RegisterUserService(
mockUserRepository, mockEmailService, mockPaymentService
);
await service.execute({ email: 'dup@example.com', password: 'pw1' });
await expect(
service.execute({ email: 'dup@example.com', password: 'pw2' })
).rejects.toThrow('Email already exists');
});
});
No database. No network. Fast, deterministic, zero external dependencies.
9. Recommended Folder Structure
src/
├── core/ # Zero external dependencies
│ ├── domain/
│ │ ├── User.ts
│ │ └── DomainError.ts
│ ├── ports/
│ │ ├── inbound/
│ │ │ └── RegisterUserUseCase.ts
│ │ └── outbound/
│ │ ├── UserRepository.ts
│ │ ├── EmailService.ts
│ │ └── PaymentService.ts
│ └── application/
│ └── RegisterUserService.ts
│
├── adapters/
│ ├── inbound/
│ │ ├── http/UserController.ts
│ │ └── cli/RegisterUserCLI.ts
│ └── outbound/
│ ├── postgres/PostgresUserRepository.ts
│ ├── sendgrid/SendGridEmailService.ts
│ └── stripe/StripePaymentService.ts
│
└── main.ts # Composition Root
Enforce one rule: nothing inside core/ may import from node_modules packages that represent infrastructure (ORMs, HTTP clients, email SDKs). Only pure TypeScript and your own domain types.
10. When to Use It (and When Not To)
Use it when:
- Domain logic is complex and valuable — it's the core of your business
- You expect infrastructure to change (databases, payment providers, email services)
- Fast, isolated unit tests matter to your team
- You're building alongside DDD — hexagonal is the natural companion pattern
Skip it when:
- It's a simple CRUD app with almost no business rules
- You're building a prototype or MVP and need to move fast
- Your team isn't familiar with the pattern — forced adoption makes code worse, not better