How to Implement Hexagonal Architecture: Complete Guide

How to Implement Hexagonal Architecture: Complete Guide

Profile-Image
Bright SEO Tools in saas Published: Apr 04, 2026 | Updated: Apr 04, 2026 · 2 months ago
0:00

How to Implement Hexagonal Architecture

Hexagonal architecture implementations fail when developers focus on folder structure instead of dependency direction. You end up with a ports-and-adapters directory layout that still has business logic calling database libraries directly, domain services importing HTTP frameworks, and tests that require spinning up the entire application stack because everything is tightly coupled to infrastructure. The architecture diagram looks clean but the dependency graph tells a different story.

This article shows how to implement hexagonal architecture (also called ports and adapters) in a way that delivers its core promise: business logic that's completely independent of frameworks, databases, and external services. You'll learn how to structure domain logic so it has no infrastructure dependencies, define ports as interfaces owned by the domain, implement adapters that translate between domain and infrastructure, and organize tests that prove your business logic works without any external systems. These patterns come from production implementations handling millions of requests daily.

We'll cover the fundamental concepts (hexagon, ports, adapters), implementation strategies in TypeScript and JavaScript, testing approaches that validate architectural boundaries, and the specific trade-offs between architectural purity and pragmatic development velocity.

Why Hexagonal Architecture Solves Real Problems

Hexagonal architecture addresses a specific failure mode in traditional layered architectures: business logic becomes coupled to infrastructure details. In a typical layered architecture (presentation, business, data), the business layer depends on the data layer. This means your business logic imports database libraries, constructs SQL queries, and knows about ORM specifics. Testing business logic requires a database. Switching from Postgres to MongoDB requires changing business logic code.

The key insight: dependencies should point inward toward domain logic, not outward toward infrastructure. Business logic should define what it needs (a port interface), and infrastructure should adapt to provide it (an adapter implementation). This inverts the traditional dependency direction and creates the isolation hexagonal architecture promises.

Consider a payment processing use case. In traditional layered architecture, your business logic imports a payment gateway SDK directly. In hexagonal architecture, business logic defines a PaymentProcessor interface (port) and knows nothing about Stripe, PayPal, or any specific gateway. The Stripe adapter implements PaymentProcessor by translating domain operations to Stripe API calls. Business logic depends only on the interface it defined.

Key Insight: The "hexagonal" part is mostly metaphorical—ports are distributed around the hexagon's edges rather than stacked in layers. What matters is dependency inversion: infrastructure depends on domain interfaces, never the reverse. You can implement this without literal hexagonal diagrams.

The Real Cost of Infrastructure Coupling

When business logic depends on infrastructure, testing becomes slow and brittle. Every test needs database connections, API mocks, or framework initialization. You can't test order validation logic without a database because the validation code imports the ORM. You can't test payment processing logic without HTTP mocks because it imports the payment gateway SDK.

This compounds over time. As the codebase grows, test suites slow down because every test pays infrastructure setup costs. CI pipelines take longer. Local development becomes frustrating because you need Docker Compose running six services to run tests. Developers stop writing tests because the friction is too high.

Hexagonal architecture solves this by making business logic testable with pure unit tests. No database, no HTTP, no frameworks. You instantiate domain objects, call methods, and assert outcomes. Tests run in milliseconds instead of seconds. The fast feedback loop makes development more productive and test coverage more comprehensive.

Core Concepts: Hexagon, Ports, and Adapters

The hexagon represents your application's core domain logic—the business rules and use cases that define what your application does. This code has no dependencies on external libraries, frameworks, or infrastructure. It only depends on interfaces it defines.

Ports are interfaces that define how the hexagon interacts with the outside world. There are two types: inbound ports (how external actors invoke the hexagon, like HTTP handlers or message consumers) and outbound ports (how the hexagon accesses external resources, like databases or APIs). The key rule: ports are owned by the domain, not by infrastructure.

Adapters implement ports. An HTTP adapter implements an inbound port by translating HTTP requests to domain operations. A Postgres adapter implements an outbound port by translating domain operations to SQL queries. Adapters live in infrastructure and depend on both the domain (to implement its ports) and external libraries (to perform I/O).

