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.
Customer use cases
When refactoring is judged only by smaller files, customer behaviour can fail even though the dependency graph looks cleaner.
| Use case ID | Actor | Customer job | Success outcome | Denial or recovery evidence |
|---|---|---|---|---|
| D03-UC-01 | Tenant checkout client | Place an order while internal modules are reorganized | Existing 201 response and tenant-scoped order state remain unchanged | Invalid order and fake decline still return stable 400/402 responses without side effects |
| D03-UC-02 | ParcelFlow developer | Replace an infrastructure adapter without rewriting order policy | Fake and in-memory adapters satisfy explicit ports and shared contract tests | Contract suite identifies semantic mismatch; prior adapter can be restored by changing composition only |
Actor-centred user stories
Without stories tied to external evidence, design principles risk becoming vocabulary exercises rather than safeguards for ParcelFlow.
| Story ID | Use case IDs | User story | Observable acceptance conditions |
|---|---|---|---|
| D03-US-01 | D03-UC-01 | As a tenant checkout client, I want refactoring to preserve accepted and denied order behaviour, so that internal architecture changes do not disrupt delivery promises | Valid request returns 201; invalid request returns 400; fake decline returns 402; each denial leaves order and inventory state unchanged |
| D03-US-02 | D03-UC-02 | As a ParcelFlow developer, I want policy to depend on owned ports, so that I can test failures and replace adapters at one composition point | Unit test injects fakes without module mocking, both repositories pass the same contract, and createApp(deps) is the only production wiring site |
End-to-end product flows
If refactoring tests bypass the HTTP boundary and adapter contracts, a dependency inversion can appear correct while the real request fails.
| Flow ID | Use case IDs | Path | Trigger | Numbered steps | Terminal evidence |
|---|---|---|---|---|---|
| D03-FLOW-01 | D03-UC-01, D03-UC-02 | Happy | Checkout sends a valid order to the refactored application | 1. Hono validates transport data.<br>2. Route calls injected placeOrder use case.<br>3. Policy reserves inventory.<br>4. Fake payment authorizes.<br>5. Policy saves the accepted order.<br>6. Route maps the result to 201. | Unchanged response contract, one reservation, one captured fake authorization, one tenant order, and passing adapter contract tests |
| D03-FLOW-02 | D03-UC-01, D03-UC-02 | Recovery | Fake payment declines after a temporary reservation, or a new adapter violates its contract | 1. Policy reserves inventory.<br>2. Fake payment returns a typed decline.<br>3. Policy releases the exact reservation as compensation.<br>4. Route returns 402.<br>5. Contract test verifies the stock count and order store. | Decline evidence, zero saved orders, restored inventory, and an unaffected accepted-order control prove recovery |
System design derived from the flows
When dependencies point from policy into concrete infrastructure, replacing storage or payment changes the highest-value rules and multiplies regression risk.
| Use case ID | Entry point | Responsible services | Authoritative store | Failure evidence |
|---|---|---|---|---|
| D03-UC-01 | Hono POST /orders route | HTTP adapter, injected place-order use case, payment port, inventory port, order repository port | In-memory order and inventory adapters behind application-owned ports | Stable typed rejection, response mismatch, orphaned reservation, or unexpected repository mutation |
| D03-UC-02 | Adapter contract test and createApp(deps) composition | Contract suite, fake payment adapter, in-memory repositories, composition root | Test fixture state and Git-owned dependency wiring | Shared contract failure, unexpected adapter call, or construction error at startup |
Data model and ownership
If ports omit tenant and lifecycle constraints, a future adapter can satisfy TypeScript while storing unsafe or incomplete records.
Generated-application database: Not created in this slice — refactoring preserves the deliberately temporary in-memory order and inventory stores while defining contracts that Day 04 will implement with Bun.SQL and PostgreSQL.
| Record or entity | Store and owner | Primary key | Foreign key or opaque reference | Tenant key | Material constraint | Lifecycle and deletion | Use case IDs |
|---|---|---|---|---|---|---|---|
| Order | In-memory adapter owned through OrderRepository port | order_id UUID | Opaque fake payment_authorization_id | tenant_id required in every method | Save then tenant-scoped read returns the same immutable identity and accepted status | Created after policy succeeds, cleared on restart or test teardown, replaced by durable adapter later | D03-UC-01, D03-UC-02 |
| InventoryItem | In-memory adapter owned through InventoryRepository port | Composite tenant_id plus sku | Reservation ID returned by the same adapter | tenant_id is part of key and method input | Reservation rejects insufficient quantity; release accepts the exact tenant-scoped reservation once | Seeded by fixture, reserved before fake authorization, retained on success, released on decline, cleared on restart | D03-UC-01, D03-UC-02 |
| AdapterContractCase | Bun test source owned by application team | Stable test name | Opaque reference to port name and adapter factory | Synthetic tenant_id fixture | Every implementation runs identical save/read, tenant isolation, and failure assertions | Versioned with port, extended when semantics change, retained with source history | D03-UC-02 |
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.