What is Hexagonal Architecture?

Hexagonal Architecture (also known as Ports and Adapters) is an architectural pattern that structures applications to isolate the core business logic from external concerns like databases, APIs, UI frameworks, and other infrastructure.

The architecture is visualized as a hexagon (though the number of sides doesn’t matter) with the business logic at the center and external dependencies on the outside.

Why Hexagonal Architecture Exists

Decoupling Business Logic from External Dependencies

The primary goal is to decouple business logic from infrastructure and external systems. This means:

Delaying Technology Choices

Hexagonal Architecture allows you to delay technology decisions by creating in-memory adapters:

// Start with an in-memory adapter
class InMemoryUserRepository implements UserRepository {
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;
}
}

// Later, swap in a real database adapter without touching business logic
class PostgresUserRepository implements UserRepository {
async save(user: User): Promise<void> {
await this.db.query('INSERT INTO users ...');
}

async findById(id: string): Promise<User | null> {
const result = await this.db.query('SELECT * FROM users WHERE id = $1', [id]);
return result.rows[0] || null;
}
}

You can build your entire application with in-memory adapters and decide on the actual database later. Who knows? Maybe you’ll end up using postgres but for some legal compliance you’ll store other data in s3 WORM buckets.

Demo Applications and Early UX Exploration

Frontends can behave like demo apps using in-memory adapters:

// Demo mode: instant responses, no backend needed
const userService = new UserService(new InMemoryUserRepository());

// Production mode: talks to real backend
const userService = new UserService(new HttpUserRepository(apiUrl));

This approach lets product teams validate ideas and user flows early, reducing waste.

Testability

With hexagonal architecture, testing becomes straightforward:

No need for complex mocking frameworks—just swap adapters.

Core Concepts

Ports

A port is an interface that defines how the outside world interacts with your application, or how your application interacts with the outside world.

Ports are defined by the business logic and represent what the application needs or provides.

// Port: defined by the application's needs
interface UserRepository {
save(user: User): Promise<void>;
findById(id: string): Promise<User | null>;
}

There are two types of ports:

Driving Ports (Primary Ports)

Driving ports allow external actors to drive your application. They represent use cases or application services.

Examples:

// Driving port: defines what the application can do
interface CreateUserUseCase {
execute(command: CreateUserCommand): Promise<User>;
}

// Driving adapter: REST controller
class UserController {
constructor(private createUser: CreateUserUseCase) {}

async post(req: Request, res: Response) {
const user = await this.createUser.execute({
email: req.body.email,
name: req.body.name,
});
res.json(user);
}
}

Driven Ports (Secondary Ports)

Driven ports are interfaces that the application uses to interact with external systems. They represent dependencies that the business logic needs.

Examples:

// Driven port: defines what the application needs
interface EmailService {
send(to: string, subject: string, body: string): Promise<void>;
}

// Driven adapter: real email implementation
class SendGridEmailService implements EmailService {
async send(to: string, subject: string, body: string): Promise<void> {
await this.sendGridClient.send({ to, subject, html: body });
}
}

// Driven adapter: in-memory for testing
class InMemoryEmailService implements EmailService {
sentEmails: Array<{ to: string; subject: string; body: string }> = [];

async send(to: string, subject: string, body: string): Promise<void> {
this.sentEmails.push({ to, subject, body });
}
}

Architecture Layers

Diagram: Hexagonal Architecture Layers

Dependency Rule: Dependencies point inward. The core business logic depends on nothing. Ports are defined by the core. Adapters depend on ports.

Transaction Management

Transactions are the Responsibility of the Driving Adapter

In hexagonal architecture, transaction management belongs to the driving adapter (or the infrastructure layer), not the business logic.

Why?

// Use case: no transaction management here
class CreateUserUseCase {
constructor(
private userRepository: UserRepository,
private eventBus: EventBus
) {}

async execute(command: CreateUserCommand): Promise<User> {
const user = User.create(command.email, command.name);
await this.userRepository.save(user);

// Publish domain event
this.eventBus.publish(new UserCreatedEvent(user));

return user;
}
}

