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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- hexagonal-architecture Installs to ~/.agents/skills/hexagonal-architecture/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install hexagonal-architecture The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project hexagonal-architecture Installs to .agents/skills/hexagonal-architecture/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/hexagonal-architecture.zipunzip -o hexagonal-architecture.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) hexagonal-architecture PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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.
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)
| Term | Is | Owned by | Example |
|---|---|---|---|
| Driving (primary) port | The API the core offers: use cases, commands, queries | Core | PlaceOrder.execute(cmd) |
| Driving adapter | Translates a delivery mechanism into port calls | Adapter layer | Express route, Click command, SQS handler |
| Driven (secondary) port | Interface the core needs from the world | Core | OrderRepository, PaymentGateway, Clock |
| Driven adapter | Implements a driven port with real tech | Adapter layer | PostgresOrderRepository, StripePaymentGateway |
| Composition root | Wires adapters to ports at startup | main / DI container | buildApp(config) |
The one rule: source dependencies point inward. Adapters import the core; the core imports nothing from adapters or frameworks.
When to apply
| Situation | Recommendation |
|---|---|
| Business rules plus 2+ integrations, long-lived service | Apply 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 database | Skip it; a framework-idiomatic layered app is cheaper |
| Prototype or script | Skip |
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_declinedfrom Stripe to{status: "declined"}; the HTTP adapter mapsdeclinedto 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
UnitOfWorkport 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
| Test | Target | Uses | Speed |
|---|---|---|---|
| Use case tests | Core through driving ports | In-memory driven adapters | Milliseconds, the bulk of tests |
| Adapter contract tests | Each driven adapter | Real tech (Testcontainers, sandbox API) | Seconds |
| Driving adapter tests | Routes / handlers | Stubbed use case | Fast |
| A few end-to-end tests | Whole wiring | Real adapters | Slow, 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:
| Stack | Tool | Rule |
|---|---|---|
| TypeScript/JS | dependency-cruiser or eslint-plugin-boundaries | core/** must not import adapters/**, express, pg, stripe |
| Python | import-linter (forbidden / layers contracts) | app.orders.core must not import app.orders.adapters, sqlalchemy, fastapi |
| Java/Kotlin | ArchUnit, or Konsist for Kotlin | ..core.. classes must not depend on ..adapters.. or org.springframework.. |
| .NET | NetArchTest or project references | Core project references no infrastructure projects |
| Go | Package layout plus internal/; a go list -deps check in CI | core 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
- Pick one use case with the most business logic. Write characterisation tests through its HTTP endpoint.
- Extract the logic into a use case class that takes its dependencies as constructor parameters typed by new port interfaces.
- Wrap the existing data access in an adapter implementing the port. Do not rewrite SQL yet.
- Make the controller a thin driving adapter. Add the in-memory adapter and use case tests.
- 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 returningAxiosResponse. 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
ifstatements 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.yaml
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.