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:
- Pick one behavior from the contract.
- Write one failing test at the module's public seam.
- Implement the smallest change that passes.
- Refactor only after the test suite is green.
- 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.
- 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.fscontract - 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> |