Hexagonal Architecture skill for Claude Code

Business logic that runs without a framework attached. Claude separates domain and use-case code from frameworks, ORMs and SDKs by defining ports in the application's own language and implementing them with adapters wired in one composition root. Use cases get millisecond tests with in-memory fakes, adapters get real integration tests, and dependency-cruiser, import-linter or ArchUnit keeps the rule from eroding.

  • namehexagonal-architecture
  • size212 lines · ≈2,100 tokens
  • files1
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Structures applications as ports and adapters so domain and use-case code depends on nothing external, with driving ports (HTTP, CLI, consumers) and driven ports (repositories, gateways, clocks) implemented by adapters wired in a composition root. Covers folder layout, the dependency rule, enforcing it with dependency-cruiser, import-linter, or ArchUnit, and testing use cases with in-memory fakes and adapters with integration tests. Use when business logic is tangled with frameworks or ORMs, when tests need a database for every case, when swapping an external provider, or when introducing clean or onion architecture to a codebase.

Try asking

  • Our business logic lives in Express route handlers. Restructure it as ports and adapters
  • Add a lint rule so nothing in src/domain can import Prisma or the adapters
  • How do I test this use case without spinning up Postgres?

Use it when

  • Business rules are tangled with framework or ORM code
  • Every test needs a database or network to run
  • You plan to swap a vendor such as a payment or email provider
  • Introducing clean or onion architecture incrementally

Skip it when

  • The service is thin CRUD with little logic: keep it simple
  • You are drawing module boundaries across a whole monolith: use modular-monolith

How to check the result

The dependency lint fails when a domain file imports an adapter, use-case tests run with no database or network, and swapping an adapter touches only the composition root.

The common mistake it prevents

Defining ports in the ORM's or vendor's terms, which leaks the adapter into the core and leaves you with extra interfaces but no real isolation.

~/.claude/skills/hexagonal-architecture/SKILL.md