// Driving adapter: handles transaction
class UserController {
constructor(
private createUser: CreateUserUseCase,
private transactionManager: TransactionManager
) {}

async post(req: Request, res: Response) {
// Transaction starts here
const user = await this.transactionManager.runInTransaction(async () => {
return await this.createUser.execute({
email: req.body.email,
name: req.body.name,
});
});

res.json(user);
}
}

Domain Events

Why Domain Events?

Domain events are a powerful tool to decouple logic that needs to react to changes made by another part of the application.

Instead of:

class CreateUserUseCase {
async execute(command: CreateUserCommand): Promise<User> {
const user = User.create(command.email, command.name);
await this.userRepository.save(user);

// Tight coupling: directly calling other services
await this.emailService.sendWelcomeEmail(user.email);
await this.analyticsService.trackUserCreation(user.id);
await this.searchIndex.indexUser(user);

return user;
}
}

Use domain events:

class CreateUserUseCase {
async execute(command: CreateUserCommand): Promise<User> {
const user = User.create(command.email, command.name);
await this.userRepository.save(user);

// Loose coupling: publish event
this.eventBus.publish(new UserCreatedEvent(user));

return user;
}
}

// Separate handlers react to the event
class SendWelcomeEmailHandler {
handle(event: UserCreatedEvent) {
this.emailService.sendWelcomeEmail(event.user.email);
}
}

class TrackUserCreationHandler {
handle(event: UserCreatedEvent) {
this.analyticsService.trackUserCreation(event.user.id);
}
}

Domain Event Handlers Must Run in the Same Transaction

Critical rule: Any handler reacting to domain events must run in the same transaction as the operation that triggered the event.

Why?

If an event handler fails, the entire transaction (including the original operation) should roll back.

Note: If you need to perform domain events across multiple services or systems, consider using a saga pattern or outbox pattern to ensure eventual consistency. Coming soon in this knowledge base!

Synchronous vs Asynchronous Events

// Synchronous: must succeed in the same transaction
this.eventBus.publish(new UserCreatedEvent(user)); // Handled synchronously

// Asynchronous: published to message queue after commit
this.messageQueue.publish(new UserCreatedMessage(user)); // Handled later

Benefits Summary

Common Mistakes

❌ Leaking Infrastructure into the Domain

// Bad: Domain depends on infrastructure
class User {
@Column() // ORM annotation leaks into domain!
email: string;
}
// Good: Domain is pure
class User {
constructor(public email: string) {}
}

// Adapter handles mapping
class PostgresUser {
constructor(
public email: string
) {}
}
class PostgresUserRepository {
toEntity(row: any): User {
return new User(row.email);
}

fromEntity(user: User): PostgresUser {
return new PostgresUser(user.email);
}
}

❌ Ports Defined by Adapters

Ports should be defined by the application’s needs, not by external libraries.

// Bad: Port shaped by ORM
interface UserRepository extends TypeOrmRepository<User> {}

// Good: Port shaped by domain needs
interface UserRepository {
save(user: User): Promise<void>;
findById(id: string): Promise<User | null>;
}

❌ Use Cases Depending on Concrete Adapters

// Bad: Use case depends on concrete adapter
class CreateUserUseCase {
constructor(private repo: PostgresUserRepository) {} // ❌
}

// Good: Use case depends on port (interface)
class CreateUserUseCase {
constructor(private repo: UserRepository) {} // ✅
}

Folder Structure Example

src/
├── domain/ # Core business logic (no dependencies)
│ ├── models/
│ │ └── User.ts
│ ├── services/
│ │ └── UserService.ts
│ └── events/
│ └── UserCreatedEvent.ts
├── application/ # Use cases (driving ports)
│ └── CreateUserUseCase.ts
├── ports/ # Port interfaces
│ ├── driving/
│ │ └── CreateUserUseCase.ts (interface)
│ └── driven/
│ ├── UserRepository.ts
│ └── EmailService.ts
└── adapters/
├── driving/ # Primary adapters
│ ├── http/
│ │ └── UserController.ts
│ └── cli/
│ └── CreateUserCommand.ts
└── driven/ # Secondary adapters
├── persistence/
│ ├── InMemoryUserRepository.ts
│ └── PostgresUserRepository.ts
└── email/
├── InMemoryEmailService.ts
└── SendGridEmailService.ts

Resources