Hexagonal Architecture: Ports and Adapters Explained
Table of Contents
- Introduction
- The Core Idea
- Ports and Adapters
- Dependency Direction
- Example: User Registration
- Hexagonal Architecture Diagram
- Example Folder Structure
- Code Example: Register User
- Composition Root
- Why It Helps Testing
- When This Pattern Is Worth It
- Common Mistakes
- Checklist
- FAQ
- Conclusion
Introduction
Many applications become hard to change because business logic is mixed with frameworks, databases, HTTP handlers, message queues, and third-party SDKs.
Hexagonal architecture, also called ports and adapters, tries to protect the core application from those external details.
The goal is not to create folders with fancy names. The goal is to keep the business rules easy to test, replace, and understand.
The Core Idea
Hexagonal architecture places the application core in the center.
The outside world talks to the core through ports. Adapters implement those ports for specific technologies.
HTTP Controller -> Input Port -> Application Service -> Output Port -> Database Adapter
The core should not care whether a request came from REST, GraphQL, CLI, or a message queue. It should care about the use case.
Ports and Adapters
A port is an interface that describes what the application needs or offers.
An adapter connects that port to a real tool.
| Concept | Example |
|---|---|
| Input port | RegisterUserUseCase |
| Input adapter | HTTP controller, CLI command |
| Output port | UserRepository, EmailSender |
| Output adapter | PostgreSQL repository, SMTP sender |
This separation makes technology replaceable without rewriting the business rules.
Dependency Direction
The most important rule is dependency direction.
The core can define interfaces. External adapters implement them. The core should not import framework or infrastructure details.
Bad direction:
Application service -> Express request -> Database client
Better direction:
Controller -> Application service -> Repository port
Postgres adapter -> Repository port
The application service depends on abstractions. The infrastructure depends on the application contract.
Example: User Registration
Imagine a user registration use case.
The core needs to:
- validate input
- check if the email already exists
- create the user
- send a welcome email
Ports:
| Port | Responsibility |
|---|---|
UserRepository | find and save users |
PasswordHasher | hash passwords |
EmailSender | send welcome email |
Adapters:
| Adapter | Implements |
|---|---|
PostgresUserRepository | UserRepository |
BcryptPasswordHasher | PasswordHasher |
SmtpEmailSender | EmailSender |
The registration logic stays stable even if the email provider or database changes.
Hexagonal Architecture Diagram
The following diagram shows the simple shape. The application core sits in the center. The outside world enters through input ports or leaves through output ports.
flowchart LR
subgraph Outside["Outside World"]
HTTP["HTTP Controller"]
CLI["CLI Command"]
Queue["Message Consumer"]
DB["PostgreSQL Adapter"]
SMTP["SMTP Adapter"]
Hash["Bcrypt Adapter"]
end
subgraph Core["Application Core"]
UseCase["RegisterUser Use Case"]
UserRepoPort["UserRepository Port"]
EmailPort["EmailSender Port"]
HashPort["PasswordHasher Port"]
end
HTTP --> UseCase
CLI --> UseCase
Queue --> UseCase
UseCase --> UserRepoPort
UseCase --> EmailPort
UseCase --> HashPort
DB -. implements .-> UserRepoPort
SMTP -. implements .-> EmailPort
Hash -. implements .-> HashPort
The important part is not the visual hexagon shape. The important part is dependency direction: adapters may know about the core, but the core does not need to know about adapters.
Example Folder Structure
Folder structures vary, but this split is easy to reason about:
src/
application/
ports/
UserRepository.ts
EmailSender.ts
PasswordHasher.ts
use-cases/
RegisterUser.ts
domain/
User.ts
infrastructure/
database/
PostgresUserRepository.ts
email/
SmtpEmailSender.ts
security/
BcryptPasswordHasher.ts
interfaces/
http/
RegisterUserController.ts
main.ts
application and domain are the parts you want to keep clean. infrastructure and interfaces are adapters that can depend on frameworks, database clients, email SDKs, or hashing libraries.
Code Example: Register User
Start with ports. A port describes what the use case needs, not the technical way to do it.
export type User = {
id: string;
email: string;
passwordHash: string;
};
export interface UserRepository {
findByEmail(email: string): Promise<User | null>;
save(user: User): Promise<void>;
}
export interface PasswordHasher {
hash(rawPassword: string): Promise<string>;
}
export interface EmailSender {
sendWelcomeEmail(email: string): Promise<void>;
}
Then the application service or use case:
type RegisterUserInput = {
email: string;
password: string;
};
export class RegisterUser {
constructor(
private readonly users: UserRepository,
private readonly passwordHasher: PasswordHasher,
private readonly emailSender: EmailSender,
) {}
async execute(input: RegisterUserInput) {
if (!input.email.includes("@")) {
throw new Error("Invalid email");
}
if (input.password.length < 8) {
throw new Error("Password must be at least 8 characters");
}
const existingUser = await this.users.findByEmail(input.email);
if (existingUser) {
throw new Error("Email is already registered");
}
const passwordHash = await this.passwordHasher.hash(input.password);
const user: User = {
id: crypto.randomUUID(),
email: input.email,
passwordHash,
};
await this.users.save(user);
await this.emailSender.sendWelcomeEmail(user.email);
return {
id: user.id,
email: user.email,
};
}
}
Notice that RegisterUser does not know whether data is stored in PostgreSQL, MySQL, DynamoDB, or memory. It also does not know whether email is sent through SMTP, SendGrid, or a message queue.
A database adapter can look like this:
export class PostgresUserRepository implements UserRepository {
constructor(private readonly db: DatabaseClient) {}
async findByEmail(email: string): Promise<User | null> {
const row = await this.db.queryOne(
"select id, email, password_hash from users where email = $1",
[email],
);
if (!row) {
return null;
}
return {
id: row.id,
email: row.email,
passwordHash: row.password_hash,
};
}
async save(user: User): Promise<void> {
await this.db.execute(
"insert into users (id, email, password_hash) values ($1, $2, $3)",
[user.id, user.email, user.passwordHash],
);
}
}
The HTTP adapter becomes the entry point. It translates an HTTP request into use case input.
export class RegisterUserController {
constructor(private readonly registerUser: RegisterUser) {}
async handle(req: Request): Promise<Response> {
const body = await req.json();
try {
const result = await this.registerUser.execute({
email: body.email,
password: body.password,
});
return Response.json(result, { status: 201 });
} catch (error) {
return Response.json(
{ message: error instanceof Error ? error.message : "Registration failed" },
{ status: 400 },
);
}
}
}
The controller may know HTTP details. The database adapter may know SQL details. The use case still talks through the interfaces it needs.
Composition Root
A practical question often comes up: if the core should not create adapters by itself, who wires everything together?
The answer is the composition root, usually in the application entry point.
const db = new DatabaseClient(process.env.DATABASE_URL);
const userRepository = new PostgresUserRepository(db);
const passwordHasher = new BcryptPasswordHasher();
const emailSender = new SmtpEmailSender(process.env.SMTP_URL);
const registerUser = new RegisterUser(
userRepository,
passwordHasher,
emailSender,
);
export const registerUserController = new RegisterUserController(registerUser);
This is where real objects are assembled. The core stays clean, while the application can still use real technologies.
Why It Helps Testing
Hexagonal architecture makes business logic easier to test because the core can be tested without real infrastructure.
For example:
| Test target | Dependency style |
|---|---|
| registration rules | fake repository and fake email sender |
| HTTP behavior | controller test |
| database SQL | repository integration test |
This keeps tests focused. You do not need a full web server and real database just to test a business rule.
Example unit test for the use case:
class FakeUserRepository implements UserRepository {
users = new Map<string, User>();
async findByEmail(email: string) {
return this.users.get(email) ?? null;
}
async save(user: User) {
this.users.set(user.email, user);
}
}
class FakePasswordHasher implements PasswordHasher {
async hash(rawPassword: string) {
return `hashed:${rawPassword}`;
}
}
class FakeEmailSender implements EmailSender {
sentTo: string[] = [];
async sendWelcomeEmail(email: string) {
this.sentTo.push(email);
}
}
const users = new FakeUserRepository();
const emailSender = new FakeEmailSender();
const useCase = new RegisterUser(
users,
new FakePasswordHasher(),
emailSender,
);
const result = await useCase.execute({
email: "[email protected]",
password: "secret123",
});
expect(result.email).toBe("[email protected]");
expect(emailSender.sentTo).toContain("[email protected]");
This test does not need a web server, database, SMTP server, or real bcrypt. It tests the registration rule.
When This Pattern Is Worth It
Hexagonal architecture is most useful when the application has important business rules and external dependencies that may change.
Use it when:
- use cases have business rules that need serious tests
- the application has multiple delivery mechanisms, such as HTTP and queues
- external dependencies change often or carry meaningful risk
- the team wants to separate domain logic from framework code
- workflows are becoming more complex than simple CRUD
For a small CRUD app, the pattern can feel too heavy. It is fine to start with a simpler structure and add ports when a boundary becomes genuinely useful.
Common Mistakes
Creating too many interfaces
Not every class needs a port. Add boundaries where replacement, testing, or ownership actually matters.
Letting framework types leak into the core
If your use case accepts an HTTP request object, the core is now tied to the web framework.
Confusing architecture with folder structure
Folders help, but dependency direction matters more than folder names.
Overengineering small CRUD apps
Simple applications may not need full hexagonal structure. Use the pattern where complexity justifies the boundaries.
Checklist
- Put business use cases near the center.
- Keep framework and infrastructure details outside the core.
- Define ports for important external dependencies.
- Implement ports with adapters.
- Keep dependency direction pointing inward.
- Test core logic with fake adapters.
- Use integration tests for real adapters.
- Avoid creating interfaces just for decoration.
FAQ
Is hexagonal architecture the same as clean architecture?
They are closely related and often paired with concepts like Layered Architecture vs Clean Architecture and Domain-Driven Design (DDD). Both emphasize dependency direction and protecting business rules from external details. For managing transactions across distributed services outside the core boundary, you can combine it with the Saga Pattern. The terminology and layering differ.
Does every repository need an interface?
Not always. Add a port when it gives you useful isolation, testing flexibility, or technology independence.
Can frontend apps use hexagonal architecture?
Yes. The same idea can separate UI frameworks from business logic, API clients, storage, and domain rules.
Conclusion
Hexagonal architecture helps keep the application core independent from delivery mechanisms and infrastructure.
Use ports to express what the core needs. Use adapters to connect real technologies. Keep the direction clear, and the system becomes easier to test and change.
Have you implemented Hexagonal Architecture or Ports & Adapters in your projects? Or do you feel the abstraction adds too much overhead for your current app scale? Share your thoughts and experience in the comments below! 💬
Related Articles
Keep reading within the same topic.
Event Sourcing: Building Auditable Systems
Understand event sourcing with practical examples, event streams, projections, audit trails, trade-offs, and when this architecture pattern is worth using.
Circuit Breaker Pattern: Building Resilient Systems
Learn the circuit breaker pattern with closed, open, and half-open states, retries, timeouts, fallbacks, observability, and production trade-offs.
Saga Pattern: Managing Distributed Transactions
Learn the saga pattern for distributed transactions, including choreography, orchestration, compensating actions, failure handling, and practical trade-offs.
Flask Jinja Templates: Layouts, Variables, and Loops
Use Jinja templates in Flask to pass data into HTML, build loops and conditionals, and reuse a base layout without repeating markup.