Hexagonal and Clean Architecture: Ports, Adapters, and Dependency Inversion
Learn how hexagonal, onion, and clean architecture decouple business logic from frameworks and infrastructure using ports, adapters, use cases, and dependency inversion.
Why These Architectures?
Frameworks, databases, queues, and UI layers change. Core business rules should change because the business changes, not because a web framework or database driver changed.
Hexagonal architecture, onion architecture, and clean architecture all push the same principle: protect the domain and application logic from infrastructure details.
Key idea: Dependencies should point inward toward business rules. Infrastructure depends on the core; the core does not depend on infrastructure.
The Core Problem
In many applications, business logic is tangled with controllers, ORM models, queue consumers, and external APIs.
This makes the system hard to test and hard to change. A simple business rule may require a database, HTTP request, framework boot, and third-party client just to run a unit test.
Hexagonal Architecture
Hexagonal architecture is also called ports and adapters. The application core defines ports. Adapters implement those ports for specific technologies.
Ports
A port is an interface that expresses what the core needs or offers.
| Port Type | Direction | Example |
|---|---|---|
| Inbound port | Outside calls into application | PlaceOrderUseCase |
| Outbound port | Application calls outside dependency | PaymentGateway, OrderRepository |
Adapters
An adapter connects a technology to a port.
| Adapter Type | Example |
|---|---|
| Inbound adapter | REST controller, GraphQL resolver, queue consumer |
| Outbound adapter | PostgreSQL repository, Stripe client, SMTP email sender |
Dependency Inversion
Dependency inversion means high-level policy should not depend on low-level details. Both should depend on abstractions.
The core owns the interface because the core decides what it needs. The adapter conforms to that interface.
// Core layer
export interface PaymentGateway {
authorizePayment(input: {
orderId: string;
amount: number;
currency: string;
}): Promise<{ authorizationId: string }>;
}
export class PlaceOrderUseCase {
constructor(private readonly paymentGateway: PaymentGateway) {}
async execute(command: PlaceOrderCommand) {
const authorization = await this.paymentGateway.authorizePayment({
orderId: command.orderId,
amount: command.totalAmount,
currency: command.currency,
});
return authorization.authorizationId;
}
}
// Infrastructure layer
export class StripePaymentGateway implements PaymentGateway {
async authorizePayment(input: {
orderId: string;
amount: number;
currency: string;
}) {
const response = await stripe.paymentIntents.create({
amount: input.amount,
currency: input.currency,
metadata: { orderId: input.orderId },
});
return { authorizationId: response.id };
}
}
Clean Architecture Layers
Clean architecture expresses the same dependency rule with concentric layers.
| Layer | Owns |
|---|---|
| Entities | Core domain rules and invariants |
| Use cases | Application-specific workflows |
| Interface adapters | Controllers, presenters, gateways |
| Frameworks and drivers | Web, database, UI, external libraries |
The rule is simple: source code dependencies point inward.
Onion Architecture
Onion architecture is similar, but often emphasizes the domain model at the center and infrastructure on the outside.
| Architecture | Main Vocabulary | Shared Principle |
|---|---|---|
| Hexagonal | Ports and adapters | Core isolated from infrastructure |
| Clean | Entities, use cases, interface adapters | Dependencies point inward |
| Onion | Domain center, outer infrastructure | Domain does not depend on outer layers |
The differences matter less than the dependency rule.
Example: Place Order
Responsibilities
| Component | Responsibility |
|---|---|
| Controller | Parse HTTP request, call use case, map response |
| Use case | Orchestrate application workflow |
| Domain model | Enforce business invariants |
| Repository port | Express persistence need |
| Repository adapter | Implement persistence with database |
| Payment port | Express payment capability |
| Payment adapter | Implement payment with provider SDK |
Testing Benefits
Because the core depends on interfaces, it can be tested with fakes.
class FakePaymentGateway implements PaymentGateway {
async authorizePayment() {
return { authorizationId: "auth-test-1" };
}
}
class InMemoryOrderRepository implements OrderRepository {
private orders = new Map<string, Order>();
async save(order: Order) {
this.orders.set(order.id, order);
}
}
| Test Type | What It Should Cover |
|---|---|
| Domain unit test | Entity and aggregate invariants |
| Use case test | Workflow rules using fake ports |
| Adapter integration test | Database queries, provider clients, serialization |
| End-to-end test | Critical happy paths across real wiring |
Do not mock the world: Test the core with fakes, and test adapters against real dependencies where practical.
Folder Structure
One possible structure:
src/
domain/
order.ts
money.ts
application/
ports/
order-repository.ts
payment-gateway.ts
use-cases/
place-order.ts
infrastructure/
database/
postgres-order-repository.ts
payments/
stripe-payment-gateway.ts
interfaces/
http/
order-controller.ts
queue/
order-command-consumer.ts
This is a starting point, not a law. The important part is dependency direction, not folder names.
Common Mistakes
| Mistake | Problem | Better Approach |
|---|---|---|
| Interfaces for everything | Adds ceremony without value | Create ports at meaningful boundaries |
| Framework types in use cases | Core becomes framework-coupled | Map to plain commands and DTOs |
| ORM entities as domain model | Database constraints shape business logic | Separate persistence mapping when needed |
| Anemic use cases | Controllers keep business workflow | Move workflow into application layer |
| Huge abstractions early | Slows simple CRUD work | Apply where change and testing justify it |
When to Use It
| Situation | Recommendation |
|---|---|
| Complex business rules | Strong fit |
| Multiple delivery mechanisms | Strong fit |
| Need fast unit tests | Strong fit |
| Infrastructure likely to change | Good fit |
| Simple CRUD admin app | Keep it lightweight |
| Prototype with short life | Avoid heavy ceremony |
Good Boundaries for Ports
Create ports around things that are slow, external, nondeterministic, or likely to vary:
- Database access
- Payment providers
- Email and SMS
- Search engines
- Message brokers
- Object storage
- Clock and ID generation
What to Remember for Interviews
- Business logic belongs in the core: Frameworks and databases sit outside it.
- Ports are interfaces owned by the application: Adapters implement them.
- Dependencies point inward: Outer layers depend on inner layers, not the reverse.
- Testing improves because the core is isolated: Use fakes for ports and integration tests for adapters.
- Use pragmatically: The pattern is valuable when complexity, change, or testing pain justifies it.
Practice: Refactor a simple checkout flow into controllers, use cases, domain objects, ports, and adapters. Then replace the real payment adapter with a fake in a use case test.