Best Practices

AI Rules for Hexagonal Architecture

AI wires application logic directly to infrastructure. Rules for ports and adapters, driving vs driven ports, primary and secondary adapters, testable boundaries, and infrastructure swapability.

7 min read·February 24, 2025

Express handler calls Prisma calls Postgres — add GraphQL and you duplicate all the business logic

Ports and adapters, driving and driven ports, fake adapters for testing, swappable infrastructure

AI Glues Logic Directly to Infrastructure

AI generates applications where: the HTTP framework calls database queries directly (Express handler contains Prisma calls), the domain imports infrastructure types (business entities reference ORM decorators), switching a database requires rewriting business logic (domain code is married to SQL syntax), testing requires real infrastructure (cannot test order rules without a database), and adding a new interface (CLI, GraphQL, WebSocket) requires duplicating business logic. The application core is inseparable from its delivery and persistence mechanisms.

Hexagonal architecture (ports and adapters) solves this by: placing the domain at the center with no infrastructure dependencies, defining ports (interfaces) that the domain exposes and requires, implementing adapters that connect ports to real infrastructure, allowing any adapter to be swapped without changing the domain, and making the domain testable with fake adapters. It is conceptually identical to clean architecture but uses different terminology — ports instead of boundaries, adapters instead of interface layers.

These rules cover: ports and adapters terminology, driving (inbound) vs driven (outbound) ports, primary and secondary adapters, testable fake adapters, and practical implementation in TypeScript.

Rule 1: Ports Define the Domain Boundary

The rule: 'Ports are interfaces that define how the outside world interacts with the domain (driving ports) and how the domain interacts with the outside world (driven ports). Driving port: interface OrderService { createOrder(input: CreateOrderInput): Promise<Order>; } — defines what the domain offers. Driven port: interface OrderRepository { save(order: Order): Promise<void>; } — defines what the domain needs. The domain defines both; adapters implement them.'

For the hexagon metaphor: 'The hexagon represents the application core. Each side is a port — a defined interaction point. Driving ports (left side): HTTP, CLI, GraphQL, WebSocket, cron jobs — things that trigger the domain. Driven ports (right side): database, payment gateway, email service, file storage — things the domain uses. The domain sits in the center, unaware of what is on either side. Only the port interface matters.'

AI generates: no ports. The Express handler directly calls Prisma, which directly queries Postgres. Adding a GraphQL interface means duplicating the Prisma calls in GraphQL resolvers. With ports: both Express and GraphQL adapters call the same OrderService port. The business logic exists once; multiple interfaces consume it through the same port.

  • Driving ports: what the domain offers — OrderService, PaymentService interfaces
  • Driven ports: what the domain needs — OrderRepository, PaymentGateway interfaces
  • Domain defines both port types — adapters implement them
  • Multiple adapters per port: HTTP + GraphQL + CLI all use the same driving port
  • The port is the contract; the adapter is the implementation
💡 Three Interfaces, One Domain

Express, GraphQL, and CLI adapters all call the same OrderService port. Business logic exists once. Adding a WebSocket interface: write a 30-line adapter. No business logic duplication. The port is the contract; adapters are interchangeable translation layers.

Rule 2: Driving (Primary) Adapters for Inbound Traffic

The rule: 'Driving adapters translate external requests into domain port calls. An Express adapter: receives HTTP request, validates and transforms input, calls the driving port (OrderService.createOrder), and formats the response. A CLI adapter: parses command-line arguments, calls the same driving port, and prints output to the console. A GraphQL adapter: resolves queries by calling the same port. Three interfaces, one domain, zero duplication.'

For adapter responsibility: 'The driving adapter handles only: protocol-specific concerns (HTTP status codes, GraphQL types, CLI output formatting), input transformation (req.body to domain DTO), output transformation (domain result to HTTP response), and error translation (domain exceptions to HTTP status codes or GraphQL errors). Business logic lives in the port implementation, never in the adapter. If you find if-statements with business rules in an adapter, they belong in the domain.'

AI generates: business rules in the Express route handler — the adapter is doing domain work. When adding a GraphQL interface, the business rules must be copied to the resolver. With hexagonal: the adapter is a thin translation layer. All business logic lives behind the port. Adding GraphQL means writing a new adapter (30 lines of translation), not duplicating business logic (300 lines of rules).

Rule 3: Driven (Secondary) Adapters for Outbound Integration

The rule: 'Driven adapters implement the interfaces the domain needs. PrismaOrderRepository implements OrderRepository: translates domain Order to Prisma create/update calls, translates Prisma results back to domain Order entities. StripePaymentAdapter implements PaymentGateway: translates domain Money to Stripe charge parameters, translates Stripe responses to domain PaymentResult. Each adapter encapsulates one external system.'