Dependency Direction

The critical architectural constraint: all dependencies point inward toward the hexagon. Infrastructure depends on domain. Domain depends on nothing except standard library and domain-defined interfaces.

// Traditional layered architecture (dependencies point outward)
class OrderService {  // Business layer
  constructor(private db: PostgresConnection) {}  // Depends on infrastructure

  async createOrder(items: Item[]): Promise {
    const order = await this.db.query(
      'INSERT INTO orders ...'  // Business logic knows SQL
    );
  }
}

// Hexagonal architecture (dependencies point inward)
// Domain defines what it needs (port)
interface OrderRepository {  // Port owned by domain
  save(order: Order): Promise;
  find(id: OrderId): Promise;
}

class OrderService {  // Domain service
  constructor(private orders: OrderRepository) {}  // Depends on domain interface

  async createOrder(items: Item[]): Promise {
    const order = new Order(items);
    await this.orders.save(order);  // Uses port, doesn't know about SQL
    return order;
  }
}

// Adapter implements port (in infrastructure layer)
class PostgresOrderRepository implements OrderRepository {
  constructor(private db: PostgresConnection) {}  // Depends on external lib

  async save(order: Order): Promise {
    await this.db.query(
      'INSERT INTO orders ...',
      this.toRow(order)  // Translates domain object to database row
    );
  }
}

Now OrderService has no infrastructure dependencies. It depends on OrderRepository (an interface), which means you can test it with a fake repository that stores orders in memory. The real database logic lives in PostgresOrderRepository, which is only loaded in production. Tests use a different adapter entirely.

Implementing Inbound Ports (Driving Side)

Inbound ports define use cases—the operations external actors can perform. In many implementations, inbound ports are simply application service methods. An HTTP adapter receives a request, maps it to a use case call, and maps the result back to an HTTP response.

Defining Use Cases

// Inbound port: use case interface
interface PlaceOrderUseCase {
  execute(request: PlaceOrderRequest): Promise;
}

interface PlaceOrderRequest {
  customerId: string;
  items: Array<{
    productId: string;
    quantity: number;
  }>;
}

interface PlaceOrderResponse {
  orderId: string;
  total: number;
  status: string;
}

// Implementation in domain layer
class PlaceOrderService implements PlaceOrderUseCase {
  constructor(
    private orders: OrderRepository,  // Outbound port
    private customers: CustomerRepository,  // Outbound port
    private events: EventPublisher  // Outbound port
  ) {}

  async execute(request: PlaceOrderRequest): Promise {
    // Pure domain logic - no infrastructure
    const customer = await this.customers.find(
      new CustomerId(request.customerId)
    );

    if (!customer) {
      throw new CustomerNotFoundError(request.customerId);
    }

    const items = request.items.map(item =>
      new OrderItem(
        new ProductId(item.productId),
        new Quantity(item.quantity)
      )
    );

    const order = customer.placeOrder(items);
    await this.orders.save(order);
    await this.events.publish(new OrderPlaced(order.id));

    return {
      orderId: order.id.value,
      total: order.total.amount,
      status: order.status
    };
  }
}

The use case coordinates domain objects and outbound ports but contains no infrastructure code. It doesn't know about HTTP, databases, or message queues. All those details are handled by adapters.

HTTP Adapter (Inbound)

// Adapter: translates HTTP to use case calls
class OrderHttpController {
  constructor(private placeOrder: PlaceOrderUseCase) {}  // Depends on port

  async handlePlaceOrder(req: Request, res: Response) {
    try {
      const request: PlaceOrderRequest = {
        customerId: req.body.customerId,
        items: req.body.items
      };

      const result = await this.placeOrder.execute(request);

      res.status(201).json({
        orderId: result.orderId,
        total: result.total,
        status: result.status
      });
    } catch (error) {
      if (error instanceof CustomerNotFoundError) {
        res.status(404).json({ error: error.message });
      } else {
        res.status(500).json({ error: 'Internal server error' });
      }
    }
  }
}

