ESC
Type to search guides, tutorials, and reference documentation.
Verified by Garnet Grid

Hexagonal Architecture

Design applications with Ports and Adapters for testability, flexibility, and independence from external systems. Covers domain isolation, port interfaces, adapter implementations, dependency inversion, and the patterns that make applications resilient to infrastructure changes.

Hexagonal Architecture (Ports and Adapters) isolates your business logic from external systems — databases, APIs, message queues, and UI frameworks. The core domain knows nothing about how data is stored or how users interact. This makes the domain testable without any infrastructure, swappable without rewriting business logic, and resilient to technology changes.


Architecture

Traditional Layered Architecture:
  Controller → Service → Repository → Database
  Problem: Domain logic depends on database technology
  Problem: Cannot test service without database
  Problem: Changing database requires rewriting service

Hexagonal Architecture:
                    ┌─────────────────────────────┐
     Driving       │                               │      Driven
     Adapters      │     ┌───────────────┐         │      Adapters
                   │     │               │         │
  ┌──────────┐    ┌┤     │   DOMAIN      │     ├┐    ┌──────────┐
  │ REST API ├────┤│     │   (Business   │     ├│────┤PostgreSQL│
  └──────────┘    ││ Port│   Logic)      │Port  ││    └──────────┘
  ┌──────────┐    ├┤     │               │     ├┤    ┌──────────┐
  │   CLI    ├────┤│     │   Pure.       │     ├│────┤  Redis   │
  └──────────┘    ││     │   No imports  │     ││    └──────────┘
  ┌──────────┐    ├┤     │   from infra. │     ├┤    ┌──────────┐
  │  gRPC    ├────┤│     │               │     ├│────┤ Stripe   │
  └──────────┘    └┤     └───────────────┘     ├┘    └──────────┘
                   │                               │
                   └─────────────────────────────┘

  Ports: Interfaces that the domain defines
  Adapters: Implementations that connect to real systems
  Domain: Pure business logic, no infrastructure imports

Implementation

# PORTS: Interfaces defined by the domain
# The domain says WHAT it needs, not HOW to get it

class OrderRepository(Protocol):
    """Port: How the domain stores orders."""
    def save(self, order: Order) -> None: ...
    def get(self, order_id: str) -> Order | None: ...
    def find_by_customer(self, customer_id: str) -> list[Order]: ...

class PaymentGateway(Protocol):
    """Port: How the domain processes payments."""
    def charge(self, amount: Decimal, customer_id: str) -> PaymentResult: ...
    def refund(self, payment_id: str) -> RefundResult: ...

class NotificationService(Protocol):
    """Port: How the domain sends notifications."""
    def send(self, recipient: str, message: str) -> None: ...


# DOMAIN: Pure business logic, depends only on ports
class OrderService:
    def __init__(
        self,
        orders: OrderRepository,      # Port, not PostgreSQL
        payments: PaymentGateway,      # Port, not Stripe
        notifications: NotificationService,  # Port, not SendGrid
    ):
        self.orders = orders
        self.payments = payments
        self.notifications = notifications
    
    def place_order(self, customer_id: str, items: list[OrderItem]) -> Order:
        order = Order.create(customer_id=customer_id, items=items)
        
        # Business rules — no infrastructure knowledge
        if order.total > Decimal("10000"):
            order.require_approval()
        
        payment = self.payments.charge(order.total, customer_id)
        if not payment.success:
            raise PaymentFailed(payment.error)
        
        order.mark_paid(payment.id)
        self.orders.save(order)
        self.notifications.send(customer_id, f"Order {order.id} confirmed")
        
        return order


# ADAPTERS: Real implementations of ports

class PostgresOrderRepository:
    """Adapter: PostgreSQL implementation of OrderRepository port."""
    def __init__(self, session: Session):
        self.session = session
    
    def save(self, order: Order) -> None:
        self.session.merge(OrderModel.from_domain(order))
        self.session.commit()
    
    def get(self, order_id: str) -> Order | None:
        model = self.session.query(OrderModel).filter_by(id=order_id).first()
        return model.to_domain() if model else None

class StripePaymentGateway:
    """Adapter: Stripe implementation of PaymentGateway port."""
    def charge(self, amount: Decimal, customer_id: str) -> PaymentResult:
        intent = stripe.PaymentIntent.create(
            amount=int(amount * 100),
            currency="usd",
            customer=customer_id,
        )
        return PaymentResult(success=True, id=intent.id)

Testing

# Test domain logic WITHOUT any infrastructure
class InMemoryOrderRepository:
    """Test adapter: In-memory implementation."""
    def __init__(self):
        self.orders = {}
    
    def save(self, order): self.orders[order.id] = order
    def get(self, order_id): return self.orders.get(order_id)

class FakePaymentGateway:
    """Test adapter: Always succeeds."""
    def charge(self, amount, customer_id):
        return PaymentResult(success=True, id="fake-payment-123")

def test_place_order():
    service = OrderService(
        orders=InMemoryOrderRepository(),
        payments=FakePaymentGateway(),
        notifications=FakeNotificationService(),
    )
    
    order = service.place_order("customer-1", [OrderItem("widget", 2, Decimal("25.00"))])
    
    assert order.total == Decimal("50.00")
    assert order.status == "paid"
    # No database, no Stripe, no HTTP — pure domain logic tested

Anti-Patterns

Anti-PatternConsequenceFix
Domain imports infrastructureDomain cannot be tested in isolationPorts as interfaces, inject adapters
Too many portsOver-abstraction, hard to navigateOne port per external concern, not per method
Adapters contain business logicLogic duplicated, hard to testAll business logic in domain
No dependency inversionCompile-time coupling to infrastructureDomain defines ports, adapters implement them
Skip for simple CRUDUnnecessary complexityHexagonal for complex domains, simple CRUD is fine without it

Hexagonal Architecture is about protecting your most valuable code — the domain logic — from the most volatile code — the infrastructure. When your database, payment provider, or notification service changes, only the adapters change. The domain remains untouched.

Jakub Dimitri Rezayev
Jakub Dimitri Rezayev
Founder & Chief Architect • Garnet Grid Consulting

Jakub holds an M.S. in Customer Intelligence & Analytics and a B.S. in Finance & Computer Science from Pace University. With deep expertise spanning D365 F&O, Azure, Power BI, and AI/ML systems, he architects enterprise solutions that bridge legacy systems and modern technology — and has led multi-million dollar ERP implementations for Fortune 500 supply chains.

View Full Profile →