← Back to the section

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-linter is the only tool that automatically holds the boundaries.
  • A structure without an import-linter contract 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.
  • 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 Protocol in 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.