mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-03 23:53:29 +08:00
277 lines
11 KiB
Markdown
277 lines
11 KiB
Markdown
---
|
|
name: hexagonal-architecture
|
|
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.
|
|
origin: ECC
|
|
---
|
|
|
|
# Hexagonal Architecture
|
|
|
|
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.
|
|
|
|
## When to Use
|
|
|
|
- Building new features where long-term maintainability and testability matter.
|
|
- Refactoring layered or framework-heavy code where domain logic is mixed with I/O concerns.
|
|
- Supporting multiple interfaces for the same use case (HTTP, CLI, queue workers, cron jobs).
|
|
- Replacing infrastructure (database, external APIs, message bus) without rewriting business rules.
|
|
|
|
Use this skill when the request involves boundaries, domain-centric design, refactoring tightly coupled services, or decoupling application logic from specific libraries.
|
|
|
|
## Core Concepts
|
|
|
|
- **Domain model**: Business rules and entities/value objects. No framework imports.
|
|
- **Use cases (application layer)**: Orchestrate domain behavior and workflow steps.
|
|
- **Inbound ports**: Contracts describing what the application can do (commands/queries/use-case interfaces).
|
|
- **Outbound ports**: Contracts for dependencies the application needs (repositories, gateways, event publishers, clock, UUID, etc.).
|
|
- **Adapters**: Infrastructure and delivery implementations of ports (HTTP controllers, DB repositories, queue consumers, SDK wrappers).
|
|
- **Composition root**: Single wiring location where concrete adapters are bound to use cases.
|
|
|
|
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.
|
|
|
|
Dependency direction is always inward:
|
|
|
|
- Adapters -> application/domain
|
|
- Application -> port interfaces (inbound/outbound contracts)
|
|
- Domain -> domain-only abstractions (no framework or infrastructure dependencies)
|
|
- Domain -> nothing external
|
|
|
|
## How It Works
|
|
|
|
### Step 1: Model a use case boundary
|
|
|
|
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.
|
|
|
|
### Step 2: Define outbound ports first
|
|
|
|
Identify every side effect as a port:
|
|
|
|
- persistence (`UserRepositoryPort`)
|
|
- external calls (`BillingGatewayPort`)
|
|
- cross-cutting (`LoggerPort`, `ClockPort`)
|
|
|
|
Ports should model capabilities, not technologies.
|
|
|
|
### Step 3: Implement the use case with pure orchestration
|
|
|
|
Use case class/function receives ports via constructor/arguments. It validates application-level invariants, coordinates domain rules, and returns plain data structures.
|
|
|
|
### Step 4: Build adapters at the edge
|
|
|
|
- Inbound adapter converts protocol input to use-case input.
|
|
- Outbound adapter maps app contracts to concrete APIs/ORM/query builders.
|
|
- Mapping stays in adapters, not inside use cases.
|
|
|
|
### Step 5: Wire everything in a composition root
|
|
|
|
Instantiate adapters, then inject them into use cases. Keep this wiring centralized to avoid hidden service-locator behavior.
|
|
|
|
### Step 6: Test per boundary
|
|
|
|
- Unit test use cases with fake ports.
|
|
- Integration test adapters with real infra dependencies.
|
|
- E2E test user-facing flows through inbound adapters.
|
|
|
|
## Architecture Diagram
|
|
|
|
```mermaid
|
|
flowchart LR
|
|
Client["Client (HTTP/CLI/Worker)"] --> InboundAdapter["Inbound Adapter"]
|
|
InboundAdapter -->|"calls"| UseCase["UseCase (Application Layer)"]
|
|
UseCase -->|"uses"| OutboundPort["OutboundPort (Interface)"]
|
|
OutboundAdapter["Outbound Adapter"] -->|"implements"| OutboundPort
|
|
OutboundAdapter --> ExternalSystem["DB/API/Queue"]
|
|
UseCase --> DomainModel["DomainModel"]
|
|
```
|
|
|
|
## Suggested Module Layout
|
|
|
|
Use feature-first organization with explicit boundaries:
|
|
|
|
```text
|
|
src/
|
|
features/
|
|
orders/
|
|
domain/
|
|
Order.ts
|
|
OrderPolicy.ts
|
|
application/
|
|
ports/
|
|
inbound/
|
|
CreateOrder.ts
|
|
outbound/
|
|
OrderRepositoryPort.ts
|
|
PaymentGatewayPort.ts
|
|
use-cases/
|
|
CreateOrderUseCase.ts
|
|
adapters/
|
|
inbound/
|
|
http/
|
|
createOrderRoute.ts
|
|
outbound/
|
|
postgres/
|
|
PostgresOrderRepository.ts
|
|
stripe/
|
|
StripePaymentGateway.ts
|
|
composition/
|
|
ordersContainer.ts
|
|
```
|
|
|
|
## TypeScript Example
|
|
|
|
### Port definitions
|
|
|
|
```typescript
|
|
export interface OrderRepositoryPort {
|
|
save(order: Order): Promise<void>;
|
|
findById(orderId: string): Promise<Order | null>;
|
|
}
|
|
|
|
export interface PaymentGatewayPort {
|
|
authorize(input: { orderId: string; amountCents: number }): Promise<{ authorizationId: string }>;
|
|
}
|
|
```
|
|
|
|
### Use case
|
|
|
|
```typescript
|
|
type CreateOrderInput = {
|
|
orderId: string;
|
|
amountCents: number;
|
|
};
|
|
|
|
type CreateOrderOutput = {
|
|
orderId: string;
|
|
authorizationId: string;
|
|
};
|
|
|
|
export class CreateOrderUseCase {
|
|
constructor(
|
|
private readonly orderRepository: OrderRepositoryPort,
|
|
private readonly paymentGateway: PaymentGatewayPort
|
|
) {}
|
|
|
|
async execute(input: CreateOrderInput): Promise<CreateOrderOutput> {
|
|
const order = Order.create({ id: input.orderId, amountCents: input.amountCents });
|
|
|
|
const auth = await this.paymentGateway.authorize({
|
|
orderId: order.id,
|
|
amountCents: order.amountCents,
|
|
});
|
|
|
|
// markAuthorized returns a new Order instance; it does not mutate in place.
|
|
const authorizedOrder = order.markAuthorized(auth.authorizationId);
|
|
await this.orderRepository.save(authorizedOrder);
|
|
|
|
return {
|
|
orderId: order.id,
|
|
authorizationId: auth.authorizationId,
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
### Outbound adapter
|
|
|
|
```typescript
|
|
export class PostgresOrderRepository implements OrderRepositoryPort {
|
|
constructor(private readonly db: SqlClient) {}
|
|
|
|
async save(order: Order): Promise<void> {
|
|
await this.db.query(
|
|
"insert into orders (id, amount_cents, status, authorization_id) values ($1, $2, $3, $4)",
|
|
[order.id, order.amountCents, order.status, order.authorizationId]
|
|
);
|
|
}
|
|
|
|
async findById(orderId: string): Promise<Order | null> {
|
|
const row = await this.db.oneOrNone("select * from orders where id = $1", [orderId]);
|
|
return row ? Order.rehydrate(row) : null;
|
|
}
|
|
}
|
|
```
|
|
|
|
### Composition root
|
|
|
|
```typescript
|
|
export const buildCreateOrderUseCase = (deps: { db: SqlClient; stripe: StripeClient }) => {
|
|
const orderRepository = new PostgresOrderRepository(deps.db);
|
|
const paymentGateway = new StripePaymentGateway(deps.stripe);
|
|
|
|
return new CreateOrderUseCase(orderRepository, paymentGateway);
|
|
};
|
|
```
|
|
|
|
## Multi-Language Mapping
|
|
|
|
Use the same boundary rules across ecosystems; only syntax and wiring style change.
|
|
|
|
- **TypeScript/JavaScript**
|
|
- Ports: `application/ports/*` as interfaces/types.
|
|
- Use cases: classes/functions with constructor/argument injection.
|
|
- Adapters: `adapters/inbound/*`, `adapters/outbound/*`.
|
|
- Composition: explicit factory/container module (no hidden globals).
|
|
- **Java**
|
|
- Packages: `domain`, `application.port.in`, `application.port.out`, `application.usecase`, `adapter.in`, `adapter.out`.
|
|
- Ports: interfaces in `application.port.*`.
|
|
- Use cases: plain classes (Spring `@Service` is optional, not required).
|
|
- Composition: Spring config or manual wiring class; keep wiring out of domain/use-case classes.
|
|
- **Kotlin**
|
|
- Modules/packages mirror the Java split (`domain`, `application.port`, `application.usecase`, `adapter`).
|
|
- Ports: Kotlin interfaces.
|
|
- Use cases: classes with constructor injection (Koin/Dagger/Spring/manual).
|
|
- Composition: module definitions or dedicated composition functions; avoid service locator patterns.
|
|
- **Go**
|
|
- Packages: `internal/<feature>/domain`, `application`, `ports`, `adapters/inbound`, `adapters/outbound`.
|
|
- Ports: small interfaces owned by the consuming application package.
|
|
- Use cases: structs with interface fields plus explicit `New...` constructors.
|
|
- Composition: wire in `cmd/<app>/main.go` (or dedicated wiring package), keep constructors explicit.
|
|
|
|
## Anti-Patterns to Avoid
|
|
|
|
- Domain entities importing ORM models, web framework types, or SDK clients.
|
|
- Use cases reading directly from `req`, `res`, or queue metadata.
|
|
- Returning database rows directly from use cases without domain/application mapping.
|
|
- Letting adapters call each other directly instead of flowing through use-case ports.
|
|
- Spreading dependency wiring across many files with hidden global singletons.
|
|
|
|
## Migration Playbook
|
|
|
|
1. Pick one vertical slice (single endpoint/job) with frequent change pain.
|
|
2. Extract a use-case boundary with explicit input/output types.
|
|
3. Introduce outbound ports around existing infrastructure calls.
|
|
4. Move orchestration logic from controllers/services into the use case.
|
|
5. Keep old adapters, but make them delegate to the new use case.
|
|
6. Add tests around the new boundary (unit + adapter integration).
|
|
7. Repeat slice-by-slice; avoid full rewrites.
|
|
|
|
### Refactoring Existing Systems
|
|
|
|
- **Strangler approach**: keep current endpoints, route one use case at a time through new ports/adapters.
|
|
- **No big-bang rewrites**: migrate per feature slice and preserve behavior with characterization tests.
|
|
- **Facade first**: wrap legacy services behind outbound ports before replacing internals.
|
|
- **Composition freeze**: centralize wiring early so new dependencies do not leak into domain/use-case layers.
|
|
- **Slice selection rule**: prioritize high-churn, low-blast-radius flows first.
|
|
- **Rollback path**: keep a reversible toggle or route switch per migrated slice until production behavior is verified.
|
|
|
|
## Testing Guidance (Same Hexagonal Boundaries)
|
|
|
|
- **Domain tests**: test entities/value objects as pure business rules (no mocks, no framework setup).
|
|
- **Use-case unit tests**: test orchestration with fakes/stubs for outbound ports; assert business outcomes and port interactions.
|
|
- **Outbound adapter contract tests**: define shared contract suites at port level and run them against each adapter implementation.
|
|
- **Inbound adapter tests**: verify protocol mapping (HTTP/CLI/queue payload to use-case input and output/error mapping back to protocol).
|
|
- **Adapter integration tests**: run against real infrastructure (DB/API/queue) for serialization, schema/query behavior, retries, and timeouts.
|
|
- **End-to-end tests**: cover critical user journeys through inbound adapter -> use case -> outbound adapter.
|
|
- **Refactor safety**: add characterization tests before extraction; keep them until new boundary behavior is stable and equivalent.
|
|
|
|
## Best Practices Checklist
|
|
|
|
- Domain and use-case layers import only internal types and ports.
|
|
- Every external dependency is represented by an outbound port.
|
|
- Validation occurs at boundaries (inbound adapter + use-case invariants).
|
|
- Use immutable transformations (return new values/entities instead of mutating shared state).
|
|
- Errors are translated across boundaries (infra errors -> application/domain errors).
|
|
- Composition root is explicit and easy to audit.
|
|
- Use cases are testable with simple in-memory fakes for ports.
|
|
- Refactoring starts from one vertical slice with behavior-preserving tests.
|
|
- Language/framework specifics stay in adapters, never in domain rules.
|