--- name: hexagonal-architecture description: Structures applications as ports and adapters so domain and use-case code depends on nothing external, with driving ports (HTTP, CLI,… license: MIT ---

Done well: the domain and use cases compile and test without a web framework, ORM, SDK, or network; every external dependency enters through a port the application defines; and the rule is enforced by a tool, not by good intentions. Done badly, it is three layers of interfaces around CRUD that nobody can navigate.

Ground rules

  • Dependencies point inward: adapters → application (use cases) → domain. Never the reverse.
  • The application defines ports in its own terms (OrderRepository.findById), not in the vendor’s terms (prisma.order.findUnique).
  • One composition root (main/bootstrap) wires adapters into use cases. No service locator calls scattered through the code.
  • Ports exist for things that cross a process boundary or are nondeterministic (DB, HTTP APIs, queues, clock, randomness, filesystem). Do not add ports between your own in-process classes.
  • Apply it to the parts with real business logic. Thin CRUD endpoints can stay simple.

Vocabulary

TermMeaningExamples
DomainEntities, value objects, domain rules; pure codeOrder, Money, Order.place()
Application / use caseOrchestrates one user intention using portsPlaceOrder, RefundPayment
Driving (primary) portHow the outside calls the app; usually the use case interface itselfplaceOrder(cmd)
Driven (secondary) portWhat the app needs from the outsideOrderRepository, PaymentGateway, Clock
Driving adapterTranslates an external trigger into a use case callHTTP controller, CLI command, queue consumer
Driven adapterImplements a driven port with real technologyPostgresOrderRepository, StripePaymentGateway

Workflow

- [ ] 1. Identify use cases and the external systems each touches
- [ ] 2. Define driven ports in domain language
- [ ] 3. Write use cases against ports; test them with fakes
- [ ] 4. Implement adapters; integration-test each against the real tech
- [ ] 5. Wire everything in a composition root
- [ ] 6. Enforce the dependency rule in CI
- [ ] 7. Migrate existing code incrementally, one use case at a time

Layout

src/
  domain/            # entities, value objects, domain errors — no imports from outside domain
  application/       # use cases + port interfaces (ports/ subfolder)
    ports/
  adapters/
    http/            # driving: routes/controllers → use cases
    messaging/       # driving: consumers; driven: publishers
    persistence/     # driven: repositories
    payments/        # driven: gateway implementations
  main.ts            # composition root

For larger systems, repeat this structure per module or bounded context rather than one global set of layers (see modular-monolith).

2–3. Ports and a use case (TypeScript)

// application/ports/order-repository.ts
export interface OrderRepository {
  findById(id: OrderId): Promise<Order | null>;
  save(order: Order): Promise<void>;
}
// application/ports/payment-gateway.ts
export interface PaymentGateway {
  charge(req: { orderId: OrderId; amount: Money; idempotencyKey: string }): Promise<'succeeded' | 'declined'>;
}
export interface Clock { now(): Date; }

// application/place-order.ts
export class PlaceOrder {
  constructor(private orders: OrderRepository, private payments: PaymentGateway, private clock: Clock) {}

  async execute(cmd: { orderId: OrderId }): Promise<{ status: 'placed' | 'payment_declined' }> {
    const order = await this.orders.findById(cmd.orderId);
    if (!order) throw new OrderNotFound(cmd.orderId);
    const result = await this.payments.charge({ orderId: order.id, amount: order.total(), idempotencyKey: `place-${order.id}` });
    if (result === 'declined') return { status: 'payment_declined' };
    order.place(this.clock.now());
    await this.orders.save(order);
    return { status: 'placed' };
  }
}

4. Adapters

// adapters/persistence/postgres-order-repository.ts
export class PostgresOrderRepository implements OrderRepository {
  constructor(private db: Kysely<DB>) {}
  async findById(id: OrderId) {
    const row = await this.db.selectFrom('orders').selectAll().where('id', '=', id).executeTakeFirst();
    return row ? toDomain(row) : null;      // map rows to domain objects here, never leak rows inward
  }
  async save(order: Order) { /* upsert with optimistic version check */ }
}

// adapters/http/orders-routes.ts
app.post('/orders/:id/place', async (req, res) => {
  const result = await placeOrder.execute({ orderId: OrderId.parse(req.params.id) });
  res.status(result.status === 'placed' ? 200 : 402).json(result);
});

Adapters own translation: HTTP DTO ↔ command, DB row ↔ entity, vendor error ↔ domain error. Domain objects never carry ORM decorators or JSON annotations if it can be avoided.

5. Composition root

// main.ts
const db = createDb(process.env.DATABASE_URL!);
const placeOrder = new PlaceOrder(new PostgresOrderRepository(db), new StripePaymentGateway(stripeClient), systemClock);
startHttpServer({ placeOrder });

A DI container is optional; constructor injection plus one wiring file is enough for most services.

Testing strategy

LayerTest withSpeed
DomainPlain unit tests, no doublesms
Use casesIn-memory fakes for driven ports (InMemoryOrderRepository, FakeClock)ms
Driven adaptersIntegration tests against real tech (Testcontainers Postgres, MSW for HTTP APIs)seconds
Driving adaptersHTTP-level tests with use cases stubbed or the full app wired to fakesms–s
End to endA few journeys through the fully wired appslow; few

Run one shared contract suite against both the fake and the real adapter so the fake cannot drift (see mocking-strategy).

6. Enforce the dependency rule

TypeScript with dependency-cruiser (npx depcruise --init to scaffold):

// .dependency-cruiser.cjs
module.exports = {
  forbidden: [
    { name: 'domain-is-pure', severity: 'error',
      from: { path: '^src/domain' },
      to: { path: '^src/(application|adapters)|^node_modules/(express|fastify|pg|kysely|@prisma|stripe)' } },
    { name: 'application-not-to-adapters', severity: 'error',
      from: { path: '^src/application' }, to: { path: '^src/adapters' } },
  ],
};
npx depcruise src --config .dependency-cruiser.cjs

Python with import-linter:

# pyproject.toml
[tool.importlinter]
root_package = "shop"

[[tool.importlinter.contracts]]
name = "Hexagonal layers"
type = "layers"
layers = ["shop.adapters", "shop.application", "shop.domain"]

Run lint-imports in CI. Java/Kotlin: ArchUnit (layeredArchitecture() or noClasses().that().resideInAPackage("..domain..").should().dependOnClassesThat().resideInAPackage("..adapters..")). Go: keep domain and app packages free of driver imports and define interfaces in the consuming package; check with a go list -deps script or a linter such as depguard.

7. Incremental migration

  1. Pick one use case with real logic and painful tests.
  2. Extract its decisions into a use case class with ports for what it touches.
  3. Move the existing DB/HTTP code behind adapter classes implementing those ports.
  4. Point the controller at the use case. Add fake-based tests.
  5. Add the lint rule scoped to the migrated folders; widen it as more code moves.

Gotchas

  • Ports shaped like the ORM (findWhere(filter: PrismaWhereInput)) leak the adapter inward. Name methods after domain needs (findOverdueInvoices(asOf)).
  • One port per table produces chatty use cases; ports follow aggregates and use cases.
  • Transactions: expose a unit-of-work port (uow.run(async (repos) => ...)) rather than passing DB connections through the domain.
  • Mapping code is real work; accept it as the price of isolation, or do not use the pattern for that part of the system.
  • Over-application: a settings page with five fields does not need a use case, three ports, and two mappers.

Verification

  1. The dependency lint passes in CI and fails if you add an adapter import to a domain file.
  2. Use case tests run with no database, network, or env vars.
  3. Each driven adapter has integration tests against the real technology.
  4. Swapping an adapter (e.g., a fake payment gateway in staging) requires changes only in the composition root.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc