What Is Hexagonal Architecture? Ports and Adapters Explained
Hexagonal Architecture, also known as Ports and Adapters Architecture, is a software architecture pattern that separates application logic from external technologies such as databases, web frameworks, message brokers, and third-party APIs. Instead of allowing business rules to depend directly on FastAPI, PostgreSQL, or an external payment SDK, the application communicates through well-defined interfaces called ports. Technology-specific implementations called adapters connect those interfaces to the outside world.
The pattern was introduced by Alistair Cockburn and formally described in 2005. Its central idea is that an application should be usable and testable without requiring its normal user interface or production database. That separation is particularly valuable in backend systems where business rules outlive individual frameworks, databases, and integration technologies. This guide explains the architectural boundaries, dependency rules, implementation decisions, and production trade-offs using a practical Python order management API.
Table of Contents
- What Is Hexagonal Architecture?
- How Hexagonal Architecture Works
- Dependency Inversion and Application Boundaries
- Production Design Example: Order Management API
- Ready-to-Use Example with FastAPI and PostgreSQL
- Hexagonal vs. Layered, Onion, and Clean Architecture
- Production Practices and Common Mistakes
- Frequently Asked Questions
- Conclusion
What Is Hexagonal Architecture?
Hexagonal Architecture organizes software around the behaviors the application provides rather than the infrastructure used to deliver those behaviors. The application's core contains its business rules and use cases. External components interact with the core through ports, while adapters translate between the application's contracts and concrete technologies.
A web API, command-line program, background worker, or automated test can all invoke the same application use case. Similarly, an application can persist data using PostgreSQL in production and an in-memory implementation during unit tests without changing the use case itself.
Origins and Core Principles
Alistair Cockburn introduced the pattern to prevent business logic from becoming entangled with user interfaces, databases, and other external systems. The objective was to enable an application to be driven through multiple mechanisms and tested independently of production infrastructure.
The core principles are:
- Inside-outside separation: Application behavior is distinguished from external technology.
- Explicit ports: Communication with the application follows defined contracts.
- Replaceable adapters: Different implementations can fulfill the same contract.
- Dependency inversion: Infrastructure implementations depend on application abstractions, not the reverse.
- Independent testing: Business behavior can be exercised without a real database or HTTP server.
The hexagonal shape is a conceptual illustration, not a requirement to have six layers, six adapters, or six interfaces. It emphasizes that the application may communicate with external actors through several different ports rather than fitting exclusively into a top-to-bottom stack.
In practice, the most important boundary is between application behavior and the technologies surrounding it.
Why Traditional Layered Architecture Can Become Tightly Coupled
A conventional backend often starts with controllers, services, and repositories. This can work well, but the naming of those layers alone does not ensure proper dependency boundaries.
Consider a service that creates an order:
from fastapi import HTTPException
from sqlalchemy.orm import Session
from app.database import OrderModel
from app.payment_sdk import PaymentClient
class OrderService:
def __init__(self, db: Session):
self.db = db
self.payment = PaymentClient()
def create_order(self, order_id: str, amount: int):
if amount <= 0:
raise HTTPException(
status_code=400,
detail="Invalid amount",
)
self.payment.charge(amount)
order = OrderModel(
id=order_id,
amount_cents=amount,
)
self.db.add(order)
self.db.commit()
return order
This service knows about FastAPI exceptions, SQLAlchemy sessions, database models, and a payment SDK. Its business operation depends on several technologies at once.
Unit testing may require patching SDK construction, configuring ORM models, and handling framework-specific errors. Replacing the payment provider also requires editing application logic.
There is a more serious problem: the payment may succeed while the database commit fails. The example has no business-level recovery strategy for this partial failure.
Hexagonal Architecture does not automatically solve distributed transactions, but it gives developers a clearer location to express the use case and design explicit failure handling independently of infrastructure details.
How Hexagonal Architecture Works
A hexagonal application is organized around an application core and a collection of adapters. The core expresses what the application does. Adapters express how external actors communicate with it and how its dependencies are fulfilled.
The boundary is more important than the number of directories. A project with dozens of folders can still violate the architecture if domain logic imports concrete infrastructure classes.
The Application Core and Domain Logic
The core commonly includes domain entities, value objects, business rules, and application use cases.
For an order management application, the domain might define an Order with an identifier, customer identifier, monetary amount, and status.
A use case such as CreateOrder coordinates validation, persistence, and business-level decisions. It should not need to know whether the initiating request arrived through an HTTP endpoint, a CLI command, or a scheduled job.
Domain entities usually enforce business invariants. Application use cases coordinate actions that involve those entities and required external capabilities.
For example, the domain can reject an order with a nonpositive amount. The application use case can decide whether an existing order ID represents a safe retry or a conflicting operation.
Ports and Adapters Explained
A port is a defined communication contract at the application's boundary. An adapter implements or invokes that contract using a particular technology.
For example, the application may require the capability to save an order. It should express that requirement using an interface such as OrderRepository, without exposing SQLAlchemy or PostgreSQL-specific operations.
Adapters can then supply implementations such as:
- PostgreSQL adapter: Persists orders using SQLAlchemy.
- In-memory adapter: Stores orders in a Python dictionary for tests.
- HTTP adapter: Converts FastAPI requests into application commands.
- CLI adapter: Invokes the same use case from a command-line tool.
- Messaging adapter: Consumes an SQS or RabbitMQ message and invokes the use case.
The interface should be designed around the application's needs. A repository port with only create and get_by_id can be better than a generic repository abstraction exposing dozens of operations the use case does not require.
Driving vs. Driven Adapters
Hexagonal Architecture commonly distinguishes two categories of adapters based on who initiates the interaction.
| Characteristic | Driving Adapter | Driven Adapter |
|---|---|---|
| Alternative term | Primary adapter | Secondary adapter |
| Responsibility | Invokes application behavior | Provides external capability requested by the application |
| Examples | FastAPI endpoint, CLI, message consumer | PostgreSQL repository, payment API, event publisher |
| Typical testing substitute | Direct use-case invocation or test driver | Fake, stub, or mock implementation |
A driving adapter receives input and invokes a port into the application. A driven adapter fulfills a dependency needed by an application operation.
The distinction is about control flow, not the direction of data. A database adapter returns data to the application, but it is still a driven adapter because the application initiates the repository operation.
Dependency Inversion and Application Boundaries
Dependency inversion is what makes ports and adapters more than a folder naming convention. High-level application policies should not require importing low-level database drivers, web frameworks, or third-party SDKs.
The implementation depends on an abstraction whose contract is defined around the application, rather than forcing the application to depend on an infrastructure API.
Dependency Direction and Interface Ownership
Imagine a CreateOrder use case that needs to persist an order. The use case depends on OrderRepository, a port defined by the application.
The PostgreSQL repository adapter imports that port or implements its expected behavior. The application does not import the PostgreSQL repository.
At runtime, control flows from the use case to the repository implementation. But source-code dependencies still point toward the application contract.
This separation is useful because runtime calls and source-code dependencies are not required to point in the same architectural direction.
In Python, a typing.Protocol can define a structural contract without requiring every implementation to inherit from a common abstract class. An ABC can also be appropriate when explicit inheritance is valuable.
Framework and Database Independence
Framework independence does not mean an application can replace PostgreSQL with DynamoDB without considering differences in transactions, consistency, or query patterns.
It means the core's business rules are not unnecessarily defined in terms of one technology's API.
If a PostgreSQL adapter uses SQLAlchemy models, SQL expressions, and database sessions, those details stay within the adapter. The domain does not receive SQLAlchemy sessions or return ORM models.
Likewise, FastAPI's request models and HTTP exceptions belong at the HTTP boundary. Application services should use framework-independent inputs, outputs, and exceptions.
Adapters should translate failures into application-meaningful concepts. For example, an HTTP adapter can map a duplicate-order error to status 409 without requiring the use case to import HTTPException.
Dependency Injection and the Composition Root
Some part of the application must choose which concrete adapters fulfill the ports. This is the responsibility of the composition root.
For a FastAPI application, the composition root may create a database session, instantiate a repository adapter, construct a use case, and supply it to the HTTP endpoint.
Tests can construct that same use case with an in-memory repository instead.
Dependency injection does not require a heavyweight container. Constructor injection is often sufficient:
class CreateOrder:
def __init__(self, repository: OrderRepository):
self.repository = repository
# Production wiring supplies PostgreSQL:
# CreateOrder(PostgresOrderRepository(session))
# Unit tests supply an in-memory adapter:
# CreateOrder(InMemoryOrderRepository())
In this snippet, OrderRepository is an application-defined port. Its definition and the full implementations appear in the example below.
The main objective is not to maximize abstraction. It is to make infrastructure choices explicit at the boundary instead of hiding them inside business code.
Production Design Example: Order Management API
Consider an order management service built with FastAPI, PostgreSQL, and a third-party payment provider. The service receives purchase requests, validates orders, persists order state, and coordinates payment processing.
The system also needs an asynchronous notification mechanism and unit tests that run without connecting to real external services.
Architecture and Component Responsibilities
A practical production design separates the following responsibilities:
- Domain: Order entities, monetary validation, and allowed state transitions.
- Application: CreateOrder, AuthorizePayment, CancelOrder, and other use cases.
- Driving adapters: FastAPI endpoints, queue consumers, administrative CLI tools.
- Repository port: Persistence operations needed by application use cases.
- Payment port: Operations required from an external payment provider.
- Event publishing port: Application-level contract for notifying other systems.
- Driven adapters: PostgreSQL implementation, payment SDK, SQS or event broker integration.
- Composition root: Configures sessions, adapters, and use cases.
The application can expose the same business operation through an HTTP endpoint and an asynchronous worker without duplicating its core validation rules.
A significant benefit is replacing external dependencies during testing. A fake payment adapter can return approved or declined results without making real payments, while a fake repository keeps test state in memory.
Request Flow, Transactions, and Failure Handling
For a simple order creation request, the HTTP adapter parses the request, constructs the application input, invokes the use case, and translates the result into an HTTP response.
The use case creates the domain entity and requests persistence through the repository port. The PostgreSQL adapter executes the SQL operations and translates the stored representation back to the domain model when necessary.
However, production order processing has additional failure conditions. If a payment is authorized and the subsequent database commit fails, the system may need to void the authorization or reconcile the payment asynchronously.
Likewise, committing an order and then directly publishing an event creates a potential dual-write failure. The database commit may succeed while the message publication fails.
A production solution may use the Transactional Outbox pattern to persist the order state and outgoing event atomically in one database transaction. A separate publisher then delivers the event to the messaging system.
Hexagonal Architecture makes the boundaries clear, but it does not itself guarantee atomicity across external systems, exactly-once delivery, or recoverability.
The example below deliberately implements the smaller, independently runnable order-creation slice. A real payment workflow would introduce further use cases and failure recovery policies without pushing the payment SDK directly into the domain.
Ready-to-Use Example with FastAPI and PostgreSQL
This implementation uses Python 3.12 or newer, FastAPI, SQLAlchemy, PostgreSQL, and pytest. It exposes an API for creating and retrieving orders while keeping business rules independent of HTTP and persistence technology.
Unlike pseudocode that only demonstrates interface names, the following files form a minimal application that can run locally.
Project Structure and Dependencies
Create the following project structure. Empty __init__.py files identify the Python packages.
hexagonal-orders/
app/
__init__.py
domain.py
ports.py
use_cases.py
postgres.py
api.py
tests/
test_create_order.py
requirements.txt
docker-compose.yml
Create requirements.txt:
fastapi>=0.115,<1
uvicorn>=0.30,<1
sqlalchemy>=2.0,<3
psycopg[binary]>=3.2,<4
pytest>=8,<10
httpx>=0.27,<1
In a production project, use a tested dependency lockfile rather than resolving potentially different compatible versions on every deployment.
Domain Models and Repository Ports
The domain model validates the order's essential business invariants. It does not know about FastAPI request types, database tables, or JSON serialization.
Create app/domain.py and app/ports.py as two separate files:
# app/domain.py
from dataclasses import dataclass
@dataclass(frozen=True)
class Order:
order_id: str
customer_id: str
amount_cents: int
def __post_init__(self) -> None:
if not self.order_id:
raise ValueError("order_id is required")
if not self.customer_id:
raise ValueError("customer_id is required")
if (
type(self.amount_cents) is not int
or self.amount_cents <= 0
):
raise ValueError(
"amount_cents must be positive"
)
# app/ports.py
from typing import Protocol
from app.domain import Order
class OrderRepository(Protocol):
def create(self, order: Order) -> bool:
"""Return False if the ID already exists."""
def get_by_id(self, order_id: str) -> Order | None:
"""Return the order, or None if missing."""
The Order model is an immutable domain object. Its validation rules work identically whether the order comes from HTTP, a command-line tool, or a queue consumer.
The repository port expresses two capabilities required by the application. It does not expose Session, SQL expressions, table metadata, or connection management.
Using a boolean for create is a deliberate simplification. A larger system may prefer an explicit result object or a domain-specific conflict exception when repository operations have more possible business outcomes.
Application Use Cases
The application layer coordinates domain behavior and persistence. It has no dependency on FastAPI or SQLAlchemy.
Create app/use_cases.py:
from dataclasses import dataclass
from app.domain import Order
from app.ports import OrderRepository
class OrderAlreadyExists(Exception):
pass
class OrderNotFound(Exception):
pass
@dataclass(frozen=True)
class CreateOrderCommand:
order_id: str
customer_id: str
amount_cents: int
class CreateOrder:
def __init__(
self,
repository: OrderRepository,
) -> None:
self.repository = repository
def execute(
self,
command: CreateOrderCommand,
) -> Order:
order = Order(
order_id=command.order_id,
customer_id=command.customer_id,
amount_cents=command.amount_cents,
)
created = self.repository.create(order)
if not created:
raise OrderAlreadyExists(
f"Order {order.order_id} already exists"
)
return order
class GetOrder:
def __init__(
self,
repository: OrderRepository,
) -> None:
self.repository = repository
def execute(self, order_id: str) -> Order:
order = self.repository.get_by_id(order_id)
if order is None:
raise OrderNotFound(
f"Order {order_id} not found"
)
return order
The CreateOrder use case depends on a repository contract, while GetOrder uses the same abstraction to retrieve an existing order.
A duplicate-order conflict is represented by a framework-independent exception. The HTTP adapter will translate it into a 409 Conflict response.
Notice that the application code does not know whether the repository stores data in PostgreSQL, a memory dictionary, or another supported persistence system.
PostgreSQL Repository Adapter
The PostgreSQL adapter translates domain objects into database operations. SQLAlchemy models remain inside the infrastructure module, and a database uniqueness constraint protects order creation against concurrent requests.
Create app/postgres.py:
import os
from sqlalchemy import (
Integer,
String,
create_engine,
)
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.orm import (
DeclarativeBase,
Mapped,
Session,
mapped_column,
)
from app.domain import Order
DATABASE_URL = os.getenv(
"DATABASE_URL",
"postgresql+psycopg://app:app@localhost:5432/orders",
)
engine = create_engine(
DATABASE_URL,
pool_pre_ping=True,
)
class Base(DeclarativeBase):
pass
class OrderRow(Base):
__tablename__ = "orders"
order_id: Mapped[str] = mapped_column(
String(100),
primary_key=True,
)
customer_id: Mapped[str] = mapped_column(
String(100),
nullable=False,
)
amount_cents: Mapped[int] = mapped_column(
Integer,
nullable=False,
)
class PostgresOrderRepository:
def __init__(self, session: Session) -> None:
self.session = session
def create(self, order: Order) -> bool:
stmt = (
insert(OrderRow)
.values(
order_id=order.order_id,
customer_id=order.customer_id,
amount_cents=order.amount_cents,
)
.on_conflict_do_nothing(
index_elements=["order_id"]
)
.returning(OrderRow.order_id)
)
result = self.session.execute(stmt)
created_id = result.scalar_one_or_none()
if created_id is None:
return False
# Commit is owned by this adapter in the
# deliberately small example.
self.session.commit()
return True
def get_by_id(
self,
order_id: str,
) -> Order | None:
row = self.session.get(OrderRow, order_id)
if row is None:
return None
return Order(
order_id=row.order_id,
customer_id=row.customer_id,
amount_cents=row.amount_cents,
)
The PostgreSQL-specific ON CONFLICT DO NOTHING operation protects the create operation against races. Two concurrent requests cannot both create the same primary key.
The adapter also converts SQLAlchemy rows into plain domain objects. ORM entities do not escape into the use case or the API response model.
For simplicity, this adapter commits its own writes. A larger application that modifies several repositories within one business operation should usually coordinate a shared transaction boundary through a unit-of-work abstraction or an explicit application transaction policy.
Also note that create_all in the next section is suitable for this small demonstration, not a substitute for versioned production migrations.
FastAPI Adapter and Dependency Injection
The FastAPI adapter owns HTTP validation, request and response models, and mapping application exceptions to HTTP status codes.
It also acts as the composition root by constructing use cases with concrete repository adapters.
Create app/api.py:
from contextlib import asynccontextmanager
from typing import Annotated, Iterator
from fastapi import Depends, FastAPI, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.postgres import (
Base,
PostgresOrderRepository,
engine,
)
from app.use_cases import (
CreateOrder,
CreateOrderCommand,
GetOrder,
OrderAlreadyExists,
OrderNotFound,
)
class OrderRequest(BaseModel):
order_id: str = Field(min_length=1)
customer_id: str = Field(min_length=1)
amount_cents: int = Field(gt=0)
class OrderResponse(BaseModel):
order_id: str
customer_id: str
amount_cents: int
@asynccontextmanager
async def lifespan(app: FastAPI):
# Demo only: use Alembic migrations
# for production schema changes.
Base.metadata.create_all(bind=engine)
yield
engine.dispose()
app = FastAPI(
title="Hexagonal Orders API",
lifespan=lifespan,
)
def get_session() -> Iterator[Session]:
with Session(engine) as session:
yield session
def get_repository(
session: Annotated[
Session,
Depends(get_session),
],
) -> PostgresOrderRepository:
return PostgresOrderRepository(session)
@app.post(
"/orders",
response_model=OrderResponse,
status_code=201,
)
def create_order(
payload: OrderRequest,
repository: Annotated[
PostgresOrderRepository,
Depends(get_repository),
],
):
use_case = CreateOrder(repository)
try:
return use_case.execute(
CreateOrderCommand(
order_id=payload.order_id,
customer_id=payload.customer_id,
amount_cents=payload.amount_cents,
)
)
except OrderAlreadyExists as exc:
raise HTTPException(
status_code=409,
detail=str(exc),
) from exc
@app.get(
"/orders/{order_id}",
response_model=OrderResponse,
)
def get_order(
order_id: str,
repository: Annotated[
PostgresOrderRepository,
Depends(get_repository),
],
):
use_case = GetOrder(repository)
try:
return use_case.execute(order_id)
except OrderNotFound as exc:
raise HTTPException(
status_code=404,
detail=str(exc),
) from exc
The endpoints are deliberately thin. They translate HTTP input into use-case commands and return application results through response schemas.
FastAPI's dependency system creates the concrete repository for each request. A unit test does not need this dependency mechanism because it can construct CreateOrder directly with a fake repository.
The synchronous database operations are used from synchronous FastAPI route functions. An application using async database drivers should adapt its port interfaces and execution model consistently rather than mixing synchronous and asynchronous operations arbitrarily.
Unit Testing Without a Database
One of the strongest benefits of the pattern is the ability to test application behavior without starting PostgreSQL, booting FastAPI, or patching SQLAlchemy internals.
The test adapter can implement the repository contract using an in-memory dictionary.
Create tests/test_create_order.py:
import pytest
from app.domain import Order
from app.use_cases import (
CreateOrder,
CreateOrderCommand,
GetOrder,
OrderAlreadyExists,
OrderNotFound,
)
class InMemoryOrderRepository:
def __init__(self) -> None:
self.orders: dict[str, Order] = {}
def create(self, order: Order) -> bool:
if order.order_id in self.orders:
return False
self.orders[order.order_id] = order
return True
def get_by_id(
self,
order_id: str,
) -> Order | None:
return self.orders.get(order_id)
def test_create_order():
repo = InMemoryOrderRepository()
service = CreateOrder(repo)
order = service.execute(
CreateOrderCommand(
order_id="order-1001",
customer_id="customer-42",
amount_cents=4999,
)
)
assert order.order_id == "order-1001"
assert order.amount_cents == 4999
assert repo.get_by_id(order.order_id) == order
def test_duplicate_order_is_rejected():
repo = InMemoryOrderRepository()
service = CreateOrder(repo)
command = CreateOrderCommand(
order_id="order-1001",
customer_id="customer-42",
amount_cents=4999,
)
service.execute(command)
with pytest.raises(OrderAlreadyExists):
service.execute(command)
def test_invalid_amount_is_rejected():
repo = InMemoryOrderRepository()
service = CreateOrder(repo)
with pytest.raises(ValueError):
service.execute(
CreateOrderCommand(
order_id="order-1002",
customer_id="customer-42",
amount_cents=0,
)
)
assert repo.orders == {}
def test_get_missing_order():
repo = InMemoryOrderRepository()
with pytest.raises(OrderNotFound):
GetOrder(repo).execute("missing")
def test_get_existing_order():
repo = InMemoryOrderRepository()
created = CreateOrder(repo).execute(
CreateOrderCommand(
order_id="order-1003",
customer_id="customer-42",
amount_cents=1250,
)
)
loaded = GetOrder(repo).execute(
created.order_id
)
assert loaded == created
These tests exercise the actual domain and use-case logic. They do not mock the use case itself or depend on an HTTP test client.
This separation also makes tests fast. Most domain tests can run in milliseconds because they avoid external resources and network requests.
However, in-memory tests cannot prove the PostgreSQL adapter is correct. Production projects should supplement them with integration tests against a real PostgreSQL instance to verify SQL behavior, schema constraints, migrations, and transaction handling.
Run and Test the Application
For local persistence, use Docker Compose to start PostgreSQL. The API itself runs on your host machine through Uvicorn.
Create docker-compose.yml:
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: orders
POSTGRES_USER: app
POSTGRES_PASSWORD: app
ports:
- "127.0.0.1:5432:5432"
healthcheck:
test:
- CMD-SHELL
- pg_isready -U app -d orders
interval: 5s
timeout: 3s
retries: 10
The database credentials are development-only values. Do not reuse them in production or expose the database port publicly.
Run the following commands:
# Start local PostgreSQL.
docker compose up -d --wait
# Create a virtual environment.
python3 -m venv .venv
source .venv/bin/activate
# Install dependencies.
pip install -r requirements.txt
# Run application tests without PostgreSQL.
pytest -q
# Configure the application database.
export DATABASE_URL="postgresql+psycopg://app:app@localhost:5432/orders"
# Start the FastAPI application.
uvicorn app.api:app --reload
# In another terminal:
# Create an order.
curl -i -X POST \
http://127.0.0.1:8000/orders \
-H "Content-Type: application/json" \
-d '{
"order_id": "order-1001",
"customer_id": "customer-42",
"amount_cents": 4999
}'
# Retrieve the order.
curl -i \
http://127.0.0.1:8000/orders/order-1001
# Repeat the create request to observe 409.
curl -i -X POST \
http://127.0.0.1:8000/orders \
-H "Content-Type: application/json" \
-d '{
"order_id": "order-1001",
"customer_id": "customer-42",
"amount_cents": 4999
}'
# When finished, stop and remove PostgreSQL.
docker compose down
The initial create request should return 201 Created with the order data. Retrieving the order should return 200 OK. Repeating the create operation with the same ID should return 409 Conflict.
The unit tests can run without PostgreSQL. The HTTP endpoints require the configured database and the PostgreSQL adapter.
If you are using Docker for the first time or want a deeper explanation of the networking and lifecycle behavior, see What Is Docker?.
Hexagonal vs. Layered, Onion, and Clean Architecture
Hexagonal Architecture is closely related to Onion Architecture and Clean Architecture. All three encourage separating business rules from infrastructure details and making dependency boundaries explicit.
The differences primarily concern how the architecture is described and which boundaries receive the most emphasis.
Key Differences and Similarities
The following comparison summarizes the typical emphasis of each approach.
| Architecture | Primary Emphasis | Typical Organization |
|---|---|---|
| Traditional Layered | Separation by technical responsibility | Controllers, services, repositories, database |
| Hexagonal | Ports, adapters, and inside-outside boundaries | Application core with driving and driven adapters |
| Onion | Domain-centric layers and inward dependencies | Domain model surrounded by application and infrastructure layers |
| Clean | Business rules, use cases, and explicit boundary dependencies | Entities, use cases, interface adapters, frameworks |
These architectures are not mutually exclusive. A Clean Architecture implementation may use ports and adapters, and an Onion Architecture application may expose interfaces equivalent to hexagonal ports.
Traditional layered architecture can also use dependency inversion correctly. The problem is not the presence of layers, but whether business logic becomes dependent on outer implementation details.
Do not rewrite an otherwise well-structured application just because a different architecture label has become popular.
When to Use Hexagonal Architecture
Hexagonal Architecture is particularly valuable when an application has important business rules, multiple delivery mechanisms, significant external dependencies, or a long expected maintenance lifetime.
Typical use cases include:
- Payment and order management systems.
- Applications integrating several external APIs.
- Microservices that may change database or messaging technology.
- Applications supporting HTTP, CLI, and message-driven entry points.
- Systems requiring fast, isolated business-rule tests.
- Long-lived monoliths with complex domain behavior.
It may be unnecessary for a small CRUD application with minimal business logic and no meaningful integration complexity. Creating multiple abstractions for simple forwarding code can increase maintenance work without providing measurable value.
A useful heuristic is to introduce ports around dependencies whose behavior matters to the application and whose implementation is external to its business rules.
Production Practices and Common Mistakes
Hexagonal Architecture helps organize code, but it does not automatically create a reliable distributed system. Transactions, schema migrations, retries, observability, and API compatibility still require deliberate engineering decisions.
Its real value comes from keeping those responsibilities at appropriate boundaries while preserving testable application behavior.
Common Implementation Mistakes
Frequent mistakes include:
- Creating interfaces for every class: Adds indirection without meaningful boundaries.
- Returning ORM models from ports: Leaks persistence technology into domain and application code.
- Throwing HTTP exceptions inside use cases: Couples business behavior to the web framework.
- Calling a payment SDK directly from domain entities: Makes business rules dependent on infrastructure.
- Building generic repositories too early: Exposes unnecessary operations instead of application-focused contracts.
- Hiding transaction boundaries: Several repository operations can commit separately when one atomic operation is required.
- Assuming replacing a database is trivial: Storage semantics may differ even when an interface is shared.
- Testing only with mocks: Infrastructure adapters remain unverified.
- Leaking framework schemas into the core: Domain behavior becomes coupled to serialization and validation frameworks.
- Confusing module separation with distributed deployment: Hexagonal boundaries do not require a microservices architecture.
- Ignoring dual-write failures: Database updates and external event publication remain inconsistent during partial failures.
Pay special attention to adapter error translation. A use case should receive meaningful domain or application outcomes rather than depending on raw database driver exceptions.
Similarly, avoid infrastructure abstractions that erase important semantics. A repository interface should not pretend every database provides identical transaction, query, or consistency guarantees.
Production Checklist
Before deploying a hexagonal application into production, verify that:
- Domain code does not import FastAPI, SQLAlchemy, or third-party SDKs.
- Use cases express meaningful application behavior.
- Ports represent application-required capabilities rather than arbitrary framework interfaces.
- Driving adapters translate input into application commands and results into transport responses.
- Driven adapters contain persistence, messaging, and external API details.
- The composition root explicitly wires dependencies.
- Transactions are coordinated at the appropriate business-operation boundary.
- External operations have timeout, retry, and idempotency strategies.
- Database changes are managed through versioned migrations.
- Domain tests run without real infrastructure.
- Integration tests verify production adapters against realistic dependencies.
- Important failure modes are captured in structured logs and metrics.
- Outbox or workflow patterns are used when required for multi-system consistency.
- Infrastructure abstractions are reviewed for unnecessary complexity.
Frequently Asked Questions
Hexagonal Architecture is sometimes mistaken for a strict folder structure, a requirement to write interfaces for every dependency, or a specialized architecture for microservices. Its purpose is broader and simpler: keeping application behavior independent of its delivery and infrastructure mechanisms.
What Are Ports and Adapters?
Ports are defined communication contracts at the application boundary. Adapters connect those contracts to technologies such as HTTP, databases, message brokers, or external APIs.
A repository interface can be a port, while a PostgreSQL repository class is an adapter implementing that contract.
Why Is It Called Hexagonal Architecture?
The hexagon is a visual metaphor used by Alistair Cockburn to emphasize multiple connection points and the distinction between the application core and external systems.
The pattern does not require exactly six ports, adapters, or layers.
Is Hexagonal Architecture Only for Microservices?
No. Hexagonal Architecture can be used in monoliths, modular monoliths, microservices, command-line applications, and other software systems.
It concerns the internal organization and dependencies of application code, not the number of deployed services.
Is the Repository Pattern Required?
No. A repository is a common example of an outbound port, but the architecture does not mandate a particular repository pattern.
Ports should represent the external capabilities the application needs. Those capabilities might involve storage, messaging, external APIs, payment systems, or other integrations.
Does Hexagonal Architecture Add Complexity?
It can introduce more interfaces, adapters, and composition code than a small CRUD application needs.
That overhead is justified when independent testing, multiple integrations, changing infrastructure, or complex business rules make the separation valuable. For simple systems, fewer abstractions may be the better design.
Is Hexagonal Architecture the Same as Clean Architecture?
They are closely related but not identical descriptions. Hexagonal Architecture emphasizes ports and adapters at the application boundary, while Clean Architecture describes concentric boundaries around entities, use cases, interface adapters, and frameworks.
Both favor protecting business logic from infrastructure dependencies, and their implementation techniques often overlap.
Conclusion
Hexagonal Architecture provides a way to structure software around application behavior rather than external frameworks and infrastructure. Its ports define the capabilities the application exposes and requires, while adapters connect those capabilities to HTTP interfaces, databases, message brokers, and third-party services.
The practical benefit is not merely being able to replace PostgreSQL with another database. It is the ability to keep business rules readable, test use cases independently, reuse operations across different entry points, and contain technology-specific changes within defined boundaries.
However, the architecture should not become an excuse for unnecessary interfaces or excessive abstraction. A useful hexagonal design starts with meaningful domain behavior, defines only the required boundaries, and treats transactions, error handling, and distributed consistency as explicit production concerns.
Key Takeaway: Design the application core around business rules and use cases. Let external technologies adapt to the application's contracts rather than forcing the application to depend directly on databases, frameworks, and SDKs.
More Articles to Read
For related implementation details, explore these additional SysDesPro articles:
- What Is Redis? — Explore an infrastructure component that can sit behind a cache adapter.
- What Is AWS Lambda? — Understand an event-driven runtime that can provide a driving adapter.
- What Is Infrastructure as Code? — Learn how to provision and operate the infrastructure surrounding application boundaries.
Comments (0)