For adapter isolation: 'Each driven adapter encapsulates: connection management (database pool, API client initialization), data mapping (domain entity to external format and back), error translation (Prisma errors to domain exceptions, Stripe errors to PaymentFailed), and retry logic (transient failure handling for external APIs). The domain sees: save(order) succeeded or threw OrderPersistenceError. It never sees: Prisma unique constraint violation P2002 or Stripe card_declined.'

AI generates: domain code that catches Prisma-specific errors: catch (e) { if (e.code === 'P2002') ... } — the domain knows about Prisma internals. With a driven adapter: the adapter catches P2002 and throws DuplicateOrderError (a domain exception). The domain catches DuplicateOrderError. Switch from Prisma to Drizzle: update the adapter error mapping. Domain code unchanged.

  • One adapter per external system: PrismaOrderRepo, StripePayment, SendGridEmail
  • Adapter encapsulates: connection, data mapping, error translation, retry logic
  • Domain exceptions, not infrastructure exceptions: DuplicateOrderError, not P2002
  • Switch infrastructure: write new adapter, same interface, zero domain changes
  • Adapter is the only place that imports infrastructure libraries
⚠️ P2002 Belongs in the Adapter

Domain code catching Prisma P2002 errors means the domain knows about Prisma internals. The adapter catches P2002 and throws DuplicateOrderError (domain exception). Switch from Prisma to Drizzle: update the adapter error mapping. Domain code never changes.

Rule 4: Fake Adapters for Testing

The rule: 'For every driven port, create a fake (in-memory) adapter for testing. InMemoryOrderRepository implements OrderRepository: stores orders in a Map, returns them by ID, simulates all repository operations without a database. FakePaymentGateway implements PaymentGateway: accepts all charges, records them for assertion, never calls Stripe. Tests use fake adapters: fast (no I/O), deterministic (no external state), and focused (test business logic, not infrastructure).'

For test setup: 'const orderRepo = new InMemoryOrderRepository(); const paymentGateway = new FakePaymentGateway(); const orderService = new OrderServiceImpl(orderRepo, paymentGateway); test("creates order and reserves payment", async () => { const order = await orderService.createOrder(validInput); expect(orderRepo.findById(order.id)).toBeDefined(); expect(paymentGateway.charges).toHaveLength(1); }). No database setup, no HTTP server, no API mocks. The test runs in 5ms.'

AI generates: tests that spin up a test database, seed data, make HTTP requests, and clean up — each test takes 500ms and is flaky when the database is slow. Fake adapters: the same tests run in 5ms, are perfectly deterministic, and test exactly what matters — the business logic. Integration tests with real adapters run separately in CI for infrastructure verification.

ℹ️ 5ms Tests, Not 500ms

AI tests spin up a database and HTTP server — 500ms per test, flaky. Fake adapters (InMemoryRepo, FakePaymentGateway): same test logic, 5ms execution, perfectly deterministic. Test business logic, not infrastructure. Integration tests run separately in CI.

Rule 5: Practical TypeScript Implementation

The rule: 'Organize by hexagonal layers: src/domain/ (entities, value objects, domain events, port interfaces), src/application/ (use case implementations of driving ports), src/adapters/inbound/ (HTTP controllers, GraphQL resolvers, CLI handlers), src/adapters/outbound/ (database repositories, API clients, email senders), src/config/ (composition root that wires adapters to ports). The composition root is the only place that knows about all layers — it assembles the application.'

For the composition root: 'const orderRepo = new PrismaOrderRepository(prisma); const paymentGateway = new StripePaymentAdapter(stripeClient); const orderService = new OrderServiceImpl(orderRepo, paymentGateway); const orderController = new ExpressOrderController(orderService); app.use("/api/orders", orderController.router);. All wiring in one place. Swap Prisma for Drizzle: change one line in the composition root. The rest of the application is unaware.'

AI generates: imports scattered everywhere — the route handler imports Prisma, which imports the schema, which depends on the database URL. The dependency graph is a web. With hexagonal organization: the composition root is the single entry point. Dependencies flow inward. Each file imports from its own layer or inner layers only. The dependency graph is a clean tree, not a tangled web.

Complete Hexagonal Architecture Rules Template

Consolidated rules for hexagonal architecture.

  • Ports define the boundary: driving ports (what domain offers), driven ports (what domain needs)
  • Driving adapters: HTTP, GraphQL, CLI — thin translation layers, no business logic
  • Driven adapters: database, payment, email — encapsulate connection, mapping, error translation
  • Domain exceptions, not infrastructure exceptions: DuplicateOrderError, not Prisma P2002
  • Fake adapters for testing: InMemoryRepo, FakePaymentGateway — 5ms tests, not 500ms
  • Composition root wires everything: one place to swap any adapter
  • Directory structure: domain/, application/, adapters/inbound/, adapters/outbound/, config/
  • Same pattern as clean architecture — different terminology, identical principles