Files
2026-05-25 05:47:28 +00:00

6.9 KiB

4. Implementation Guide

Goal: Mechanical translation from F# Design to TypeScript/Effect Code, using TDD during implementation.

TDD Inside Phase 5

Keep the F# design fixed first. TDD starts only after the contract is frozen.

Use short vertical cycles:

  1. Pick one behavior from the contract.
  2. Write one failing test at the module's public seam.
  3. Implement the smallest change that passes.
  4. Refactor only after the test suite is green.
  5. Avoid premature indirection: keep logic combined by default, and extract only when the green code shows a real gain in nameability, testability, reuse, boundary clarity, or duplicated complexity.
  6. Good names help reveal useful extractions, but they do not justify an unnecessary layer by themselves.

Choose the test style by seam:

  • context-local models and policies: favor example-based tests plus property-based tests for invariants, boundary conditions, and illegal-state protection.
  • top-level src/workflows/: favor scenario tests using Effect layers or in-memory adapters. Verify the workflow's observable result, not internal calls.
  • context adapters: use contract tests for shared adapter behavior and integration tests when talking to real infrastructure.

The frozen design tells you what to test:

  • Command + Events -> workflow scenarios
  • Policy signature -> decision examples and failure cases
  • State model -> invariants and state-transition properties

The point is not to replace TDFDDD with TDD. The design process decides what must be true; TDD drives the safe, incremental implementation of that design.

Slice Implementation by Workflow

The current artifact structure already assumes one F# blueprint per workflow slice. Do not combine multiple workflow slices into one assembly pass.

Prefer one workflow slice per implementation pass:

  • one 04-blueprint.fs contract
  • one focused set of tests
  • one reviewable code change
  • one fresh context if the work is large enough to benefit from it

This keeps context windows smaller, review easier, and generated code more reliable. The feature-level status file plus the workflow-slice artifacts should be enough to let a new context pick up the next slice safely.

Review Focus After Implementation

Review rigor should concentrate at the places where business meaning and system shape live:

  • Review closely: contracts, domain types, policies, workflows, and adapter seams.
  • Review more lightly: service and adapter internals, as long as they satisfy the seam, keep business logic out, and behave correctly through tests.

This is not permission to be sloppy in infrastructure code. The goal is locality: when types are strong, policies are pure, and seams are well-designed, a reviewer should not need to re-derive the whole business model from inside each adapter.

The Pattern: Decide -> Match -> Apply

This is the standard orchestration pattern for all workflows. It keeps the rules pure, the math pure, and the side effects isolated.

1. The Policy (Pure Rules)

Located in: the owning bounded context by default; only top-level when the seam is truly cross-context

A pure decision should usually still be a function, but it does not always need its own top-level policy module. Keep a decision as a local pure function inside the owning context when that is the clearest choice. Promote it to a broader seam only when extraction materially improves nameability, testability, reuse, or boundary clarity. Prefer local logic when extraction would only add indirection.

// LoadPolicy.ts
export const decide = (truck: LoadingTruck, pkg: Package): Result<PackageLoaded, LoadFailure> => {
  // 1. Check Rules
  if (truck.currentLoad.weight + pkg.weight > truck.capacity.maxWeight) {
    return Result.fail({ tag: "LoadFailure", reason: "OverWeight", ... })
  }

  if (truck.currentLoad.volume + pkg.volume > truck.capacity.maxVolume) {
    return Result.fail({ tag: "LoadFailure", reason: "InsufficientVolume", ... })
  }

  // 2. Return Success Event (Do NOT calculate new state here, just return the fact)
  return Result.succeed({
    tag: "PackageLoaded",
    package: pkg,
    truckId: truck.id
  })
}

2. The Model (Pure Math)

Located in: the owning bounded context's local model seam

// Truck.ts
// The "Reducer" - takes state + data -> new state
export const applyLoad = (truck: LoadingTruck, pkg: Package): LoadingTruck => ({
  ...truck,
  currentLoad: {
    weight: truck.currentLoad.weight + pkg.weight,
    volume: truck.currentLoad.volume + pkg.volume,
  },
});

3. The Workflow (Impure Shell)

Located in: the owning context for local orchestration, or top-level src/workflows/ for cross-context orchestration

// LoadWorkflow.ts
const loadPackageWorkflow = (truckId: TruckId, packageId: PackageId) =>
  Effect.gen(function* (_) {
    // 0. Get Dependencies
    const repo = yield* Database;

    // 1. Gather Data (IO)
    const truck = yield* _(repo.getTruck(truckId));
    const pkg = yield* _(repo.getPackage(packageId));

    // 2. Execute Policy (Decide)
    // The Workflow does not know logic. It just asks the Policy.
    const decision = LoadPolicy.decide(truck, pkg);

    // 3. Match & Apply (Orchestration)
    return yield* _(
      Match.value(decision).pipe(
        // CASE: SUCCESS
        Match.when({ _tag: "PackageLoaded" }, (event) =>
          Effect.gen(function* (_) {
            // A. Apply the change (Pure Math)
            // The Policy said "Yes", so we calculate the new state.
            const newTruckState = Truck.applyLoad(truck, event.package);

            // B. Persist the new state (IO)
            yield* _(repo.saveTruck(newTruckState));

            // C. Return the response
            return {
              success: true,
              updatedCapacity: newTruckState.currentLoad,
            };
          }),
        ),

        // CASE: FAILURE
        Match.when({ _tag: "LoadFailure" }, (failure) =>
          // We can choose to return a failure response OR fail the effect
          Effect.fail(new BusinessError(failure.reason)),
        ),

        Match.exhaustive,
      ),
    );
  });

Translation Table

Concept F# Design TypeScript / Effect Implementation
Primitive type Weight = int<kg> type Weight = number & Brand<"Kg">
Structure type User = { Name: string } const User = Schema.Struct({ name: Schema.String })
Union type State = A | B Schema.Union(A, B) (Discriminated Union)
Function Input -> Output (input: Input) => Output
Result Result<Success, Error> Either<Error, Success> (Effect's Either)
Async Async<Result<T, E>> Effect<T, E>