Initial commit
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
# 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.
|
||||
|
||||
```typescript
|
||||
// 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_
|
||||
|
||||
```typescript
|
||||
// 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_
|
||||
|
||||
```typescript
|
||||
// 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>` |
|
||||
Reference in New Issue
Block a user