The HTTP adapter handles HTTP-specific concerns (request parsing, status codes, error formatting) but delegates business logic to the use case. This separation means you can add a GraphQL adapter, CLI adapter, or message queue adapter that reuses the same use case implementation.

Pro Tip: Keep request/response DTOs (data transfer objects) separate from domain entities. DTOs are designed for transport and may have different validation rules, serialization formats, or structure than domain objects. The adapter translates between DTOs and domain objects, keeping both layers clean.

Implementing Outbound Ports (Driven Side)

Outbound ports define what the domain needs from infrastructure: persistence, external APIs, messaging, etc. The domain defines these as interfaces, and infrastructure provides implementations.

Repository Port and Adapter

// Outbound port: domain defines what it needs
interface OrderRepository {
  save(order: Order): Promise;
  find(id: OrderId): Promise;
  findByCustomer(customerId: CustomerId): Promise;
}

// Adapter: infrastructure implements port
class PostgresOrderRepository implements OrderRepository {
  constructor(private pool: pg.Pool) {}

  async save(order: Order): Promise {
    const client = await this.pool.connect();
    try {
      await client.query('BEGIN');

      // Map domain object to database representation
      await client.query(`
        INSERT INTO orders (id, customer_id, status, total, created_at)
        VALUES ($1, $2, $3, $4, $5)
        ON CONFLICT (id) DO UPDATE SET
          status = EXCLUDED.status,
          total = EXCLUDED.total
      `, [
        order.id.value,
        order.customerId.value,
        order.status,
        order.total.amount,
        order.createdAt
      ]);

      // Save order items
      for (const item of order.items) {
        await client.query(`
          INSERT INTO order_items (order_id, product_id, quantity, price)
          VALUES ($1, $2, $3, $4)
        `, [
          order.id.value,
          item.productId.value,
          item.quantity.value,
          item.price.amount
        ]);
      }

      await client.query('COMMIT');
    } catch (error) {
      await client.query('ROLLBACK');
      throw error;
    } finally {
      client.release();
    }
  }

  async find(id: OrderId): Promise {
    const result = await this.pool.query(`
      SELECT o.*,
             json_agg(
               json_build_object(
                 'product_id', oi.product_id,
                 'quantity', oi.quantity,
                 'price', oi.price
               )
             ) as items
      FROM orders o
      LEFT JOIN order_items oi ON o.id = oi.order_id
      WHERE o.id = $1
      GROUP BY o.id
    `, [id.value]);

    if (result.rows.length === 0) return null;

    return this.toDomain(result.rows[0]);
  }

  private toDomain(row: any): Order {
    // Map database row to domain object
    return new Order({
      id: new OrderId(row.id),
      customerId: new CustomerId(row.customer_id),
      items: row.items.map((i: any) => new OrderItem(
        new ProductId(i.product_id),
        new Quantity(i.quantity),
        new Money(i.price)
      )),
      status: row.status,
      createdAt: row.created_at
    });
  }
}

The adapter handles all database-specific logic: connection pooling, transactions, SQL queries, row mapping. The domain only knows about the OrderRepository interface and domain objects.

External API Port and Adapter

// Outbound port: payment processing
interface PaymentProcessor {
  authorizePayment(
    amount: Money,
    paymentMethod: PaymentMethodId
  ): Promise;

  capturePayment(
    authorizationId: string,
    amount: Money
  ): Promise;
}

interface PaymentAuthorization {
  id: string;
  status: 'authorized' | 'declined';
  declineReason?: string;
}

// Adapter: Stripe implementation
class StripePaymentProcessor implements PaymentProcessor {
  constructor(private stripe: Stripe) {}

  async authorizePayment(
    amount: Money,
    paymentMethod: PaymentMethodId
  ): Promise {
    const intent = await this.stripe.paymentIntents.create({
      amount: amount.toCents(),  // Stripe uses cents
      currency: amount.currency.toLowerCase(),
      payment_method: paymentMethod.value,
      confirm: true,
      capture_method: 'manual'  // Authorize only
    });

    return {
      id: intent.id,
      status: intent.status === 'requires_capture' ? 'authorized' : 'declined',
      declineReason: intent.last_payment_error?.message
    };
  }

