DRY, SRP, IoC, DI, and the Composition Root
Refactor: make ParcelFlow’s policy independent from Hono, Bun.SQL, and the fake payment adapter without adding a DI container.
The enterprise problem and today’s slice
Enterprise problem: As order logic grows, duplicated policy and hidden infrastructure imports let a harmless transport change alter payment or inventory behaviour, causing checkout regressions that are difficult to isolate.
Whole-course context: ParcelFlow has a tested Hono-on-Bun request boundary and a recorded compatibility decision; today preserves that customer contract while restructuring the code behind it.
Today’s slice: Distinguish DRY from coincidental duplication, apply the single responsibility principle, invert infrastructure dependencies through ports, inject adapters through constructors, and assemble everything in createApp(deps).
End-of-day evidence: Existing request tests remain green, order policy tests run with fakes, and the same adapter contract suite can be applied to the in-memory implementation and later Bun.SQL implementation.
Still unsolved: State is still non-durable, concurrent inventory updates are unsafe, service boundaries are not extracted, and the fake payment adapter remains the only payment implementation.
The thesis is that good modularity changes the direction of knowledge: business policy defines the interfaces it needs, while startup code selects implementations. The smallest complete model is policy, port, and adapter, assembled once at the composition root.
DRY means one source of truth, not zero repetition
When similar-looking code is merged before its reasons for change are understood, one customer rule can accidentally control an unrelated rule and every edit gains collateral effects.
DRY means “don’t repeat yourself”: one authoritative expression for one piece of knowledge. It does not mean every repeated line deserves a helper. In the simple ParcelFlow example, the rule “quantity must be a positive integer” should have one application-level definition if HTTP, a future consumer, and tests all enforce the same business fact. A realistic system may also validate a transport schema that deliberately duplicates shape constraints for early rejection; transport validation and business policy have different owners and error semantics. The failure mode is a generic validateNumber abstraction shared by order quantity, retry count, and parcel weight merely because all are positive today. The decision rule is: deduplicate only when the statements represent the same knowledge and must change together.
| Duplication | Same knowledge? | Decision |
|---|---|---|
| Positive order quantity in HTTP and message adapters | Yes, if both represent the identical application invariant | Name one parser/policy and reuse it at both boundaries |
| Retry count and parcel quantity both compare with zero | No; operations and commerce change for different reasons | Keep the coincidence duplicated |
| Tenant-scoped repository query repeated in two methods | Yes; data authorization must remain uniform | Encapsulate the tenant predicate in the adapter |
SRP separates reasons to change
If one module changes for HTTP, pricing, persistence, and payment policy, every release touches a wide blast radius and ownership becomes unclear.
The single responsibility principle (SRP) says a unit should have one primary reason to change, not that every function must be tiny. In the simple ParcelFlow slice, Hono routes change for HTTP concerns, the place-order use case changes for ordering policy, and a repository adapter changes for storage. In a realistic team, these boundaries let contract owners review relevant changes while still deploying one modular monolith. The failure mode is splitting each five-line helper into a “service” while shared state and coordinated releases remain unchanged. The decision rule is: split around independently changing policy and ownership, not line count.
export type PlaceOrder = (command: PlaceOrderCommand) => Promise<PlaceOrderResult>;
export function makePlaceOrder(deps: PlaceOrderDependencies): PlaceOrder {
return async (command) => {
assertValidOrder(command);
const reservation = await deps.inventory.reserve({
tenantId: command.tenantId,
sku: command.sku,
quantity: command.quantity,
});
try {
const authorization = await deps.payment.authorize({
tenantId: command.tenantId,
amountMinor: command.amountMinor,
});
return await deps.orders.save({
orderId: deps.ids.next(),
...command,
paymentAuthorizationId: authorization.id,
status: "accepted",
});
} catch (error) {
await deps.inventory.release(reservation.id);
throw error;
}
};
}
This compensation is sufficient only because the course payment adapter is fake and creates no external financial effect. A real adapter would also need an idempotent authorization key and an explicit void or capture protocol; that boundary remains outside this course.
IoC, constructor DI, ports, and adapters
When policy constructs new PaymentClient() or imports a global repository, tests cannot select failure behaviour and production configuration leaks into business decisions.
Inversion of control (IoC) means a higher-level policy no longer decides the concrete mechanism it calls. Dependency injection (DI) is the practical technique of passing those collaborators in, often through a constructor or factory. A port is the application-owned interface; an adapter implements that port for a mechanism such as memory, Bun.SQL, or the fake payment service. The simple example passes a PaymentPort into makePlaceOrder. A realistic example also injects ID generation, clock, transaction scope, and telemetry so tests are deterministic. The failure mode is a service locator or runtime module patch that hides requirements. The decision rule is: required dependencies appear in the construction signature and business code imports only owned contracts.
export interface PaymentPort {
authorize(input: {
tenantId: string;
amountMinor: number;
}): Promise<{ id: string }>;
}
export interface OrderRepository {
save(order: Order): Promise<Order>;
findById(tenantId: string, orderId: string): Promise<Order | undefined>;
}
export interface InventoryRepository {
reserve(input: {
tenantId: string;
sku: string;
quantity: number;
}): Promise<{ id: string }>;
release(reservationId: string): Promise<void>;
}
export class PlaceOrderService {
constructor(
private readonly payments: PaymentPort,
private readonly inventory: InventoryRepository,
private readonly orders: OrderRepository,
) {}
async execute(command: PlaceOrderCommand): Promise<Order> {
const reservation = await this.inventory.reserve(command);
try {
const authorization = await this.payments.authorize(command);
return await this.orders.save(toAcceptedOrder(command, authorization.id));
} catch (error) {
await this.inventory.release(reservation.id);
throw error;
}
}
}
One explicit composition root
If dependencies are assembled across routes and feature modules, configuration failures occur during traffic and no reviewer can see the runtime graph in one place.
A composition root is the single startup location that creates concrete adapters and connects them to application ports. The simple ParcelFlow root builds memory repositories, fake payment, place-order policy, and Hono adapter. The realistic Day 04 root will replace memory with Bun.SQL without editing policy. The failure mode is introducing a DI container that discovers classes by metadata, obscures lifetime, and solves no demonstrated graph complexity. The decision rule is: use explicit factories until manual wiring is measurably repetitive or error-prone; this course uses no DI container.
export function createApp(deps: {
payment: PaymentPort;
orders: OrderRepository;
inventory: InventoryRepository;
ids: IdSource;
}) {
const placeOrder = makePlaceOrder(deps);
const http = createHttpApp({ placeOrder });
return { fetch: http.fetch, placeOrder };
}
const app = createApp({
payment: fakePayment({ declineTokens: ["decline"] }),
orders: memoryOrders(),
inventory: memoryInventory(),
ids: cryptoIds(),
});
Bun.serve({ port: 3000, fetch: app.fetch });
Fakes and adapter contract tests
When each adapter has bespoke tests, two implementations can both look green while disagreeing on tenant isolation, missing records, or duplicate saves.
The general rule is to test policy with controlled fakes and test every adapter against one semantic contract. The simple fake captures calls and returns a fixed authorization. A decline test also records the reservation ID passed to release and proves inventory returns to its original count before the 402 response. A realistic contract suite receives an adapter factory and verifies save/read identity, tenant denial, single-use release, duplicate handling, and cleanup. The failure mode is mocking implementation details such as SQL strings or asserting only that a decline was thrown while an orphaned reservation remains. The decision rule is: policy tests assert sequence and compensation; contract tests assert observable port semantics.
import { describe, expect, test } from "bun:test";
export function orderRepositoryContract(
name: string,
makeRepository: () => Promise<OrderRepository>,
) {
describe(name, () => {
test("never returns another tenant's order", async () => {
const repository = await makeRepository();
await repository.save(orderFixture({ tenantId: "tenant-a", orderId: "o-1" }));
expect(await repository.findById("tenant-b", "o-1")).toBeUndefined();
});
});
}
orderRepositoryContract("memory orders", async () => memoryOrders());
bun test tests/order-policy.test.ts
bun test tests/order-repository.contract.test.ts
bunx tsc --noEmit
These snippets specify runnable seams and expected evidence; this lesson does not claim the commands were executed here.
Key takeaways
Without explicit reasons to change and dependency direction, a modular monolith becomes a directory-shaped monolith.
- DRY removes duplicate knowledge, not all similar syntax.
- SRP separates independent reasons to change; it does not demand microscopic classes.
- IoC changes who chooses a mechanism; constructor DI makes required collaborators visible.
- Ports belong to application policy; adapters belong to infrastructure edges.
- Reserve inventory before fake authorization and compensate the exact reservation on decline or persistence failure.
createApp(deps)is the composition root, and ParcelFlow does not need a DI container.- Fakes test policy decisions; shared adapter contracts test replaceability.
Checklist
If an adapter cannot be replaced in composition alone, infrastructure still leaks into policy and the boundary is incomplete.
- [ ] Keep Hono, Bun.SQL, and fake payment types outside order policy.
- [ ] Name one reason to change for each module.
- [ ] Review repeated code for shared knowledge before applying DRY.
- [ ] Inject clock, IDs, repositories, and payment through explicit construction.
- [ ] Prove fake-payment decline releases the exact reservation and saves no order.
- [ ] Run the same tenant-isolation contract against every repository adapter.
- [ ] Preserve accepted and denied HTTP evidence through the refactor.
Sources
When architecture terms are detached from working evidence, teams can agree on labels while implementing opposite dependency directions.