|
| 1 | +--- |
| 2 | +name: hexagonal-architecture |
| 3 | +description: Design, implement, and refactor Ports & Adapters systems with clear domain boundaries, dependency inversion, and testable use-case orchestration across TypeScript, Java, Kotlin, and Go services. |
| 4 | +origin: ECC |
| 5 | +--- |
| 6 | + |
| 7 | +# Hexagonal Architecture |
| 8 | + |
| 9 | +Hexagonal architecture (Ports and Adapters) keeps business logic independent from frameworks, transport, and persistence details. The core app depends on abstract ports, and adapters implement those ports at the edges. |
| 10 | + |
| 11 | +## When to Use |
| 12 | + |
| 13 | +- Building new features where long-term maintainability and testability matter. |
| 14 | +- Refactoring layered or framework-heavy code where domain logic is mixed with I/O concerns. |
| 15 | +- Supporting multiple interfaces for the same use case (HTTP, CLI, queue workers, cron jobs). |
| 16 | +- Replacing infrastructure (database, external APIs, message bus) without rewriting business rules. |
| 17 | + |
| 18 | +Use this skill when the request involves boundaries, domain-centric design, refactoring tightly coupled services, or decoupling application logic from specific libraries. |
| 19 | + |
| 20 | +## Core Concepts |
| 21 | + |
| 22 | +- **Domain model**: Business rules and entities/value objects. No framework imports. |
| 23 | +- **Use cases (application layer)**: Orchestrate domain behavior and workflow steps. |
| 24 | +- **Inbound ports**: Contracts describing what the application can do (commands/queries/use-case interfaces). |
| 25 | +- **Outbound ports**: Contracts for dependencies the application needs (repositories, gateways, event publishers, clock, UUID, etc.). |
| 26 | +- **Adapters**: Infrastructure and delivery implementations of ports (HTTP controllers, DB repositories, queue consumers, SDK wrappers). |
| 27 | +- **Composition root**: Single wiring location where concrete adapters are bound to use cases. |
| 28 | + |
| 29 | +Outbound port interfaces usually live in the application layer (or in domain only when the abstraction is truly domain-level), while infrastructure adapters implement them. |
| 30 | + |
| 31 | +Dependency direction is always inward: |
| 32 | + |
| 33 | +- Adapters -> application/domain |
| 34 | +- Application -> port interfaces (inbound/outbound contracts) |
| 35 | +- Domain -> domain-only abstractions (no framework or infrastructure dependencies) |
| 36 | +- Domain -> nothing external |
| 37 | + |
| 38 | +## How It Works |
| 39 | + |
| 40 | +### Step 1: Model a use case boundary |
| 41 | + |
| 42 | +Define a single use case with a clear input and output DTO. Keep transport details (Express `req`, GraphQL `context`, job payload wrappers) outside this boundary. |
| 43 | + |
| 44 | +### Step 2: Define outbound ports first |
| 45 | + |
| 46 | +Identify every side effect as a port: |
| 47 | + |
| 48 | +- persistence (`UserRepositoryPort`) |
| 49 | +- external calls (`BillingGatewayPort`) |
| 50 | +- cross-cutting (`LoggerPort`, `ClockPort`) |
| 51 | + |
| 52 | +Ports should model capabilities, not technologies. |
| 53 | + |
| 54 | +### Step 3: Implement the use case with pure orchestration |
| 55 | + |
| 56 | +Use case class/function receives ports via constructor/arguments. It validates application-level invariants, coordinates domain rules, and returns plain data structures. |
| 57 | + |
| 58 | +### Step 4: Build adapters at the edge |
| 59 | + |
| 60 | +- Inbound adapter converts protocol input to use-case input. |
| 61 | +- Outbound adapter maps app contracts to concrete APIs/ORM/query builders. |
| 62 | +- Mapping stays in adapters, not inside use cases. |
| 63 | + |
| 64 | +### Step 5: Wire everything in a composition root |
| 65 | + |
| 66 | +Instantiate adapters, then inject them into use cases. Keep this wiring centralized to avoid hidden service-locator behavior. |
| 67 | + |
| 68 | +### Step 6: Test per boundary |
| 69 | + |
| 70 | +- Unit test use cases with fake ports. |
| 71 | +- Integration test adapters with real infra dependencies. |
| 72 | +- E2E test user-facing flows through inbound adapters. |
| 73 | + |
| 74 | +## Architecture Diagram |
| 75 | + |
| 76 | +```mermaid |
| 77 | +flowchart LR |
| 78 | + Client["Client (HTTP/CLI/Worker)"] --> InboundAdapter["Inbound Adapter"] |
| 79 | + InboundAdapter -->|"calls"| UseCase["UseCase (Application Layer)"] |
| 80 | + UseCase -->|"uses"| OutboundPort["OutboundPort (Interface)"] |
| 81 | + OutboundAdapter["Outbound Adapter"] -->|"implements"| OutboundPort |
| 82 | + OutboundAdapter --> ExternalSystem["DB/API/Queue"] |
| 83 | + UseCase --> DomainModel["DomainModel"] |
| 84 | +``` |
| 85 | + |
| 86 | +## Suggested Module Layout |
| 87 | + |
| 88 | +Use feature-first organization with explicit boundaries: |
| 89 | + |
| 90 | +```text |
| 91 | +src/ |
| 92 | + features/ |
| 93 | + orders/ |
| 94 | + domain/ |
| 95 | + Order.ts |
| 96 | + OrderPolicy.ts |
| 97 | + application/ |
| 98 | + ports/ |
| 99 | + inbound/ |
| 100 | + CreateOrder.ts |
| 101 | + outbound/ |
| 102 | + OrderRepositoryPort.ts |
| 103 | + PaymentGatewayPort.ts |
| 104 | + use-cases/ |
| 105 | + CreateOrderUseCase.ts |
| 106 | + adapters/ |
| 107 | + inbound/ |
| 108 | + http/ |
| 109 | + createOrderRoute.ts |
| 110 | + outbound/ |
| 111 | + postgres/ |
| 112 | + PostgresOrderRepository.ts |
| 113 | + stripe/ |
| 114 | + StripePaymentGateway.ts |
| 115 | + composition/ |
| 116 | + ordersContainer.ts |
| 117 | +``` |
| 118 | + |
| 119 | +## TypeScript Example |
| 120 | + |
| 121 | +### Port definitions |
| 122 | + |
| 123 | +```typescript |
| 124 | +export interface OrderRepositoryPort { |
| 125 | + save(order: Order): Promise<void>; |
| 126 | + findById(orderId: string): Promise<Order | null>; |
| 127 | +} |
| 128 | + |
| 129 | +export interface PaymentGatewayPort { |
| 130 | + authorize(input: { orderId: string; amountCents: number }): Promise<{ authorizationId: string }>; |
| 131 | +} |
| 132 | +``` |
| 133 | + |
| 134 | +### Use case |
| 135 | + |
| 136 | +```typescript |
| 137 | +type CreateOrderInput = { |
| 138 | + orderId: string; |
| 139 | + amountCents: number; |
| 140 | +}; |
| 141 | + |
| 142 | +type CreateOrderOutput = { |
| 143 | + orderId: string; |
| 144 | + authorizationId: string; |
| 145 | +}; |
| 146 | + |
| 147 | +export class CreateOrderUseCase { |
| 148 | + constructor( |
| 149 | + private readonly orderRepository: OrderRepositoryPort, |
| 150 | + private readonly paymentGateway: PaymentGatewayPort |
| 151 | + ) {} |
| 152 | + |
| 153 | + async execute(input: CreateOrderInput): Promise<CreateOrderOutput> { |
| 154 | + const order = Order.create({ id: input.orderId, amountCents: input.amountCents }); |
| 155 | + |
| 156 | + const auth = await this.paymentGateway.authorize({ |
| 157 | + orderId: order.id, |
| 158 | + amountCents: order.amountCents, |
| 159 | + }); |
| 160 | + |
| 161 | + // markAuthorized returns a new Order instance; it does not mutate in place. |
| 162 | + const authorizedOrder = order.markAuthorized(auth.authorizationId); |
| 163 | + await this.orderRepository.save(authorizedOrder); |
| 164 | + |
| 165 | + return { |
| 166 | + orderId: order.id, |
| 167 | + authorizationId: auth.authorizationId, |
| 168 | + }; |
| 169 | + } |
| 170 | +} |
| 171 | +``` |
| 172 | + |
| 173 | +### Outbound adapter |
| 174 | + |
| 175 | +```typescript |
| 176 | +export class PostgresOrderRepository implements OrderRepositoryPort { |
| 177 | + constructor(private readonly db: SqlClient) {} |
| 178 | + |
| 179 | + async save(order: Order): Promise<void> { |
| 180 | + await this.db.query( |
| 181 | + "insert into orders (id, amount_cents, status, authorization_id) values ($1, $2, $3, $4)", |
| 182 | + [order.id, order.amountCents, order.status, order.authorizationId] |
| 183 | + ); |
| 184 | + } |
| 185 | + |
| 186 | + async findById(orderId: string): Promise<Order | null> { |
| 187 | + const row = await this.db.oneOrNone("select * from orders where id = $1", [orderId]); |
| 188 | + return row ? Order.rehydrate(row) : null; |
| 189 | + } |
| 190 | +} |
| 191 | +``` |
| 192 | + |
| 193 | +### Composition root |
| 194 | + |
| 195 | +```typescript |
| 196 | +export const buildCreateOrderUseCase = (deps: { db: SqlClient; stripe: StripeClient }) => { |
| 197 | + const orderRepository = new PostgresOrderRepository(deps.db); |
| 198 | + const paymentGateway = new StripePaymentGateway(deps.stripe); |
| 199 | + |
| 200 | + return new CreateOrderUseCase(orderRepository, paymentGateway); |
| 201 | +}; |
| 202 | +``` |
| 203 | + |
| 204 | +## Multi-Language Mapping |
| 205 | + |
| 206 | +Use the same boundary rules across ecosystems; only syntax and wiring style change. |
| 207 | + |
| 208 | +- **TypeScript/JavaScript** |
| 209 | + - Ports: `application/ports/*` as interfaces/types. |
| 210 | + - Use cases: classes/functions with constructor/argument injection. |
| 211 | + - Adapters: `adapters/inbound/*`, `adapters/outbound/*`. |
| 212 | + - Composition: explicit factory/container module (no hidden globals). |
| 213 | +- **Java** |
| 214 | + - Packages: `domain`, `application.port.in`, `application.port.out`, `application.usecase`, `adapter.in`, `adapter.out`. |
| 215 | + - Ports: interfaces in `application.port.*`. |
| 216 | + - Use cases: plain classes (Spring `@Service` is optional, not required). |
| 217 | + - Composition: Spring config or manual wiring class; keep wiring out of domain/use-case classes. |
| 218 | +- **Kotlin** |
| 219 | + - Modules/packages mirror the Java split (`domain`, `application.port`, `application.usecase`, `adapter`). |
| 220 | + - Ports: Kotlin interfaces. |
| 221 | + - Use cases: classes with constructor injection (Koin/Dagger/Spring/manual). |
| 222 | + - Composition: module definitions or dedicated composition functions; avoid service locator patterns. |
| 223 | +- **Go** |
| 224 | + - Packages: `internal/<feature>/domain`, `application`, `ports`, `adapters/inbound`, `adapters/outbound`. |
| 225 | + - Ports: small interfaces owned by the consuming application package. |
| 226 | + - Use cases: structs with interface fields plus explicit `New...` constructors. |
| 227 | + - Composition: wire in `cmd/<app>/main.go` (or dedicated wiring package), keep constructors explicit. |
| 228 | + |
| 229 | +## Anti-Patterns to Avoid |
| 230 | + |
| 231 | +- Domain entities importing ORM models, web framework types, or SDK clients. |
| 232 | +- Use cases reading directly from `req`, `res`, or queue metadata. |
| 233 | +- Returning database rows directly from use cases without domain/application mapping. |
| 234 | +- Letting adapters call each other directly instead of flowing through use-case ports. |
| 235 | +- Spreading dependency wiring across many files with hidden global singletons. |
| 236 | + |
| 237 | +## Migration Playbook |
| 238 | + |
| 239 | +1. Pick one vertical slice (single endpoint/job) with frequent change pain. |
| 240 | +2. Extract a use-case boundary with explicit input/output types. |
| 241 | +3. Introduce outbound ports around existing infrastructure calls. |
| 242 | +4. Move orchestration logic from controllers/services into the use case. |
| 243 | +5. Keep old adapters, but make them delegate to the new use case. |
| 244 | +6. Add tests around the new boundary (unit + adapter integration). |
| 245 | +7. Repeat slice-by-slice; avoid full rewrites. |
| 246 | + |
| 247 | +### Refactoring Existing Systems |
| 248 | + |
| 249 | +- **Strangler approach**: keep current endpoints, route one use case at a time through new ports/adapters. |
| 250 | +- **No big-bang rewrites**: migrate per feature slice and preserve behavior with characterization tests. |
| 251 | +- **Facade first**: wrap legacy services behind outbound ports before replacing internals. |
| 252 | +- **Composition freeze**: centralize wiring early so new dependencies do not leak into domain/use-case layers. |
| 253 | +- **Slice selection rule**: prioritize high-churn, low-blast-radius flows first. |
| 254 | +- **Rollback path**: keep a reversible toggle or route switch per migrated slice until production behavior is verified. |
| 255 | + |
| 256 | +## Testing Guidance (Same Hexagonal Boundaries) |
| 257 | + |
| 258 | +- **Domain tests**: test entities/value objects as pure business rules (no mocks, no framework setup). |
| 259 | +- **Use-case unit tests**: test orchestration with fakes/stubs for outbound ports; assert business outcomes and port interactions. |
| 260 | +- **Outbound adapter contract tests**: define shared contract suites at port level and run them against each adapter implementation. |
| 261 | +- **Inbound adapter tests**: verify protocol mapping (HTTP/CLI/queue payload to use-case input and output/error mapping back to protocol). |
| 262 | +- **Adapter integration tests**: run against real infrastructure (DB/API/queue) for serialization, schema/query behavior, retries, and timeouts. |
| 263 | +- **End-to-end tests**: cover critical user journeys through inbound adapter -> use case -> outbound adapter. |
| 264 | +- **Refactor safety**: add characterization tests before extraction; keep them until new boundary behavior is stable and equivalent. |
| 265 | + |
| 266 | +## Best Practices Checklist |
| 267 | + |
| 268 | +- Domain and use-case layers import only internal types and ports. |
| 269 | +- Every external dependency is represented by an outbound port. |
| 270 | +- Validation occurs at boundaries (inbound adapter + use-case invariants). |
| 271 | +- Use immutable transformations (return new values/entities instead of mutating shared state). |
| 272 | +- Errors are translated across boundaries (infra errors -> application/domain errors). |
| 273 | +- Composition root is explicit and easy to audit. |
| 274 | +- Use cases are testable with simple in-memory fakes for ports. |
| 275 | +- Refactoring starts from one vertical slice with behavior-preserving tests. |
| 276 | +- Language/framework specifics stay in adapters, never in domain rules. |
0 commit comments