  async capturePayment(
    authorizationId: string,
    amount: Money
  ): Promise {
    const intent = await this.stripe.paymentIntents.capture(
      authorizationId,
      { amount_to_capture: amount.toCents() }
    );

    return {
      id: intent.id,
      status: intent.status === 'succeeded' ? 'captured' : 'failed'
    };
  }
}

The domain defines payment operations in its own terms (authorize, capture). The Stripe adapter translates those to Stripe API calls. If you switch to PayPal, you write a PayPalPaymentProcessor that implements the same interface. Domain code unchanged.

Directory Structure and Organization

Folder structure should reflect architectural layers and make dependency direction obvious. A common pattern organizes code by layer: domain, application, infrastructure.

src/
├── domain/                    # Inner hexagon (no external dependencies)
│   ├── model/
│   │   ├── Order.ts
│   │   ├── OrderItem.ts
│   │   ├── Customer.ts
│   │   └── Money.ts
│   ├── ports/                 # Interfaces domain defines
│   │   ├── OrderRepository.ts
│   │   ├── CustomerRepository.ts
│   │   └── PaymentProcessor.ts
│   └── services/              # Domain services, use cases
│       └── PlaceOrderService.ts
│
├── application/               # Application layer (orchestration)
│   └── use-cases/
│       └── PlaceOrderUseCase.ts
│
├── infrastructure/            # Adapters (depend on domain + external libs)
│   ├── http/
│   │   ├── OrderController.ts
│   │   └── ExpressApp.ts
│   ├── persistence/
│   │   ├── PostgresOrderRepository.ts
│   │   └── PostgresCustomerRepository.ts
│   ├── payment/
│   │   └── StripePaymentProcessor.ts
│   └── messaging/
│       └── RabbitMQEventPublisher.ts
│
└── main.ts                    # Wiring (dependency injection)

The key constraint: code in domain/ cannot import anything from infrastructure/. Code in infrastructure/ can import from domain/ (to implement interfaces). This enforces dependency inversion.

Dependency Injection and Wiring

The main entry point wires together domain and infrastructure by constructing adapters and injecting them into use cases.

// main.ts - composition root
import { PlaceOrderService } from './domain/services/PlaceOrderService';
import { PostgresOrderRepository } from './infrastructure/persistence/PostgresOrderRepository';
import { PostgresCustomerRepository } from './infrastructure/persistence/PostgresCustomerRepository';
import { StripePaymentProcessor } from './infrastructure/payment/StripePaymentProcessor';
import { OrderController } from './infrastructure/http/OrderController';

async function bootstrap() {
  // Infrastructure setup
  const dbPool = new pg.Pool({ /* config */ });
  const stripe = new Stripe(process.env.STRIPE_KEY);

  // Construct adapters
  const orderRepo = new PostgresOrderRepository(dbPool);
  const customerRepo = new PostgresCustomerRepository(dbPool);
  const paymentProcessor = new StripePaymentProcessor(stripe);

  // Construct use cases with adapters
  const placeOrderService = new PlaceOrderService(
    orderRepo,
    customerRepo,
    paymentProcessor
  );

  // Construct HTTP adapter with use cases
  const orderController = new OrderController(placeOrderService);

  // Start server
  const app = express();
  app.post('/orders', (req, res) => orderController.handlePlaceOrder(req, res));
  app.listen(3000);
}

bootstrap();

All dependencies are created at startup and injected. This makes the dependency graph explicit and allows swapping implementations (production vs test) by changing what's constructed in the composition root.

Layer Dependencies Allowed Exports
Domain None (only standard library) Entities, value objects, port interfaces
Application Domain layer Use case interfaces and implementations
Infrastructure Domain, application, external libraries Adapter implementations

Testing Hexagonal Architecture

