03

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 IDActorCustomer jobSuccess outcomeDenial or recovery evidence
D03-UC-01Tenant checkout clientPlace an order while internal modules are reorganizedExisting 201 response and tenant-scoped order state remain unchangedInvalid order and fake decline still return stable 400/402 responses without side effects
D03-UC-02ParcelFlow developerReplace an infrastructure adapter without rewriting order policyFake and in-memory adapters satisfy explicit ports and shared contract testsContract 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 IDUse case IDsUser storyObservable acceptance conditions
D03-US-01D03-UC-01As a tenant checkout client, I want refactoring to preserve accepted and denied order behaviour, so that internal architecture changes do not disrupt delivery promisesValid request returns 201; invalid request returns 400; fake decline returns 402; each denial leaves order and inventory state unchanged
D03-US-02D03-UC-02As a ParcelFlow developer, I want policy to depend on owned ports, so that I can test failures and replace adapters at one composition pointUnit 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 IDUse case IDsPathTriggerNumbered stepsTerminal evidence
D03-FLOW-01D03-UC-01, D03-UC-02HappyCheckout sends a valid order to the refactored application1. 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-02D03-UC-01, D03-UC-02RecoveryFake payment declines after a temporary reservation, or a new adapter violates its contract1. 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 IDEntry pointResponsible servicesAuthoritative storeFailure evidence
D03-UC-01Hono POST /orders routeHTTP adapter, injected place-order use case, payment port, inventory port, order repository portIn-memory order and inventory adapters behind application-owned portsStable typed rejection, response mismatch, orphaned reservation, or unexpected repository mutation
D03-UC-02Adapter contract test and createApp(deps) compositionContract suite, fake payment adapter, in-memory repositories, composition rootTest fixture state and Git-owned dependency wiringShared 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 entityStore and ownerPrimary keyForeign key or opaque referenceTenant keyMaterial constraintLifecycle and deletionUse case IDs
OrderIn-memory adapter owned through OrderRepository portorder_id UUIDOpaque fake payment_authorization_idtenant_id required in every methodSave then tenant-scoped read returns the same immutable identity and accepted statusCreated after policy succeeds, cleared on restart or test teardown, replaced by durable adapter laterD03-UC-01, D03-UC-02
InventoryItemIn-memory adapter owned through InventoryRepository portComposite tenant_id plus skuReservation ID returned by the same adaptertenant_id is part of key and method inputReservation rejects insufficient quantity; release accepts the exact tenant-scoped reservation onceSeeded by fixture, reserved before fake authorization, retained on success, released on decline, cleared on restartD03-UC-01, D03-UC-02
AdapterContractCaseBun test source owned by application teamStable test nameOpaque reference to port name and adapter factorySynthetic tenant_id fixtureEvery implementation runs identical save/read, tenant isolation, and failure assertionsVersioned with port, extended when semantics change, retained with source historyD03-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.

DuplicationSame knowledge?Decision
Positive order quantity in HTTP and message adaptersYes, if both represent the identical application invariantName one parser/policy and reuse it at both boundaries
Retry count and parcel quantity both compare with zeroNo; operations and commerce change for different reasonsKeep the coincidence duplicated
Tenant-scoped repository query repeated in two methodsYes; data authorization must remain uniformEncapsulate 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.