Hexagonal Architecture (aka "ports and adapters") is a useful tool, but not for every service. In this article we'll look at when it really pays off and when it only adds extra code.
The problem Hexagonal solves
Imagine a FastAPI service. At first it's small: routers, a bit of SQLAlchemy, a couple of HTTP requests to external systems. Everything is in one file or a few flat modules. Developers understand the structure, tests run fast.
A few months later, integrations are added: a payment gateway, a message queue, an external notification service. The business logic grows more complex — confirmation rules, invariants, events appear. And then, in a single handler you end up with: a SQLAlchemy query, a call to a payment API, a business check and building the response — all mixed together.
To test a business rule, you have to spin up a database and mock HTTP. To change the storage, you have to rewrite half the handlers. A new developer can't find where the "real" logic lives.
Hexagonal Architecture is a way to organize the code so that the business logic is separated from everything external: HTTP, DB, queues. The boundary is called a "port", the concrete implementation beyond the boundary is an "adapter".
core/order/ ← pure Python, no FastAPI or SQLAlchemy
aggregate.py ← business rules
port/ ← interfaces (Protocol) to external systems
adapters/in/http/ ← FastAPI: accept the request, call the use case
adapters/out/pg/ ← SQLAlchemy: implementation of the database port
app/ ← assembly: bind ports to adapters, start up
But there's a price to pay: extra packages, mappers between layers, explicit "wiring" of dependencies. That's why it's important to understand when it's justified.
When Hexagonal really helps
Several external systems with different logic
If the service works only with a single database — Hexagonal isn't needed. But when a payment gateway, an event queue, an SMS service are added — each system brings its own formats, its own error-retry rules, its own quirks.
Without a clear boundary, all of this settles in the handler:
# Without Hexagonal: the handler knows about Sber, SQLAlchemy and Kafka all at once
class ConfirmOrderHandler:
async def handle(self, cmd: ConfirmOrderCommand) -> None:
async with self.session.begin():
order = await self.session.get(OrderOrmModel, cmd.order_id)
sber_resp = await self.http.post(
"https://securepayments.sberbank.ru/payment/rest/register.do",
json={"amount": order.total_kopecks, "orderNumber": str(order.id)},
)
if sber_resp.json()["errorCode"] != "0":
raise ValueError(sber_resp.json()["errorMessage"])
order.status = "confirmed"
With ports, each external system is hidden behind an interface. The test checks the business logic, substituting a simple stub for real HTTP:
# core/order/port/out/payment_port.py
class PaymentPort(Protocol):
async def register_payment(self, order: Order) -> PaymentRef: ...
# adapters/out/sber/sber_payment_adapter.py
class SberPaymentAdapter:
async def register_payment(self, order: Order) -> PaymentRef:
req = SberMapper.to_register_request(order)
resp = await self._client.post("/payment/rest/register.do", json=req)
return SberMapper.to_payment_ref(resp.json())
A complex domain with business rules
If an order has rules ("you can't confirm an empty order", "you can't confirm twice"), it's worth extracting them into a pure Python object and testing them without spinning up infrastructure:
# core/order/aggregate.py — stdlib only, no external dependencies
@dataclass
class Order:
id: OrderId
items: list[OrderItem]
status: OrderStatus
events: list[DomainEvent] = field(default_factory=list)
def confirm(self) -> None:
if self.status != OrderStatus.PENDING:
raise OrderAlreadyConfirmedError(self.id)
if not self.items:
raise EmptyOrderError(self.id)
self.status = OrderStatus.CONFIRMED
self.events.append(OrderConfirmed(order_id=self.id))
The test runs in milliseconds, without a database and FastAPI:
def test_confirm_empty_order_raises():
order = Order(id=OrderId.generate(), items=[], status=OrderStatus.PENDING)
with pytest.raises(EmptyOrderError):
order.confirm()
If, however, the "logic" is just customer.update_email(new_email) with no checks, Hexagonal gives you nothing.
Several entry points
HTTP for clients, HTTP for operators with different permissions, a Kafka consumer, periodic tasks — each entry point has its own authorization rules. Hexagonal lets you cleanly separate them:
adapters/in/http/customer/ # JWT, public endpoints
adapters/in/http/operator/ # service-to-service
adapters/in/kafka/ # consumer, no HTTP authorization
adapters/in/cron/ # periodic tasks
The import-linter tool in CI won't let you accidentally mix the logic of the client and operator adapters.
Tests require spinning up the whole application
The symptom: to check a single business rule, you need TestClient(app) with a test database, because the handler directly imports SQLAlchemy. Hexagonal removes this dependency: core/ doesn't know about SQLAlchemy, the test works with pure Python objects.
The team grows
For two developers, boundaries hold by word of mouth. When the team gets bigger, import-linter in CI automatically catches violations — for example, an accidental from sqlalchemy.orm import Session in an aggregate file:
# pyproject.toml
[[tool.importlinter.contracts]]
name = "core-must-not-import-infrastructure"
type = "forbidden"
source_modules = ["order_service.core"]
forbidden_modules = ["fastapi", "sqlalchemy", "pydantic", "httpx"]
When Hexagonal is overkill
Simple CRUD with a single database
A product catalog: get the list, update the price. The only external dependency is PostgreSQL. Here the Repository pattern in a flat structure fully does the job:
class ProductRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def find_by_id(self, product_id: ProductId) -> Product | None:
row = await self._session.get(ProductOrmModel, product_id.value)
return ProductMapper.to_domain(row) if row else None
Adding a port Protocol, a separate adapter package and configuring import-linter for the sake of a single database is complexity without benefit.
A small service or a small team
If a service contains fewer than 10 thousand lines of code and one or two developers work on it, the architectural rules hold more simply — through agreements and code review. Tooling-based boundary control adds overhead where it isn't needed.
The logic hasn't settled yet
Early in a product, "Customer" may become "Account", "Order" — "Contract". Hexagonal, with its mappers and Protocol interfaces, slows down iteration: every "let's try it differently" turns into rewriting several mappers and ports. First you should find a stable form of the domain — then impose a rigid structure.
Two common mistakes during adoption
Hexagonal everywhere, regardless of complexity. If all of the team's services are forced into one structure regardless of the actual needs, that's a sign the architecture is applied as a rule rather than as a tool. A service of three endpoints with no complex domain in the full hex layout is about 200 lines of extra code for the sake of isolation that protects nothing.
The decision is made per service. A simple CRUD service and a complex service with ports can live side by side — that's fine.
A partial structure without a real boundary. The core/ and adapters/ folders are created, but the routers still contain business logic, and SQLAlchemy models are used inside core/:
# Partial structure: the folders exist, the boundary doesn't hold
@router.post("/orders/{order_id}/confirm")
async def confirm_order(order_id: UUID, session: AsyncSession = Depends(get_session)):
order_row = await session.get(OrderOrmModel, order_id) # SQLAlchemy in the router
if order_row.total > 50_000: # business logic in the router
raise HTTPException(status_code=400, detail="Limit exceeded")
order_row.status = "confirmed"
This is worse than an honest flat structure: import-linter doesn't catch violations, the tests still require spinning up HTTP and a DB, and a developer reading the code doesn't know where the boundary actually runs.
The rule is simple: either full Hexagonal with an import-linter contract in CI and mappers between layers, or a flat structure with no pretense of separation. An intermediate state is acceptable only with an explicit deadline for finishing the transition.
In short
- Hexagonal Architecture separates the business logic from HTTP, the database and external services through explicit interfaces ("ports") and their implementations ("adapters").
- It's worth applying when there are several external systems, a complex domain with business rules, several entry points with different authorization rules, or a team of three or more developers.
- Don't apply it when the service is simple CRUD with a single database, the team is small, the logic is still changing.
- Python has no compile-time module isolation:
import-linteris the only tool that automatically holds the boundaries. - A structure without an
import-lintercontract in CI isn't Hexagonal, it's just a set of folders. - Cargo cult (hex everywhere) and a partial structure (the folders exist, the boundary doesn't hold) — both situations are worse than an honest simple structure.
What to read next
- Module structure — how to organize the
core/adapters/app/packages. - Core layer — what goes into
core/and which dependencies are acceptable. - Ports — how to describe outbound ports through a
Protocolin Python. - Adapters in — the FastAPI router: mapping a Pydantic DTO into a use case command.
- Adapters out — implementing a port Protocol, mapping the domain and the external system's DTO.