The primary benefit of hexagonal architecture is testability. Business logic can be tested with pure unit tests because it has no infrastructure dependencies. Adapters can be tested independently. Integration tests verify the wiring.

Testing Domain Logic

// Pure unit test - no infrastructure needed
describe('PlaceOrderService', () => {
  it('should place order for valid customer with items', async () => {
    // Fake repository (in-memory, no database)
    class FakeOrderRepository implements OrderRepository {
      private orders = new Map();

      async save(order: Order): Promise {
        this.orders.set(order.id.value, order);
      }

      async find(id: OrderId): Promise {
        return this.orders.get(id.value) || null;
      }

      async findByCustomer(customerId: CustomerId): Promise {
        return Array.from(this.orders.values())
          .filter(o => o.customerId.equals(customerId));
      }
    }

    class FakeCustomerRepository implements CustomerRepository {
      async find(id: CustomerId): Promise {
        return new Customer(id, new CustomerName('Test Customer'));
      }
    }

    const orderRepo = new FakeOrderRepository();
    const customerRepo = new FakeCustomerRepository();
    const service = new PlaceOrderService(orderRepo, customerRepo);

    const result = await service.execute({
      customerId: 'customer-123',
      items: [{ productId: 'product-1', quantity: 2 }]
    });

    expect(result.orderId).toBeDefined();
    expect(result.status).toBe('placed');

    // Verify order was saved
    const saved = await orderRepo.find(new OrderId(result.orderId));
    expect(saved).toBeDefined();
    expect(saved!.items.length).toBe(1);
  });

  it('should throw error for non-existent customer', async () => {
    class FakeCustomerRepository implements CustomerRepository {
      async find(id: CustomerId): Promise {
        return null;  // Customer not found
      }
    }

    const service = new PlaceOrderService(
      new FakeOrderRepository(),
      new FakeCustomerRepository()
    );

    await expect(service.execute({
      customerId: 'invalid',
      items: []
    })).rejects.toThrow(CustomerNotFoundError);
  });
});

These tests run in milliseconds because they use fake implementations that store data in memory. No database, no network, no external dependencies. You're testing pure business logic.

Testing Adapters

// Integration test for repository adapter
describe('PostgresOrderRepository', () => {
  let pool: pg.Pool;
  let repository: PostgresOrderRepository;

  beforeEach(async () => {
    pool = new pg.Pool({ /* test database config */ });
    repository = new PostgresOrderRepository(pool);
    await pool.query('TRUNCATE orders, order_items CASCADE');
  });

  afterEach(async () => {
    await pool.end();
  });

  it('should save and retrieve order', async () => {
    const order = new Order({
      id: new OrderId('order-123'),
      customerId: new CustomerId('customer-1'),
      items: [
        new OrderItem(
          new ProductId('product-1'),
          new Quantity(2),
          new Money(1999, Currency.USD)
        )
      ],
      status: 'placed'
    });

    await repository.save(order);

    const retrieved = await repository.find(order.id);
    expect(retrieved).toBeDefined();
    expect(retrieved!.customerId.equals(order.customerId)).toBe(true);
    expect(retrieved!.items.length).toBe(1);
  });
});

Adapter tests require infrastructure (database, external APIs) but they're isolated—testing only the adapter's mapping logic, not business rules. Business rules are already tested in domain tests.

Warning: Don't test business logic through adapters. If you find yourself writing tests that set up a database to test validation logic, you're coupling tests to infrastructure unnecessarily. Test domain logic with fakes, test adapters with real infrastructure, keep them separate.

Common Implementation Challenges

Transaction Boundaries

When a use case needs to update multiple aggregates atomically, where does the transaction boundary go? The domain shouldn't know about transactions (infrastructure concern), but the transaction needs to span multiple repository calls (domain operations).

One solution: pass a unit of work or transaction context through the port interfaces. The domain calls repository methods on the transaction object. The adapter manages transaction lifecycle.

// Port includes transaction support
interface UnitOfWork {
  begin(): Promise;
  commit(): Promise;
  rollback(): Promise;
  getOrderRepository(): OrderRepository;
  getCustomerRepository(): CustomerRepository;
}

