Hexagonal Architecture in the Real World: Trade-offs, Pitfalls, and When Not to Use It
You added hexagonal architecture to your CRUD app. Now changing a field name requires touching 6 files. Congratulations you’ve optimized for the wrong problem.
Every pattern has a failure mode. Hexagonal architecture’s is using it for everything.
This series taught the pattern in good faith: clean domain models, proper ports, testable adapters, and a composition root that wires it all together. Now let’s do the honest part. When does this structure pay off? When is it expensive overhead that slows you down? And what are the traps that catch even experienced engineers?
The Real Costs
Hexagonal architecture is not free. Before weighing whether to use it, you need to know what you’re actually paying.
The Upfront Complexity Tax
You’re writing a new feature. In a flat codebase, you add a field to a model, update the query, ship. In a hexagonal one, adding promo_code to Order means updating the domain model, the port interface, the SQLAlchemy adapter, the in-memory test adapter, and probably two or three test fixtures. Here’s what that cascade looks like:
# 1. Domain model — domain/models.py
@dataclass
class Order:
customer_id: str
items: list[OrderItem]
discount: Discount
promo_code: str | None = None # new field
status: str = "draft"
id: str | None = None
# 2. Port — domain/ports.py
class OrderRepository(Protocol):
def save(self, order: Order) -> Order: ...
def find_by_promo_code(self, code: str) -> list[Order]: ... # new method
# 3. SQLAlchemy adapter
class SQLAlchemyOrderRepository:
def find_by_promo_code(self, code: str) -> list[Order]:
rows = self.session.query(OrderRow).filter_by(promo_code=code).all()
return [self._to_domain(row) for row in rows]
# 4. In-memory adapter
class InMemoryOrderRepository:
def find_by_promo_code(self, code: str) -> list[Order]:
return [o for o in self.saved if o.promo_code == code]
# 5. ORM table definition — orm.py
orders_table = Table("orders", metadata,
Column("promo_code", String, nullable=True),
...
)Five files for one field. In a small FastAPI app with a Pydantic model and a SQLAlchemy model, that’s two files. When you’re moving fast or still discovering the domain, this multiplier is painful.
The Indirection Overhead
Junior engineers joining the codebase struggle to follow the execution path. A request comes in at api/orders.py, calls OrderService, which calls a CustomerRepository Protocol and where does that actually go? You have to know about dependencies.py and the composition root to understand what runs. Debugging a broken save operation means tracing through three layers before you find the SQLAlchemy stack trace.
This is a real onboarding cost. Budget for it.
Port Interface Rigidity
Ports are stable by design that’s the point. But early in a project, requirements change constantly. Every time a port changes, every adapter implementing it must change too. When your domain isn’t stable yet, that multiplies churn from exploratory work. You end up refactoring interfaces more than refactoring implementations.
When It’s Overkill
Here’s the honest version: hexagonal architecture is the wrong choice for a lot of real projects.
Signal 1: No Meaningful Business Logic
If your domain model is just dataclasses with no methods data in, data out, nothing in between there’s no domain worth protecting:
@dataclass
class BlogPost:
title: str
body: str
author_id: str
published_at: datetime | None = None
id: str | None = NoneNo behavior. No rules. No invariants. You’ve drawn a clean fence around an empty lot. Most internal tooling, admin dashboards, content management systems, and data pipelines live here. Hexagonal architecture would be ceremony without substance.
Signal 2: A Single Adapter You’ll Never Swap
# You have this:
class UserRepository(Protocol):
def find_by_id(self, user_id: str) -> User | None: ...
# Implemented only by:
class PostgreSQLUserRepository:
... # and this will never be replacedYou’ve paid the interface cost for flexibility you’ll never use. You could write the SQLAlchemy call directly in the service and be done in half the time. The “but we might swap it later” argument is real but weak if the chance is low, optimize for the present.
Signal 3: An Early-Stage Project Where the Domain Is Still Being Discovered
Hexagonal architecture rewards stable domains. It punishes exploration. When you’re still figuring out whether Order should have line_items or items, whether discounts live on the customer or the order adding ports and adapters before the domain settles means you’ll refactor interfaces constantly.
“Write it flat, refactor when the boundaries are clear” is a legitimate strategy. The ports will emerge naturally when you feel the second adapter appearing or when testing becomes painful without them.
Signal 4: A Performance-Critical Hot Path
The port abstraction adds function calls and interface dispatch. In tight loops or data processing pipelines where Python-layer performance matters, the indirection accumulates. Direct calls are faster.
Common Pitfalls (Even When You’re Doing It Right)
Over-Porting
Creating a port for every external dependency, including things that will never have a second implementation. The test: will there ever be a second adapter? If the answer is no and in-memory test doubles don’t count as a real second adapter you’re creating ceremony, not flexibility.
Leaky Ports
A port should speak the language of the domain, not the language of infrastructure:
# Bad: infrastructure leaking into the port
class OrderRepository(Protocol):
def find_orders(self, filters: dict, limit: int, offset: int) -> tuple[list[Order], int]: ...
def execute_raw(self, query: str, params: dict) -> list[dict]: ...
# Good: domain language only
class OrderRepository(Protocol):
def save(self, order: Order) -> Order: ...
def find_pending(self) -> list[Order]: ...
def find_by_customer(self, customer_id: str) -> list[Order]: ...The bad version describes how a database works. The good version describes what the domain needs. If you can read the method name and understand the business intent without knowing anything about databases, you’re in the right place.
The Anemic Service Trap
If your domain model is rich, the service should be thin. The opposite failure is common: a service that does all the work, and domain objects that are still just data containers.
# Service doing too much — domain logic leaked up
class OrderService:
def place_order(self, customer_id: str, items: list[OrderItem]) -> Order:
customer = self.customer_repo.find_by_id(customer_id)
raw_total = sum(item.price.amount * item.quantity for item in items)
if customer.loyalty_points > 500:
raw_total *= 0.9
order = Order(customer_id=customer_id, items=items, discount=Discount(0.0))
order.status = "pending"
return self.order_repo.save(order)The discount calculation belongs on Customer.discount(). The status transition belongs on Order.place(). When you see arithmetic in the service, a rule has leaked out of the domain.
Testing the Wrong Things
Hexagonal architecture makes domain logic easy to test in isolation. But over-investing in unit tests and under-investing in integration tests produces a domain with 100% coverage that silently breaks because the SQLAlchemy adapter isn’t mapping a new field correctly.
The right portfolio: thorough unit tests for domain logic; a smaller set of integration tests verifying the full adapter stack (real database, real queries); and a handful of end-to-end API tests covering the happy path and key error cases.
When Hexagonal Architecture Actually Pays Off
Long-lived systems with real business rules. Order management, billing, insurance, financial calculations. These systems have genuine domain behavior. The longer the codebase lives, the more the clean boundary pays back.
Systems with multiple infrastructure targets. When you actually need multiple adapters a PaymentGateway port backed by StripeAdapter, BraintreeAdapter, and InMemoryPaymentAdapter for tests the pattern earns its keep. The service never changes when you add a new processor. That’s the pattern working as designed.
Teams doing Domain-Driven Design seriously. When the team has a shared ubiquitous language and domain objects reflect real business concepts, ports emerge naturally. Without DDD discipline, hexagonal architecture tends to produce arbitrary layering.
Codebases with a 2+ year horizon. The upfront cost is amortized over time. A codebase maintained for years pays the setup cost once and benefits across every feature after that.
The Decision Framework
Use hexagonal architecture if:
✓ Your domain has real behavior — methods, rules, state transitions
✓ You need (or will need) multiple adapters for the same port
✓ The codebase will be actively maintained for 2+ years
✓ The team understands DDD and will maintain the vocabulary
✓ You need fast, isolated domain tests without infrastructure
Skip it (or defer it) if:
✗ You're building CRUD — data in, data out, minimal logic
✗ The domain is still being discovered (early startup, first few sprints)
✗ Every port has exactly one adapter and no second is planned
✗ You're wrapping a framework with strong data layer opinions (Django ORM)
✗ The team is small and junior — onboarding time matters more than purity
✗ This is a prototype or MVP with a likely rewrite horizonThe gradient approach is often the right one: start without hexagonal architecture. Build flat. Extract a port when you feel the second adapter appearing. Extract another when testing becomes painful. The full structure emerges when it’s earned, not when it’s assumed.
What to Do With Your Existing Codebase
Don’t rewrite everything. That’s how big-bang refactors die.
Pick one service the one with the most business logic, the hardest tests to write, the most infrastructure coupled into the domain. Extract that one first. Build the port. Write the adapter. Move the domain logic off the service methods and onto the domain objects.
If the result is cleaner and faster to test, you’ve found the right place to keep going. If it’s not improving anything, that’s the signal to stop not to push harder.
The Series in One Paragraph
Post 1 named the problem: domain logic fused with infrastructure. Post 2 named the pattern: ports and adapters. Post 3 built a domain worth protecting: value objects, rich entities, behavior on the objects that own it. Post 4 wired it all together: composition root, FastAPI dependency injection, adapter swapping in tests. This post gave you the honest accounting: real costs, real failure modes, a framework for deciding when to use it.
The meta-lesson is the same one that applies to every architecture pattern: it’s a tool for a specific problem. The engineers who get the most out of hexagonal architecture are the ones who understand its costs as clearly as its benefits and who reach for it precisely when it’s the right tool, not reflexively when they want “clean architecture.”
Pick one place in your current codebase. Apply the framework. That’s worth more than any number of rewrites.
