Hexagonal Architecture skill for Codex

Keep frameworks and vendors out of your core. Codex introduces ports where real seams exist, external systems and test needs, rather than wrapping every class in an interface. Ports speak the core's language, adapters translate vendor errors and payloads, and one contract test suite runs against both the in-memory fake and the real adapter so fakes cannot drift. Import rules in CI keep frameworks out of the core.

  • Architecture
  • $hexagonal-architecture
  • 213 lines
  • 2 files
  • ~2,350 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- hexagonal-architecture

Installs to ~/.agents/skills/hexagonal-architecture/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $hexagonal-architecture, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Structures applications as ports and adapters (hexagonal architecture) - application core with use cases and domain logic, driving ports for HTTP/CLI/queue entry points, driven ports for databases, brokers and third-party APIs - with in-memory adapters for fast tests, adapter contract tests and import rules that keep frameworks out of the core. Use when isolating business logic from a web framework or ORM, swapping a vendor (payments, email, storage), making code testable without Docker, or reviewing a ports-and-adapters codebase.

Use it when

  • Business logic is tangled with a web framework, ORM or vendor SDK
  • You need to swap or add an implementation of an external integration
  • Tests are slow because every test needs a real database or network
  • Reviewing a codebase that claims to follow ports and adapters

Use something else when

  • You are deciding overall layering and want to avoid over-engineering a small app: use clean-architecture
  • You need help wiring dependencies or choosing a DI container: use dependency-injection

How to check the result

Core tests pass with network off and no database, the import-boundary check fails when a framework import is added to the core, and the contract suite passes against fake and real adapters.

The mistake it prevents

Defining ports that leak the vendor or ORM (Stripe types, SQL where clauses), so the second adapter cannot implement them and the core is still coupled.

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

--- name: hexagonal-architecture description: Structures applications as ports and adapters (hexagonal architecture) - application core with use cases and domain logic, driving ports for… license: MIT ---

Done well, the application core compiles and its tests run with no web framework, no database driver and no network; every external technology sits behind a port the core defines in its own language; and replacing an adapter touches one folder plus the wiring.

The shape

           driving side                                   driven side
  HTTP controller --\                               /--> PostgresOrderRepository
  CLI command -------> [driving port] CORE [driven port] --> StripePaymentGateway
  Queue consumer ---/   (use case API)   (interfaces)  \--> InMemoryOrderRepository (tests)
TermIsOwned byExample
Driving (primary) portThe API the core offers: use cases, commands, queriesCorePlaceOrder.execute(cmd)
Driving adapterTranslates a delivery mechanism into port callsAdapter layerExpress route, Click command, SQS handler
Driven (secondary) portInterface the core needs from the worldCoreOrderRepository, PaymentGateway, Clock
Driven adapterImplements a driven port with real techAdapter layerPostgresOrderRepository, StripePaymentGateway
Composition rootWires adapters to ports at startupmain / DI containerbuildApp(config)

The one rule: source dependencies point inward. Adapters import the core; the core imports nothing from adapters or frameworks.

When to apply

SituationRecommendation
Business rules plus 2+ integrations, long-lived serviceApply fully
Vendor likely to change, or several implementations (S3 and local disk)At least a driven port for that integration
Thin CRUD API over one databaseSkip it; a framework-idiomatic layered app is cheaper
Prototype or scriptSkip

Introduce ports where there is a real seam (an external system or a test need), not for every class. A port with exactly one adapter and no test double is ceremony.

Layout

src/
  orders/
    core/
      domain/            # entities, value objects, domain errors (no I/O)
      application/       # use cases = driving ports; port interfaces for driven side
        place_order.ts
        ports.ts         # OrderRepository, PaymentGateway, Clock
    adapters/
      http/              # driving: routes, request/response mapping, status codes
      postgres/          # driven: repository implementation, SQL, row mapping
      stripe/            # driven: payment gateway implementation
      memory/            # driven: in-memory fakes used by tests and local dev
  main.ts                # composition root

Organise by feature first (orders/, billing/), hexagon inside each, when the codebase has several areas. Read AGENTS.md and match existing naming before introducing folders.

Ports defined by the core, in core language

// core/application/ports.ts
export interface OrderRepository {
  get(id: OrderId): Promise<Order | null>;
  save(order: Order, expectedVersion: number): Promise<void>; // throws ConcurrencyError
}

export interface PaymentGateway {
  charge(req: { orderId: OrderId; amount: Money; idempotencyKey: string }): Promise<
    { status: "captured"; paymentRef: string } | { status: "declined"; reason: string }
  >;
}

export interface Clock { now(): Date; }
// core/application/place_order.ts  (driving port)
export class PlaceOrder {
  constructor(
    private readonly orders: OrderRepository,
    private readonly payments: PaymentGateway,
    private readonly clock: Clock,
  ) {}