// Use case manages transaction
class PlaceOrderService {
  constructor(private uow: UnitOfWork) {}

  async execute(request: PlaceOrderRequest): Promise {
    await this.uow.begin();
    try {
      const orders = this.uow.getOrderRepository();
      const customers = this.uow.getCustomerRepository();

      // Multiple operations in same transaction
      const customer = await customers.find(new CustomerId(request.customerId));
      const order = customer.placeOrder(request.items);
      await orders.save(order);
      await customers.save(customer);

      await this.uow.commit();
      return { orderId: order.id.value, status: order.status };
    } catch (error) {
      await this.uow.rollback();
      throw error;
    }
  }
}

This keeps transaction management explicit while maintaining dependency inversion. The domain defines the UnitOfWork interface, infrastructure provides a database-specific implementation.

Domain Events Across Ports

Domain events need to be published after successful persistence. But publishing is infrastructure (message queues, event buses). How does the domain trigger infrastructure actions?

Define an EventPublisher port that the domain uses to publish events. The adapter implements publication using actual infrastructure (RabbitMQ, Kafka, etc.). Events are published after the transaction commits.

// Outbound port for events
interface EventPublisher {
  publish(event: DomainEvent): Promise;
}

// Use case publishes events
class PlaceOrderService {
  constructor(
    private orders: OrderRepository,
    private events: EventPublisher
  ) {}

  async execute(request: PlaceOrderRequest): Promise {
    const order = new Order(/* ... */);
    await this.orders.save(order);
    await this.events.publish(new OrderPlaced(order.id));
    return { orderId: order.id.value };
  }
}

// Adapter publishes to message queue
class RabbitMQEventPublisher implements EventPublisher {
  constructor(private channel: amqp.Channel) {}

  async publish(event: DomainEvent): Promise {
    const message = JSON.stringify({
      type: event.constructor.name,
      data: event,
      timestamp: new Date().toISOString()
    });

    this.channel.publish('domain-events', '', Buffer.from(message));
  }
}

When to Use Hexagonal Architecture

Hexagonal architecture adds complexity—more interfaces, more indirection, more files. That complexity pays off in specific situations but can be overhead in others.

Good Fit

Use hexagonal architecture when you need infrastructure independence. If you're building a product that might be deployed on-premise or in cloud, sold to enterprise customers who want to use their own databases, or need to support multiple persistence strategies, hexagonal architecture provides the flexibility.

It's also valuable when business logic is complex and changes frequently. Being able to test domain logic without infrastructure dependencies makes refactoring safer and faster. If your competitive advantage comes from domain modeling accuracy, the investment in architectural purity pays dividends.

Poor Fit

For simple CRUD applications where business logic is minimal, hexagonal architecture is overhead. If your application mostly moves data between HTTP requests and database tables with basic validation, the abstraction layers don't provide enough value to justify their cost.

Early-stage startups validating product-market fit might prioritize development speed over architectural purity. It's easier to add hexagonal structure later than to remove it, so starting simple and refactoring toward ports and adapters as complexity grows can be pragmatic.

Scenario Hexagonal Architecture Value
Complex domain logic, frequent changes High - testability and maintainability benefits
Multiple deployment targets or databases High - infrastructure swappability is key
Simple CRUD with minimal business rules Low - overhead exceeds benefit
Early-stage MVP, unclear requirements Medium - can adopt incrementally as needs clarify
Team unfamiliar with the pattern Medium - learning curve vs long-term benefits

Frequently Asked Questions

How is hexagonal architecture different from clean architecture?

They're closely related and share the core principle of dependency inversion. Clean architecture specifies more layers (entities, use cases, interface adapters, frameworks) while hexagonal architecture is less prescriptive about internal structure. Both agree that domain logic should be independent of infrastructure. In practice, many implementations blend concepts from both—using hexagonal architecture's port/adapter terminology with clean architecture's layer structure.

Should ports be interfaces or abstract classes?

Use interfaces in languages that support them (TypeScript, Java, C#). Interfaces clearly communicate that ports define contracts without implementation. Abstract classes can provide default implementations, which sometimes helps reduce boilerplate, but they're less flexible—a class can implement multiple interfaces but only extend one abstract class. For ports, pure interfaces are typically clearer.

How do I handle cross-cutting concerns like logging and authentication?

Cross-cutting concerns can be implemented as decorators or middleware that wraps use cases. For example, a LoggingDecorator implements the same use case interface but logs before and after delegating to the actual use case. This keeps cross-cutting concerns separate from business logic while maintaining the port interface.

Do I need separate port interfaces for every adapter?

No. Multiple adapters can implement the same port. You might have PostgresOrderRepository and MongoOrderRepository both implementing OrderRepository. The port defines what the domain needs, adapters provide different implementations. Only create multiple ports when the domain genuinely needs different capabilities, not just different implementations of the same capability.

How do I handle validation—in adapters or domain?

Domain validation (business rules like "order total must match item sum") goes in domain objects. Input validation (HTTP request format, required fields) goes in adapters. The adapter validates that input is well-formed before constructing domain objects. The domain validates that business rules are satisfied. This separation keeps domain focused on business rules while adapters handle transport-specific validation.

Can I use ORMs with hexagonal architecture?

Yes, but keep ORM code in adapters, not domain. Your domain defines entities as plain objects with business logic. The repository adapter uses the ORM to persist and retrieve those objects, mapping between domain entities and ORM models. Avoid letting ORM concerns (lazy loading, change tracking) leak into domain objects.

How do I handle database migrations in hexagonal architecture?

Database migrations are infrastructure concerns. Run them as part of deployment infrastructure, not domain code. Use migration tools (Flyway, Liquibase, or language-specific tools) in the infrastructure layer. The domain doesn't know about schema changes—the repository adapter handles mapping between domain objects and whatever schema exists.

Should every use case have its own interface?

It's common but not required. Having explicit use case interfaces makes dependency injection clearer and testing easier—you can mock specific use cases independently. But for simpler applications, use case classes without interfaces can reduce boilerplate. The critical architectural constraint is that use cases depend on repository ports, not repository implementations. Whether use cases themselves are interfaces is a secondary design decision.

How do I handle DTOs—are they part of ports or adapters?

Request and response DTOs used by use cases are part of the application layer (use case interfaces). They define the contract between adapters and use cases. Adapters translate from transport-specific formats (HTTP JSON, protobuf, etc.) to these DTOs, then call use cases. Use cases return DTOs, which adapters translate back to transport-specific formats. This keeps domain objects separate from serialization concerns.

Can I gradually refactor an existing codebase to hexagonal architecture?

Yes, start by identifying domain logic that's currently coupled to infrastructure. Extract port interfaces for the infrastructure dependencies (database, external APIs). Create adapters that implement those ports. Inject adapters into domain code through constructor parameters. You can do this incrementally, one bounded context or module at a time, rather than rewriting the entire application. Focus on areas where testability or infrastructure independence provides the most value first.

Conclusion

Hexagonal architecture succeeds when it delivers on its core promise: business logic that's completely independent of infrastructure. The key is dependency inversion—domain defines interfaces (ports), infrastructure implements them (adapters), and dependencies always point inward toward domain. This enables fast unit testing of business logic, flexibility to swap infrastructure implementations, and clearer separation of concerns.

Implementation requires discipline: keeping domain code free of infrastructure imports, defining ports that represent domain needs rather than infrastructure capabilities, and accepting the additional indirection that comes with adapter layers. The pattern works best when domain complexity justifies the architectural overhead and when infrastructure independence (testability, swappable implementations) provides tangible value.

Start by defining clear port interfaces for your most critical infrastructure dependencies. Implement adapters that translate between domain and infrastructure. Test domain logic with fake implementations that run in memory. Gradually expand the pattern to cover more of your codebase as the benefits become clear. The architecture's value compounds over time as business logic complexity grows and infrastructure requirements evolve.


Share on Social Media: