Design, implement, and refactor Ports & Adapters systems with clear domain boundaries, dependency inversion, and testable use-case orchestration across…
Hexagonal Architecture
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.
When to Use
Building new features where long-term maintainability and testability matter.
Refactoring layered or framework-heavy code where domain logic is mixed with I/O concerns.
Supporting multiple interfaces for the same use case (HTTP, CLI, queue workers, cron jobs).
Replacing infrastructure (database, external APIs, message bus) without rewriting business rules.
Use this skill when the request involves boundaries, domain-centric design, refactoring tightly coupled services, or decoupling application logic from specific libraries.
Core Concepts
Domain model: Business rules and entities/value objects. No framework imports.
Use cases (application layer): Orchestrate domain behavior and workflow steps.
Inbound ports: Contracts describing what the application can do (commands/queries/use-case interfaces).
Outbound ports: Contracts for dependencies the application needs (repositories, gateways, event publishers, clock, UUID, etc.).
Adapters: Infrastructure and delivery implementations of ports (HTTP controllers, DB repositories, queue consumers, SDK wrappers).
Composition root: Single wiring location where concrete adapters are bound to use cases.
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:
Adapters -> application/domain
Application -> port interfaces (inbound/outbound contracts)
Domain -> domain-only abstractions (no framework or infrastructure dependencies)
Domain -> nothing external
How It Works
Step 1: Model a use case boundary
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.
Step 2: Define outbound ports first
Identify every side effect as a port:
persistence (UserRepositoryPort)
external calls (BillingGatewayPort)
cross-cutting (LoggerPort, ClockPort)
Ports should model capabilities, not technologies.
Step 3: Implement the use case with pure orchestration
Use case class/function receives ports via constructor/arguments. It validates application-level invariants, coordinates domain rules, and returns plain data structures.
Step 4: Build adapters at the edge
Inbound adapter converts protocol input to use-case input.
Outbound adapter maps app contracts to concrete APIs/ORM/query builders.
Mapping stays in adapters, not inside use cases.
Step 5: Wire everything in a composition root
Instantiate adapters, then inject them into use cases. Keep this wiring centralized to avoid hidden service-locator behavior.
Step 6: Test per boundary
Unit test use cases with fake ports.
Integration test adapters with real infra dependencies.
E2E test user-facing flows through inbound adapters.
Architecture Diagram
flowchart 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"]
Suggested Module Layout
Use feature-first organization with explicit boundaries:
src/
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
TypeScript Example
Port definitions
export 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 }>;
}
Use case
type 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,
};
}
}
Outbound adapter
export 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;
}
}
Composition root
export const buildCreateOrderUseCase = (deps: { db: SqlClient; stripe: StripeClient }) => {
const orderRepository = new PostgresOrderRepository(deps.db);
const paymentGateway = new StripePaymentGateway(deps.stripe);
return new CreateOrderUseCase(orderRepository, paymentGateway);
};
Multi-Language Mapping
Use the same boundary rules across ecosystems; only syntax and wiring style change.
TypeScript/JavaScript
Ports: application/ports/* as interfaces/types.
Use cases: classes/functions with constructor/argument injection.
Adapters: adapters/inbound/*, adapters/outbound/*.
Composition: explicit factory/container module (no hidden globals).
Java
Packages: domain, application.port.in, application.port.out, application.usecase, adapter.in, adapter.out.
Ports: interfaces in application.port.*.
Use cases: plain classes (Spring @Service is optional, not required).
Composition: Spring config or manual wiring class; keep wiring out of domain/use-case classes.
Kotlin
Modules/packages mirror the Java split (domain, application.port, application.usecase, adapter).
Ports: Kotlin interfaces.
Use cases: classes with constructor injection (Koin/Dagger/Spring/manual).
Composition: module definitions or dedicated composition functions; avoid service locator patterns.
Go
Packages: internal/<feature>/domain, application, ports, adapters/inbound, adapters/outbound.
Ports: small interfaces owned by the consuming application package.
Use cases: structs with interface fields plus explicit New... constructors.
Composition: wire in cmd/<app>/main.go (or dedicated wiring package), keep constructors explicit.
Anti-Patterns to Avoid
Domain entities importing ORM models, web framework types, or SDK clients.
Use cases reading directly from req, res, or queue metadata.
Returning database rows directly from use cases without domain/application mapping.
Letting adapters call each other directly instead of flowing through use-case ports.
Spreading dependency wiring across many files with hidden global singletons.
Migration Playbook
Pick one vertical slice (single endpoint/job) with frequent change pain.
Extract a use-case boundary with explicit input/output types.
Introduce outbound ports around existing infrastructure calls.
Move orchestration logic from controllers/services into the use case.
Keep old adapters, but make them delegate to the new use case.
Add tests around the new boundary (unit + adapter integration).
Repeat slice-by-slice; avoid full rewrites.
Refactoring Existing Systems
Strangler approach: keep current endpoints, route one use case at a time through new ports/adapters.
No big-bang rewrites: migrate per feature slice and preserve behavior with characterization tests.
Facade first: wrap legacy services behind outbound ports before replacing internals.
Composition freeze: centralize wiring early so new dependencies do not leak into domain/use-case layers.
Slice selection rule: prioritize high-churn, low-blast-radius flows first.
Rollback path: keep a reversible toggle or route switch per migrated slice until production behavior is verified.
Testing Guidance (Same Hexagonal Boundaries)
Domain tests: test entities/value objects as pure business rules (no mocks, no framework setup).
Use-case unit tests: test orchestration with fakes/stubs for outbound ports; assert business outcomes and port interactions.
Outbound adapter contract tests: define shared contract suites at port level and run them against each adapter implementation.
Inbound adapter tests: verify protocol mapping (HTTP/CLI/queue payload to use-case input and output/error mapping back to protocol).
Adapter integration tests: run against real infrastructure (DB/API/queue) for serialization, schema/query behavior, retries, and timeouts.
End-to-end tests: cover critical user journeys through inbound adapter -> use case -> outbound adapter.
Refactor safety: add characterization tests before extraction; keep them until new boundary behavior is stable and equivalent.
Best Practices Checklist
Domain and use-case layers import only internal types and ports.
Every external dependency is represented by an outbound port.
Validation occurs at boundaries (inbound adapter + use-case invariants).
Use immutable transformations (return new values/entities instead of mutating shared state).
Errors are translated across boundaries (infra errors -> application/domain errors).
Composition root is explicit and easy to audit.
Use cases are testable with simple in-memory fakes for ports.
Refactoring starts from one vertical slice with behavior-preserving tests.
Language/framework specifics stay in adapters, never in domain rules.don't have the plugin yet? install it then click "run inline in claude" again.
restructured raw content into implexa's six mandatory components, added explicit edge-case decision points, clarified external dependency setup requirements, documented error translation across boundaries, provided testability checkpoints, and added measurable outcome signals tied to observable architecture properties.
hexagonal architecture (ports and adapters) keeps business logic independent from frameworks, transport, and persistence. use this skill when designing new systems that need long-term maintainability, refactoring layered code where domain logic mixes with I/O, supporting multiple interfaces for the same use case (HTTP, CLI, workers, cron), or replacing infrastructure without touching business rules. the core pattern is simple: domain models live in the center with zero framework dependencies. use cases orchestrate domain behavior and call abstract outbound ports (repositories, gateways, loggers). adapters sit at the edges, implementing both inbound ports (what clients call) and outbound ports (what infrastructure does). dependency always flows inward: adapters and infrastructure depend on application and domain contracts, never the reverse.
model the use case boundary (inputs: domain knowledge, deployment targets; outputs: use-case interface spec, input/output DTO definitions).
define outbound ports (inputs: use case boundary, external dependencies list; outputs: port interface definitions).
implement the use case orchestration (inputs: use case boundary, port definitions, domain rules; outputs: use-case class/function).
build inbound adapters (inputs: use case, deployment targets; outputs: HTTP controller, CLI handler, queue consumer, or gRPC service).
build outbound adapters (inputs: port definitions, infrastructure details (database connection, SDK client, API endpoint); outputs: concrete port implementations).
centralize wiring in a composition root (inputs: all adapters, use cases, configuration; outputs: single wiring location or factory module).
add tests per boundary (inputs: use case, adapters, infrastructure; outputs: test suite with unit, integration, E2E coverage).
if you are building a new feature from scratch: start at step 1 (model the boundary), then define ports, then implement use case. you have full control to design clean boundaries. once one use case works, duplicate the pattern for additional features.
if you are refactoring tightly coupled existing code: use the strangler pattern (step 6). keep the old endpoint/handler. route one new use case at a time through new ports/adapters. keep old infrastructure adapters in place initially and make them delegate to the new boundary. add tests around the new use case. only delete old code after behavior is verified in production.
if you need to support multiple transports for the same use case (e.g., HTTP REST and CLI for "create order"): define one use case with one input/output DTO. build separate inbound adapters for each protocol (HTTP controller, CLI command). both adapters call the same use case and translate its output to their respective format. ports remain single and transport-agnostic.
if an outbound dependency is synchronous and required (e.g., payment authorization must complete before saving order): call that port in the use case directly (step 3) and let failures propagate as exceptions.
if an outbound dependency is asynchronous or optional (e.g., send email after order creation, log analytics): emit a domain event in the use case, and let a separate event handler (an inbound adapter listening to that event) call the port. this decouples the critical path from side effects.
if you have no explicit composition root yet: create one immediately (step 6). do not let dependency wiring spread across constructors, init functions, or global modules. a single auditable location prevents service-locator anti-patterns and makes testing simpler.
if a port needs to handle retries, timeouts, or rate limits: implement that logic in the adapter, not the use case. the port contract should describe happy path and domain errors; infrastructure resilience is adapter concern.
if you inherit legacy code with no port abstractions: introduce outbound ports first (step 2) by wrapping existing infrastructure calls. this lets you keep old code intact while new use cases depend on the port interface. gradually replace the old implementations when safe.
if the use case is data-heavy (query builder, filtering, pagination): keep query construction in the adapter. the outbound port returns a list of domain objects or value objects, never raw database rows. the adapter owns SQL/query logic; the use case orchestrates domain rules on the results.
successful hexagonal boundary includes:
file location checklist:
measurable outcomes:
you know the skill worked when:
domain layer compiles and runs with zero external dependencies: no imports from web frameworks, ORMs, SDKs, or infrastructure libraries. domain tests run in milliseconds with no setup.
use case is testable with fake ports: write a unit test that instantiates the use case with in-memory port implementations (e.g., FakeOrderRepository that stores orders in a Map). the test passes without touching a database or API.
adding a new protocol (e.g., gRPC, WebSocket) requires only a new inbound adapter: the use case and outbound ports are unchanged. you write one new file, wire it in composition, and reuse existing business logic.
swapping an external dependency requires only a new adapter: e.g., replace PostgreSQL with MongoDB or Stripe with Adyen. the use case and other adapters are unaffected. you implement the outbound port interface in a new file and update composition.
composition root is a single auditable file: grep for "new OrderRepository|new PaymentGateway" returns results only in the composition module. no hidden wiring in constructors or global init functions.
end-to-end test runs and verifies full flow (inbound -> use case -> outbound). you can trace a request from the inbound adapter, through use-case orchestration, to outbound adapter calls, without jumping between hidden globals or magic wiring.
refactoring existing code is safer: characterization tests on the old code now pass, new use case is extracted, old code is replaced one slice at a time, and production behavior is preserved. no big-bang rewrite required.
errors are typed and recoverable: domain ValidationError is thrown by use case, caught by inbound adapter, and translated to HTTP 400. no generic Exception leaking across boundaries.
team can reason about dependencies: "where does this use case fetch users?" -> "via UserRepositoryPort" -> "which adapter implements it?" -> composition module shows PostgresUserRepository is injected -> read the adapter code to see the SQL. no mystery calls or implicit dependencies.
tests run fast and deterministically: domain and use-case unit tests complete in milliseconds. adapter integration tests are isolated per test (e.g., one database transaction per test, rolled back after). no test pollution or flaky async assertions.