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.
- name
hexagonal-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.
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
| Term | Meaning | Examples |
|---|---|---|
| Domain | Entities, value objects, domain rules; pure code | Order, Money, Order.place() |
| Application / use case | Orchestrates one user intention using ports | PlaceOrder, RefundPayment |
| Driving (primary) port | How the outside calls the app; usually the use case interface itself | placeOrder(cmd) |
| Driven (secondary) port | What the app needs from the outside | OrderRepository, PaymentGateway, Clock |
| Driving adapter | Translates an external trigger into a use case call | HTTP controller, CLI command, queue consumer |
| Driven adapter | Implements a driven port with real technology | PostgresOrderRepository, 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
| Layer | Test with | Speed |
|---|---|---|
| Domain | Plain unit tests, no doubles | ms |
| Use cases | In-memory fakes for driven ports (InMemoryOrderRepository, FakeClock) | ms |
| Driven adapters | Integration tests against real tech (Testcontainers Postgres, MSW for HTTP APIs) | seconds |
| Driving adapters | HTTP-level tests with use cases stubbed or the full app wired to fakes | ms–s |
| End to end | A few journeys through the fully wired app | slow; 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
- Pick one use case with real logic and painful tests.
- Extract its decisions into a use case class with ports for what it touches.
- Move the existing DB/HTTP code behind adapter classes implementing those ports.
- Point the controller at the use case. Add fake-based tests.
- 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
- The dependency lint passes in CI and fails if you add an adapter import to a domain file.
- Use case tests run with no database, network, or env vars.
- Each driven adapter has integration tests against the real technology.
- 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.