Clean and hexagonal architecture
The boundary is inside versus outside
Hexagonal architecture is also called ports and adapters. The application core contains the business behavior. User interfaces, databases, message brokers, external APIs, and test drivers sit outside it.
The hexagon is a drawing device. It gives the application several sides where external actors can connect. It does not require six components, six layers, or a particular folder tree.
Wrong "A controller, service, and repository folder make an application hexagonal."
Right "The useful boundary separates application behavior from technology-specific interaction. Ports describe the conversations across that boundary, and adapters handle the technology."
What ports and adapters do
A port is an application-facing contract. An incoming port describes something an external actor can ask the application to do. An outgoing port describes something the application needs from the outside world.
An adapter translates between a specific technology and a port. An HTTP controller can adapt a request to an incoming use case. A SQL repository can adapt an outgoing persistence port to database operations. A command-line program and an HTTP controller can both drive the same incoming port.
// Application-owned contracts
interface PlaceOrder {
execute(input: PlaceOrderInput): Promise<OrderReceipt>;
}
interface SaveOrder {
save(order: Order): Promise<void>;
}
// Technology-specific adapters
class HttpPlaceOrderController {
constructor(private readonly placeOrder: PlaceOrder) {}
}
class SqlOrderRepository implements SaveOrder {
async save(order: Order): Promise<void> {
// Translate Order to the database representation here.
}
}
The port names describe application intent: PlaceOrder and SaveOrder. Names such as PostOrderRoute or InsertOrderRow would pull HTTP or SQL vocabulary into the core contract.
Keep business behavior out of infrastructure
Suppose a discount depends on order value. If the rule lives in an HTTP controller, a batch import must either call HTTP-specific code or reimplement the rule. If it lives in a database trigger, a test of that rule needs the database behavior available. Keeping the rule in the core lets each external actor call the same implementation.
class Order {
constructor(readonly totalCents: number) {}
discountedTotalCents(): number {
return this.totalCents >= 10_000
? Math.floor(this.totalCents * 0.9)
: this.totalCents;
}
}
Wrong "Infrastructure is outside, so it contains no important code."
Right "Adapters contain important translation and integration behavior. The separation keeps that behavior from becoming business policy."
Why the boundary changes testing
Cockburn's original description allows an application to be driven by an automated test and to use an in-memory replacement for a database. A test adapter can call the same port as the production entry point. An in-memory adapter can implement the same outgoing port as the SQL adapter.
class InMemoryOrderRepository implements SaveOrder {
readonly saved: Order[] = [];
async save(order: Order): Promise<void> {
this.saved.push(order);
}
}
This makes core tests independent of HTTP servers and databases. The SQL adapter still needs integration tests. Replacing infrastructure in a core test does not verify SQL mappings, constraints, or queries.
Runtime calls and source dependencies are different arrows
The dependency rule says source-code dependencies point inward, toward higher-level application policy. A database adapter may import core types and implement a core-owned port. The core must not import that adapter or its database library.
Runtime control can travel the other way. A use case can call an injected repository object, and that object can execute SQL. The use case compiled against the port, while the concrete adapter was selected elsewhere.
// core/place-order.ts
export interface OrderRepository {
save(order: Order): Promise<void>;
}
export class PlaceOrderService {
constructor(private readonly orders: OrderRepository) {}
async execute(order: Order): Promise<void> {
await this.orders.save(order);
}
}
// infrastructure/sql-order-repository.ts
import {
type OrderRepository,
type Order,
} from "../core/place-order";
export class SqlOrderRepository implements OrderRepository {
async save(order: Order): Promise<void> {
// Map the domain value and execute the SQL operation.
}
}
The infrastructure file depends on the core contract. Dependency injection does not create this architecture by itself. The location and ownership of the contract determine the compile-time direction.
Wrong "The repository interface belongs in infrastructure because the database adapter implements it."
Right "The core owns the capability it requires. Infrastructure depends on that capability and supplies an implementation."
Wire concrete adapters at the edge
The composition root knows the concrete types because something must assemble the running program. Microsoft describes startup wiring as the place where implementation types are connected to core interfaces.
// main.ts
const orders = new SqlOrderRepository(databaseConnection);
const placeOrder = new PlaceOrderService(orders);
const controller = new HttpPlaceOrderController(placeOrder);
startHttpServer(controller);
Keep this knowledge local. If application services construct SqlOrderRepository themselves, their source dependencies point outward again.
Boundary data belongs to the boundary
Framework request objects and database row types carry details from outer mechanisms. Passing them into the core makes the inner code know those mechanisms. Clean Architecture recommends simple data structures across boundaries in forms convenient for the inner layer.
Map at the adapter:
type PlaceOrderInput = {
customerId: string;
lines: Array<{ productId: string; quantity: number }>;
};
async function handlePostOrder(request: HttpRequest): Promise<HttpResponse> {
const input: PlaceOrderInput = {
customerId: request.body.customer_id,
lines: request.body.items.map((item) => ({
productId: item.sku,
quantity: item.count,
})),
};
const receipt = await placeOrder.execute(input);
return { status: 201, body: { order_id: receipt.orderId } };
}
Wrong "A generated ORM entity is a domain entity because both represent the same order."
Right "A shared business meaning does not erase dependencies. If the core imports an ORM-generated type, the ORM has crossed the boundary."
Test each side for its own contract
Core tests can use small fakes for outgoing ports and invoke incoming ports without a server. This removes external setup from tests of business behavior.
Adapter tests cover the details omitted by those fakes. A database adapter test exercises mappings and queries against the supported database. An HTTP adapter test covers request parsing and response mapping. The split creates faster core feedback while retaining evidence about real integrations.
Protect policy, not every class
Clean and hexagonal architecture express the same central pressure in different drawings: keep high-level policy independent of outer mechanisms. Cockburn draws an inside and an outside connected by ports. Clean Architecture draws concentric circles whose source-code dependencies point inward.
Neither source requires one interface per class. Robert Martin states that the number of circles is schematic. A boundary earns its cost when it protects application policy from a volatile or awkward external concern, supports more than one driver, or enables useful isolated tests.
// The port states one application need. It does not mirror a database client.
interface ReserveInventory {
reserve(lines: OrderLine[]): Promise<ReservationResult>;
}
class Checkout {
constructor(private readonly inventory: ReserveInventory) {}
async execute(order: Order): Promise<CheckoutResult> {
const reservation = await this.inventory.reserve(order.lines);
return order.completeWith(reservation);
}
}
Wrong "Clean architecture means adding an interface in front of every concrete type."
Right "Ports describe conversations across the application boundary. Internal classes that do not cross that boundary need no adapter ceremony."
Choose the contract from the inside
An outgoing port should express what the use case needs. Copying a vendor API into an interface preserves the vendor's concepts and call shape, even if the import disappears.
// Vendor-shaped abstraction leaks the outside model.
interface PaymentPort {
createProviderIntent(
providerCustomerId: string,
providerPaymentMethodId: string,
captureMode: "automatic" | "manual",
): Promise<ProviderIntent>;
}
Define a narrower application contract, then translate in the adapter:
interface CollectPayment {
collect(orderId: string, amount: Money): Promise<PaymentResult>;
}
class ProviderPaymentAdapter implements CollectPayment {
async collect(
orderId: string,
amount: Money,
): Promise<PaymentResult> {
// Translate application values to the provider request and back.
}
}
This translation is purposeful duplication. It prevents outer data formats from becoming the core's model. It also creates code that must be named, reviewed, and tested.
Compare with a pragmatic layered design
A traditional layered application commonly has UI, business logic, and data access layers. Microsoft notes that each layer has a known responsibility, but the common top-to-bottom dependency direction can make business logic depend on data-access details. Applying dependency inversion changes that direction without changing deployment: a clean architecture can still ship as one monolith and one process.
AWS lists maintenance overhead as a consideration for ports and adapters. Extra adapter code is most justified when a component needs several input or output implementations, or when those technologies are expected to change. AWS also notes that the additional layer might add latency.
For a small read-only service with one stable database and little business policy, a controller, query service, and data-access layer may be clearer. Keep responsibilities separate and test the real queries. Extract a port when there is a capability worth protecting.
For a pricing or fulfillment component used by HTTP, scheduled jobs, and messages, the pressure is different. One incoming application contract lets each driver call the same policy. Outgoing ports let core tests cover policy without provisioning each external system.
Wrong "Hexagonal architecture is always more maintainable because more boundaries mean less coupling."
Right "A boundary trades coupling to an external detail for contracts, mapping, wiring, and tests. The trade pays when the protected policy and expected changes are more expensive than that overhead."
Tests prove different things
The original ports-and-adapters article uses test drivers and an in-memory database replacement so the application can run without its eventual UI or database. AWS likewise describes isolated tests of business logic with injected mocks.
A core test shows how the core behaves with a test double that implements the port. It cannot prove that the real adapter conforms to that port, maps SQL fields correctly, or sends a request that its provider interprets as expected. Keep adapter integration tests beside the fast core suite.
describe("SqlOrderRepository", () => {
it("round-trips an order through the supported database", async () => {
const repository = new SqlOrderRepository(testDatabase);
const order = Order.open("order-42");
await repository.save(order);
expect(await repository.get("order-42")).toEqual(order);
});
});
The architecture changes where tests attach and which dependencies they require. It does not remove the need to test infrastructure behavior.
dig deeper
Primary sources behind these notes - the specs and official docs worth reading in full.
beta
The interviewer part is in the works.
The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.