Install any skill in seconds. Free to start, no credit card required.
Get Started Free →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.
.claude/skills/hexagonal-architecture/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-20 | ✗→✓ | ▲ Improved | — | — |
| case-21 | ✗→✓ | ▲ Improved | — | — |
| case-16 | ✓→✓ | = Same ✓ | — | — |
| case-12 | ✓→✓ | = Same ✓ | — | — |
| case-06 | ✗→✗ | = Same ✗ | — | — |
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.
Use this skill when the request involves boundaries, domain-centric design, refactoring tightly coupled services, or decoupling application logic from specific libraries.
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:
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.
Identify every side effect as a port:
UserRepositoryPort)BillingGatewayPort)LoggerPort, ClockPort)Ports should model capabilities, not technologies.
Use case class/function receives ports via constructor/arguments. It validates application-level invariants, coordinates domain rules, and returns plain data structures.
Instantiate adapters, then inject them into use cases. Keep this wiring centralized to avoid hidden service-locator behavior.
mermaidflowchart 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"]
Use feature-first organization with explicit boundaries:
textsrc/ 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
typescriptexport 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 }>; }
typescripttype 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, }; } }
typescriptexport 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; } }
typescriptexport const buildCreateOrderUseCase = (deps: { db: SqlClient; stripe: StripeClient }) => { const orderRepository = new PostgresOrderRepository(deps.db); const paymentGateway = new StripePaymentGateway(deps.stripe); return new CreateOrderUseCase(orderRepository, paymentGateway); };
Use the same boundary rules across ecosystems; only syntax and wiring style change.
application/ports/* as interfaces/types.adapters/inbound/*, adapters/outbound/*.domain, application.port.in, application.port.out, application.usecase, adapter.in, adapter.out.application.port.*.@Service is optional, not required).domain, application.port, application.usecase, adapter).internal/<feature>/domain, application, ports, adapters/inbound, adapters/outbound.New... constructors.cmd/<app>/main.go (or dedicated wiring package), keep constructors explicit.req, res, or queue metadata.| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-06 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-17 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-16 | pass→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-04 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-20 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-10 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-09 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-22 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-14 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-01 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-18 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-19 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-15 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-08 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-07 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-05 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-13 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-12 | pass→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-03 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-21 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-11 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-02 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +9 percentage points is the difference between those two pass rates over the 22 comparable cases.
The per-case answers from this run were removed by the retention sweep, so the case table below shows the verdicts without the text either arm produced. The counts above were recorded at the time and are unaffected. Answers are now kept for 180 days.
Other measured skills in the registry, with their headline benchmark lift.