mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-01 06:33:27 +08:00
feat: add hexagonal architecture SKILL. (#1034)
This commit is contained in:
276
skills/hexagonal-architecture/SKILL.md
Normal file
276
skills/hexagonal-architecture/SKILL.md
Normal file
@@ -0,0 +1,276 @@
|
||||
---
|
||||
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.
|
||||
Reference in New Issue
Block a user