Architecture Patterns

Hexagonal and Clean Architecture: Ports, Adapters, and Dependency Inversion

Learn how hexagonal, onion, and clean architecture decouple business logic from frameworks and infrastructure using ports, adapters, use cases, and dependency inversion.

hexagonalclean architectureports and adaptersdependency inversionarchitecture

Why These Architectures?

Frameworks, databases, queues, and UI layers change. Core business rules should change because the business changes, not because a web framework or database driver changed.

Hexagonal architecture, onion architecture, and clean architecture all push the same principle: protect the domain and application logic from infrastructure details.

Key idea: Dependencies should point inward toward business rules. Infrastructure depends on the core; the core does not depend on infrastructure.


The Core Problem

In many applications, business logic is tangled with controllers, ORM models, queue consumers, and external APIs.

This makes the system hard to test and hard to change. A simple business rule may require a database, HTTP request, framework boot, and third-party client just to run a unit test.


Hexagonal Architecture

Hexagonal architecture is also called ports and adapters. The application core defines ports. Adapters implement those ports for specific technologies.

Ports

A port is an interface that expresses what the core needs or offers.

Port TypeDirectionExample
Inbound portOutside calls into applicationPlaceOrderUseCase
Outbound portApplication calls outside dependencyPaymentGateway, OrderRepository

Adapters

An adapter connects a technology to a port.

Adapter TypeExample
Inbound adapterREST controller, GraphQL resolver, queue consumer
Outbound adapterPostgreSQL repository, Stripe client, SMTP email sender

Dependency Inversion

Dependency inversion means high-level policy should not depend on low-level details. Both should depend on abstractions.

The core owns the interface because the core decides what it needs. The adapter conforms to that interface.

ts
// Core layer
export interface PaymentGateway {
  authorizePayment(input: {
    orderId: string;
    amount: number;
    currency: string;
  }): Promise<{ authorizationId: string }>;
}

export class PlaceOrderUseCase {
  constructor(private readonly paymentGateway: PaymentGateway) {}

  async execute(command: PlaceOrderCommand) {
    const authorization = await this.paymentGateway.authorizePayment({
      orderId: command.orderId,
      amount: command.totalAmount,
      currency: command.currency,
    });

    return authorization.authorizationId;
  }
}
ts
// Infrastructure layer
export class StripePaymentGateway implements PaymentGateway {
  async authorizePayment(input: {
    orderId: string;
    amount: number;
    currency: string;
  }) {
    const response = await stripe.paymentIntents.create({
      amount: input.amount,
      currency: input.currency,
      metadata: { orderId: input.orderId },
    });

    return { authorizationId: response.id };
  }
}

Clean Architecture Layers

Clean architecture expresses the same dependency rule with concentric layers.

LayerOwns
EntitiesCore domain rules and invariants
Use casesApplication-specific workflows
Interface adaptersControllers, presenters, gateways
Frameworks and driversWeb, database, UI, external libraries

The rule is simple: source code dependencies point inward.


Onion Architecture

Onion architecture is similar, but often emphasizes the domain model at the center and infrastructure on the outside.

ArchitectureMain VocabularyShared Principle
HexagonalPorts and adaptersCore isolated from infrastructure
CleanEntities, use cases, interface adaptersDependencies point inward
OnionDomain center, outer infrastructureDomain does not depend on outer layers

The differences matter less than the dependency rule.


Example: Place Order

Responsibilities

ComponentResponsibility
ControllerParse HTTP request, call use case, map response
Use caseOrchestrate application workflow
Domain modelEnforce business invariants
Repository portExpress persistence need
Repository adapterImplement persistence with database
Payment portExpress payment capability
Payment adapterImplement payment with provider SDK

Testing Benefits

Because the core depends on interfaces, it can be tested with fakes.

ts
class FakePaymentGateway implements PaymentGateway {
  async authorizePayment() {
    return { authorizationId: "auth-test-1" };
  }
}

class InMemoryOrderRepository implements OrderRepository {
  private orders = new Map<string, Order>();

  async save(order: Order) {
    this.orders.set(order.id, order);
  }
}
Test TypeWhat It Should Cover
Domain unit testEntity and aggregate invariants
Use case testWorkflow rules using fake ports
Adapter integration testDatabase queries, provider clients, serialization
End-to-end testCritical happy paths across real wiring
💡

Do not mock the world: Test the core with fakes, and test adapters against real dependencies where practical.


Folder Structure

One possible structure:

txt
src/
  domain/
    order.ts
    money.ts
  application/
    ports/
      order-repository.ts
      payment-gateway.ts
    use-cases/
      place-order.ts
  infrastructure/
    database/
      postgres-order-repository.ts
    payments/
      stripe-payment-gateway.ts
  interfaces/
    http/
      order-controller.ts
    queue/
      order-command-consumer.ts

This is a starting point, not a law. The important part is dependency direction, not folder names.


Common Mistakes

MistakeProblemBetter Approach
Interfaces for everythingAdds ceremony without valueCreate ports at meaningful boundaries
Framework types in use casesCore becomes framework-coupledMap to plain commands and DTOs
ORM entities as domain modelDatabase constraints shape business logicSeparate persistence mapping when needed
Anemic use casesControllers keep business workflowMove workflow into application layer
Huge abstractions earlySlows simple CRUD workApply where change and testing justify it

When to Use It

SituationRecommendation
Complex business rulesStrong fit
Multiple delivery mechanismsStrong fit
Need fast unit testsStrong fit
Infrastructure likely to changeGood fit
Simple CRUD admin appKeep it lightweight
Prototype with short lifeAvoid heavy ceremony

Good Boundaries for Ports

Create ports around things that are slow, external, nondeterministic, or likely to vary:

  • Database access
  • Payment providers
  • Email and SMS
  • Search engines
  • Message brokers
  • Object storage
  • Clock and ID generation

What to Remember for Interviews

  1. Business logic belongs in the core: Frameworks and databases sit outside it.
  2. Ports are interfaces owned by the application: Adapters implement them.
  3. Dependencies point inward: Outer layers depend on inner layers, not the reverse.
  4. Testing improves because the core is isolated: Use fakes for ports and integration tests for adapters.
  5. Use pragmatically: The pattern is valuable when complexity, change, or testing pain justifies it.

Practice: Refactor a simple checkout flow into controllers, use cases, domain objects, ports, and adapters. Then replace the real payment adapter with a fake in a use case test.