  async execute(cmd: { orderId: OrderId }): Promise<PlaceOrderResult> {
    const order = await this.orders.get(cmd.orderId);
    if (!order) return { kind: "not_found" };
    const result = await this.payments.charge({
      orderId: order.id, amount: order.total(), idempotencyKey: `place-${order.id}`,
    });
    if (result.status === "declined") return { kind: "declined", reason: result.reason };
    order.markPlaced(result.paymentRef, this.clock.now());
    await this.orders.save(order, order.version);
    return { kind: "placed", orderId: order.id };
  }
}

Port design rules:

  • Name ports by what the core needs (PaymentGateway.charge), not by vendor (StripeClient.createPaymentIntent). No vendor types, HTTP status codes, SQL errors or ORM entities cross a port.
  • Return domain results or domain errors. The adapter maps card_declined from Stripe to {status: "declined"}; the HTTP adapter maps declined to 402 or 422.
  • Make time, randomness and IDs ports (Clock, IdGenerator) when behaviour depends on them; it makes tests deterministic.
  • Keep transactions in the use case boundary: a UnitOfWork port or a repository method that saves one aggregate atomically.

Adapters

Driving adapter: parse and validate input, call the use case, map the result. No business decisions.

// adapters/http/orders_routes.ts
app.post("/orders/:id/place", async (req, res) => {
  const result = await placeOrder.execute({ orderId: OrderId.parse(req.params.id) });
  switch (result.kind) {
    case "placed": return res.status(200).json({ id: result.orderId });
    case "declined": return res.status(402).json({ error: result.reason });
    case "not_found": return res.status(404).end();
  }
});

Driven adapter: implement the port, translate errors, own retries and timeouts for its technology, and map rows or payloads to domain objects explicitly.

Testing strategy

TestTargetUsesSpeed
Use case testsCore through driving portsIn-memory driven adaptersMilliseconds, the bulk of tests
Adapter contract testsEach driven adapterReal tech (Testcontainers, sandbox API)Seconds
Driving adapter testsRoutes / handlersStubbed use caseFast
A few end-to-end testsWhole wiringReal adaptersSlow, keep few

Run the same contract test suite against the in-memory fake and the real adapter, so the fake cannot drift:

export function orderRepositoryContract(make: () => Promise<OrderRepository>) {
  test("save then get returns an equal order", async () => { /* ... */ });
  test("save with stale version throws ConcurrencyError", async () => { /* ... */ });
}
// memory.test.ts:   orderRepositoryContract(async () => new InMemoryOrderRepository());
// postgres.test.ts: orderRepositoryContract(async () => new PostgresOrderRepository(await startDb()));

Enforce the dependency rule

Pick the tool for the stack and add it to CI:

StackToolRule
TypeScript/JSdependency-cruiser or eslint-plugin-boundariescore/** must not import adapters/**, express, pg, stripe
Pythonimport-linter (forbidden / layers contracts)app.orders.core must not import app.orders.adapters, sqlalchemy, fastapi
Java/KotlinArchUnit, or Konsist for Kotlin..core.. classes must not depend on ..adapters.. or org.springframework..
.NETNetArchTest or project referencesCore project references no infrastructure projects
GoPackage layout plus internal/; a go list -deps check in CIcore package imports no adapter packages

Separate build modules (Gradle subprojects, .csproj, workspace packages) make the rule a compile error, which beats a lint rule.

Migrating an existing service

  1. Pick one use case with the most business logic. Write characterisation tests through its HTTP endpoint.
  2. Extract the logic into a use case class that takes its dependencies as constructor parameters typed by new port interfaces.
  3. Wrap the existing data access in an adapter implementing the port. Do not rewrite SQL yet.
  4. Make the controller a thin driving adapter. Add the in-memory adapter and use case tests.
  5. Add the import rule for that feature only; widen as more features move.

Gotchas

  • ORM entities used as domain objects drag the ORM into the core. Map explicitly, or accept the coupling knowingly for simple aggregates and record it in an ADR.
  • Leaky ports: findBy(where: SqlWhere) or returning AxiosResponse. If a second adapter could not implement the port naturally, the port is leaking.
  • Business rules creeping into adapters (discount logic in the Stripe adapter, status transitions in the controller). Review adapters for if statements about the domain.
  • Over-mocking: tests that assert call sequences on mocks break on every refactor. Prefer in-memory fakes and assert on outcomes.
  • One interface per class “for testability” across the codebase is not hexagonal; it is noise. Ports exist at the boundary of the application.

Verification

  • Core tests run with network off and no database: run them in the Codex sandbox.
  • The dependency rule check passes in CI and fails if you add a framework import to the core.
  • Contract tests pass against both the fake and the real adapter.
  • Swapping an adapter (e.g. local disk for S3) changes only its folder and the composition root.

Output contract

**Ports added:** OrderRepository, PaymentGateway, Clock (core/application/ports.ts)
**Adapters:** http (driving), postgres, stripe, memory (driven)
**Tests:** use case tests (n, in-memory), contract suite run against memory + postgres
**Boundary check:** <tool + config file>, wired into CI step <name>
**Left as-is (by choice):** <areas kept simple and why>

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Hexagonal Architecture"
  short_description: "Keep frameworks and vendors out of your core"
  default_prompt: "$hexagonal-architecture Refactor our checkout logic out of the Express routes into a ports-and-adapters core"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc