Hexagonal Architecture: Ports and Adapters
Design your application first, then plug in external systems via ports. Swap a database adapter without rewriting core logic. Test without infrastructure. The hexagon's just a convention; the real power is inverting dependencies so external systems adapt to you.
Definition
Hexagonal Architecture, introduced by Alistair Cockburn, organizes systems around a clear boundary between the application core and the outside world. The core exposes ports (interfaces) for communicating with external systems. Adapters (implementations) connect those ports to specific technologies—databases, message queues, APIs, UIs.
The hexagon isn't the point. The shape is just a convention for drawing it. The actual architecture is the inversion of dependency: external systems depend on the core, never the reverse.
Why "ports"? Because a port is where you plug things in. Your application has a port for persistence (database, file, memory—doesn't matter). An adapter implements that port for a specific technology. Swap adapters without touching the core.
Why This Matters
Most systems are built inside-out: you pick a database, build a schema, then build the application on top of it. The application depends on the database. The database shapes the code.
Hexagonal Architecture inverts this: design the application first (what does it need to do?), then figure out how to persist state. The core knows it needs to store users. It doesn't know about PostgreSQL. An adapter bridges the gap.
This solves three problems:
Testability without infrastructure. You don't need a database to test core logic. Mock the port, plug in a test adapter, run your tests. Fast.
Replacing external systems is cheap. Started with PostgreSQL? Switching to MongoDB is a repository adapter rewrite, not an application rewrite.
Technology diversity. Different parts of the system can use different technologies. One adapter talks to PostgreSQL, another to MongoDB, another to a file system. The core is agnostic.
Ports: The Application Boundary
A port is an interface that describes what the application needs from the outside world.
Ports come in two types:
Primary Ports
Primary ports are where actors drive the application. The user sends an HTTP request. A scheduled job triggers. A CLI command runs. These are the ways the application gets work to do.
// Primary port - incoming, drives the application
interface UserCommandPort {
createUser(request: CreateUserRequest): Promise<CreateUserResponse>;
updateUser(id: string, request: UpdateUserRequest): Promise<void>;
deleteUser(id: string): Promise<void>;
}
interface UserQueryPort {
getUser(id: string): Promise<User>;
listUsers(filters: UserFilters): Promise<User[]>;
}When an HTTP request arrives, the controller implements these ports and calls the core. The core doesn't know about HTTP. It knows about these abstract operations.
Secondary Ports
Secondary ports are where the application drives external systems. "I need to persist this user." "I need to send an email." "I need to publish an event." These are the things the application asks the outside world to do.
// Secondary ports - outgoing, application depends on
interface UserRepository {
save(user: User): Promise<void>;
findById(id: string): Promise<User | null>;
delete(id: string): Promise<void>;
}
interface EmailService {
sendWelcome(email: string): Promise<void>;
sendReset(email: string, token: string): Promise<void>;
}
interface EventBus {
publish(event: DomainEvent): Promise<void>;
}The application core depends on these interfaces. The outside world provides implementations.
Adapters: The Technology Detail
An adapter implements a port for a specific technology. Two adapters for the same port means two implementations of the same contract.
Secondary Adapters (Driven)
These implement secondary ports. They handle persistence, messaging, external APIs.
// Adapter 1: PostgreSQL
class PostgresUserRepository implements UserRepository {
constructor(private db: Database) {}
async save(user: User): Promise<void> {
await this.db.query(
'INSERT INTO users (id, name, email) VALUES ($1, $2, $3)',
[user.id, user.name, user.email]
);
}
async findById(id: string): Promise<User | null> {
const row = await this.db.query('SELECT * FROM users WHERE id = $1', [id]);
if (!row) return null;
return new User(row.id, row.name, row.email);
}
async delete(id: string): Promise<void> {
await this.db.query('DELETE FROM users WHERE id = $1', [id]);
}
}
// Adapter 2: In-memory (for testing)
class MemoryUserRepository implements UserRepository {
private users = new Map<string, User>();
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 delete(id: string): Promise<void> {
this.users.delete(id);
}
}
// Adapter 3: MongoDB
class MongoUserRepository implements UserRepository {
constructor(private collection: MongoCollection) {}
async save(user: User): Promise<void> {
await this.collection.updateOne(
{ _id: user.id },
{ $set: { name: user.name, email: user.email } },
{ upsert: true }
);
}
async findById(id: string): Promise<User | null> {
const doc = await this.collection.findOne({ _id: id });
if (!doc) return null;
return new User(doc._id, doc.name, doc.email);
}
async delete(id: string): Promise<void> {
await this.collection.deleteOne({ _id: id });
}
}All three adapt the same port to different technologies. In production, you use PostgreSQL. In tests, you use in-memory. The application doesn't know the difference.
Primary Adapters (Drivers)
These adapt inbound requests to primary port calls. An HTTP controller, a CLI handler, a gRPC service.
// HTTP adapter
class CreateUserController {
constructor(private userCommands: UserCommandPort) {}
async handle(req: Request, res: Response): Promise<void> {
try {
const response = await this.userCommands.createUser({
name: req.body.name,
email: req.body.email
});
res.status(201).json(response);
} catch (error) {
res.status(400).json({ error: error.message });
}
}
}
// CLI adapter
class CreateUserCommand {
constructor(private userCommands: UserCommandPort) {}
async execute(args: CLIArgs): Promise<void> {
try {
const response = await this.userCommands.createUser({
name: args.name,
email: args.email
});
console.log(`User created: ${response.userId}`);
} catch (error) {
console.error(`Error: ${error.message}`);
}
}
}Same core logic, different entry points. HTTP, CLI, gRPC, cron job—all adapt the same application.
The Architecture in Practice
A typical structure:
/core
/domain
User.ts (entity)
/ports
UserRepository.ts (interface)
UserCommands.ts (interface)
EmailService.ts (interface)
/usecases
CreateUserUseCase.ts (uses ports)
GetUserUseCase.ts (uses ports)
/adapters
/primary
/http
CreateUserController.ts
GetUserController.ts
/cli
CreateUserCommand.ts
/secondary
/repositories
PostgresUserRepository.ts
MemoryUserRepository.ts
/services
SendgridEmailService.ts
MockEmailService.ts
/configuration
Bootstrap.ts (wire everything together)The core is isolated. The adapters live on the outside, plugging into ports. The bootstrap layer wires them together.
Hexagonal vs. Clean Architecture
These patterns are cousins. They both isolate domain logic from infrastructure. But they emphasize different things:
Clean Architecture emphasizes layering: entities are most stable, outer layers depend on inner ones. It provides a general framework.
Hexagonal Architecture emphasizes ports: external systems are abstracted behind interfaces. It's more about what adapts to what.
In practice, many systems use both: Clean Architecture's layering to organize the core, Hexagonal Architecture's ports to isolate external dependencies.
Testing with Hexagonal Architecture
Here's the payoff. Your use case depends on repositories and services. In tests, you inject mock implementations
// Test: no database, no email service
describe('CreateUserUseCase', () => {
let useCase: CreateUserUseCase;
let mockRepository: UserRepository;
let mockEmailService: EmailService;
beforeEach(() => {
mockRepository = new MemoryUserRepository();
mockEmailService = new MockEmailService();
useCase = new CreateUserUseCase(mockRepository, mockEmailService);
});
it('creates a user and sends welcome email', async () => {
const response = await useCase.execute({
name: 'Alice',
email: 'alice@example.com'
});
expect(response.userId).toBeDefined();
const user = await mockRepository.findById(response.userId);
expect(user?.email).toBe('alice@example.com');
expect(mockEmailService.sent).toContain('alice@example.com');
});
});No database setup. No external service calls. Tests run in milliseconds. This is what dependency inversion buys you.
Handling Cross-Service Communication
Hexagonal Architecture shines when systems need to talk to each other. Each system is a hexagon with its own ports.
Service A publishes a UserCreated event via its EventBus port. Service B listens via an adapter. Service B's logic depends on the event interface, not on Service A directly.
// Service A
interface EventBus {
publish(event: DomainEvent): Promise<void>;
}
class UserCreatedEvent implements DomainEvent {
constructor(public userId: string, public email: string) {}
}
class CreateUserUseCase {
constructor(
private repository: UserRepository,
private eventBus: EventBus
) {}
async execute(request: CreateUserRequest): Promise<void> {
const user = new User(generateId(), request.email);
await this.repository.save(user);
await this.eventBus.publish(
new UserCreatedEvent(user.id, user.email)
);
}
}
// Service B: Adapter listens to Service A's events
class EventBusKafkaAdapter implements EventBus {
subscribe(topic: string, handler: (event: DomainEvent) => Promise<void>) {
kafka.subscribe(topic, async (message) => {
const event = JSON.parse(message.value.toString());
await handler(event);
});
}
}
// Service B: Use case reacts to external event
class WelcomeUserUseCase {
constructor(private emailService: EmailService) {}
async execute(event: UserCreatedEvent): Promise<void> {
await this.emailService.sendWelcome(event.email);
}
}Services are decoupled. Service B doesn't import from Service A. It reacts to events. Either can change without breaking the other.
Common Mistakes
Mixing adapters with core logic. If your use case imports from an adapter, you've missed the point. Core logic should depend on ports (abstractions), not adapters (implementations).
Too many ports. If every data structure has its own repository, you've created overhead. Group related data under one repository port.
Confusing primary and secondary. Primary adapters drive the application (HTTP requests, timers). Secondary adapters are driven by it (database, email). Mixed up? You've broken the architecture.
Not mocking adapters in tests. The whole point of ports is testability. If you're spinning up a database in tests, you're using real adapters when you should be using mocks.
When Hexagonal Works Best
Multiple entry points. Your system needs HTTP, CLI, gRPC, cron jobs. Hexagonal makes this natural.
Multiple implementations of the same concern. You might use PostgreSQL in production but want MySQL as an option. You might use SMTP for email but also want Sendgrid. Different adapters, same port.
Systems that evolve technology. You don't know which database you'll end up with. Hexagonal delays that decision until you know more.
Microservices. Each service is a hexagon. Services communicate via adapters. The pattern scales naturally.
AI Agents and Hexagonal Architecture
Hexagonal Architecture makes AI-assisted development safer. Clear ports mean clear contracts. An agent trying to write a use case that directly imports a database adapter will violate the port contract, which Bitloops can catch.
Moreover, when you provide an AI agent with detailed port definitions, it knows exactly what abstractions to depend on. "Here's the UserRepository port, here's what it offers." The agent generates code that respects those boundaries.
This is context engineering: the better your architecture is defined (explicit ports, clear contracts), the safer AI generation becomes. See How AI-Generated Code Impacts Architecture for strategies to enforce these patterns at scale, and What Is AI-Native Development for how architectural thinking changes in AI-driven teams.
FAQ
Isn't Hexagonal Architecture overkill for a simple CRUD app?
For a simple app with a single database and UI, maybe. But even simple apps benefit from mocking in tests. And "simple" grows. Build the structure early; you'll thank yourself when requirements change.
How many adapters should I have for one port?
Usually two: a production adapter and a test adapter. Sometimes three or four if you truly have multiple implementations (e.g., file storage, S3, and memory for different use cases). If you have ten adapters for one port, you've probably over-abstracted.
What about shared libraries between services?
Share domain models, not adapters. Service A and Service B might both have a User entity. They can share the definition. But they shouldn't share repositories—each service owns its own data layer.
How do I handle transactions across multiple adapters?
Usually you keep transactions within one adapter (one database). If you need distributed transactions, you're in microservices territory and should use sagas or event sourcing.
Can I use ORMs with Hexagonal Architecture?
Yes. The ORM stays in the secondary adapter. Your repository adapter uses the ORM to persist entities. The core never imports ORM code.
What about dependency injection frameworks?
They're useful for wiring adapters together. But don't let the framework leak into your core. Spring Boot is an adapter, not part of your application.
Primary Sources
- Cockburn's original article introducing the hexagonal architecture pattern and its principles. Hexagonal Architecture
- Martin's foundational principle on how source code dependencies point inward. Dependency Rule
- Shore's guide to test-driven development discipline and three core rules. TDD Rules
- Fowler's comprehensive exploration of mock objects and testing patterns. Mock Objects
- Robert C. Martin's canonical text on architecture, dependency rules, and design principles. Clean Architecture
- Bass, Clements, and Kazman's comprehensive guide to software architecture decisions. Architecture in Practice
More in this hub
Hexagonal Architecture: Ports and Adapters
3 / 10Previous
Article 2
Clean Architecture: The Dependency Rule and Concentric Layers
Next
Article 4
Onion Architecture: Concentric Layers Without Compromise
Also in this hub
Get Started with Bitloops.
Apply what you learn in these hubs to real AI-assisted delivery workflows with shared context, traceable reasoning, and architecture-aware engineering practices.
curl -sSL https://bitloops.com/install.sh | bash