Initial commit
This commit is contained in:
@@ -0,0 +1,637 @@
|
||||
# Effect-TS Best Practices Guide
|
||||
|
||||
This guide documents best practices for writing Effect-TS code, following principles from:
|
||||
- **"Grokking Simplicity"** by Eric Normand (functional programming fundamentals)
|
||||
- **"Domain Modeling Made Functional"** by Scott Wlaschin (domain-driven design)
|
||||
- **Effect-TS patterns** (modern TypeScript effect systems)
|
||||
|
||||
## Core Principles
|
||||
|
||||
### 1. Domain-Driven Design (Scott Wlaschin)
|
||||
|
||||
#### Make Illegal States Unrepresentable
|
||||
|
||||
Use the type system to prevent invalid domain states:
|
||||
|
||||
```typescript
|
||||
// ❌ BAD: Primitives allow invalid states
|
||||
import {CpuCount} from "./VmSpec";
|
||||
|
||||
interface VmConfig {
|
||||
cpus: number // Could be 0, negative, or 1000
|
||||
memory: number // Could be any number
|
||||
status: string // Could be typo: "runing"
|
||||
}
|
||||
|
||||
// ✅ GOOD: Types enforce domain rules
|
||||
const Slug = Schema.String.pipe(
|
||||
Schema.pattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {
|
||||
message: () =>
|
||||
"Slug must be lowercase letters, numbers, and hyphens only (e.g., 'my-snippet-name')",
|
||||
}),
|
||||
Schema.minLength(1),
|
||||
Schema.maxLength(100),
|
||||
Schema.brand('Slug'),
|
||||
);
|
||||
export type Slug = typeof Slug.Type;
|
||||
|
||||
|
||||
export const CpuCount = Schema.Number.pipe(
|
||||
Schema.int(),
|
||||
Schema.between(1, 64), // Physical constraint
|
||||
Schema.brand("CpuCountBrand" as const)
|
||||
)
|
||||
// Make sure to bind type
|
||||
export type CpuCount = typeof CpuCount.Type;
|
||||
|
||||
export const VmStatus = Schema.Literal("running", "stopped", "error").pipe(
|
||||
Schema.brand("VmStatus")
|
||||
)
|
||||
export type VmStatus = typeof VmStatus.Type
|
||||
|
||||
interface VmConfig {
|
||||
slug: Slug
|
||||
cpus: CpuCount // Guaranteed valid
|
||||
memory: MemoryMB // Guaranteed valid
|
||||
status: VmStatus // Typos impossible
|
||||
}
|
||||
```
|
||||
|
||||
#### Model the Domain Precisely
|
||||
|
||||
Domain types should exactly match business concepts:
|
||||
|
||||
```typescript
|
||||
// ❌ BAD: Generic, imprecise
|
||||
type NetworkConfig = {
|
||||
vlan: number
|
||||
subnet: string
|
||||
}
|
||||
|
||||
// ✅ GOOD: Precise domain model
|
||||
export const VlanId = Schema.Number.pipe(
|
||||
Schema.int(),
|
||||
Schema.between(1, 4094), // IEEE 802.1Q valid range
|
||||
Schema.brand("VlanId")
|
||||
)
|
||||
export type VlanId = typeof VlanId.Type;
|
||||
|
||||
export const SubnetCidr = Schema.String.pipe(
|
||||
Schema.pattern(/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/\d{1,2}$/),
|
||||
Schema.brand("SubnetCidr")
|
||||
)
|
||||
|
||||
export type SubnetCidr = typeof SubnetCidr.Type;
|
||||
|
||||
export const NetworkSegment = Schema.Struct({
|
||||
name: Schema.String,
|
||||
vlanId: VlanId,
|
||||
subnet: SubnetCidr,
|
||||
gateway: IpAddress,
|
||||
})
|
||||
```
|
||||
|
||||
#### Use Ubiquitous Language
|
||||
|
||||
Domain types should use terminology from the problem domain:
|
||||
|
||||
```typescript
|
||||
// ❌ BAD: Technical/generic terms
|
||||
interface Config {
|
||||
val: number
|
||||
data: string
|
||||
}
|
||||
|
||||
// ✅ GOOD: Domain language
|
||||
export const TestSection = Schema.Struct({
|
||||
name: TestSectionName,
|
||||
estimatedDuration: EstimatedDuration,
|
||||
vmSpecs: Schema.Array(VmSpec),
|
||||
requiresNestedVirt: Schema.Boolean
|
||||
})
|
||||
```
|
||||
|
||||
## Tagged Error Handling
|
||||
|
||||
### ❌ DON'T: Throw generic errors
|
||||
|
||||
Never use generic `Error` or `throw` statements in Effect code.
|
||||
|
||||
```typescript
|
||||
// ❌ BAD - Generic Error loses type information
|
||||
const validateTask = (data: unknown): Effect.Effect<Task, Error> => {
|
||||
return Effect.try({
|
||||
try: () => {
|
||||
if (!data) {
|
||||
throw new Error("Invalid data"); // ❌ Generic error
|
||||
}
|
||||
return data as Task;
|
||||
},
|
||||
catch: (e) => new Error(String(e)) // ❌ Loses error context
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
### ✅ DO: Use tagged error classes
|
||||
|
||||
|
||||
```typescript
|
||||
class ValidationError extends Data.TaggedError("ValidationError")<{
|
||||
message: string
|
||||
}> {}
|
||||
|
||||
|
||||
// Use Effect.fail instead of throw
|
||||
const validateTask = (data: unknown): Effect.Effect<Task, ValidationError> => {
|
||||
if (!data || typeof data !== "object") {
|
||||
return Effect.fail(new ValidationError("Data must be an object")); // ✅
|
||||
}
|
||||
|
||||
const task = data as any;
|
||||
if (!task.text) {
|
||||
return Effect.fail(new ValidationError("Missing required field: text")); // ✅
|
||||
}
|
||||
|
||||
return Effect.succeed(task as Task);
|
||||
};
|
||||
```
|
||||
|
||||
### Why Tagged Errors?
|
||||
|
||||
1. **Type-safe error handling**: Function signatures show exactly what can fail
|
||||
2. **Pattern matching**: Use `Effect.catchTag` to handle specific errors
|
||||
3. **Better debugging**: Error `_tag` shows up in stack traces
|
||||
4. **Self-documenting**: Compiler enforces handling all error cases
|
||||
5. **Composable**: Errors compose through Effect chains
|
||||
|
||||
### Pattern Matching on Errors
|
||||
|
||||
```typescript
|
||||
const program = pipe(
|
||||
loadTask(id),
|
||||
// Catch specific error types by tag
|
||||
Effect.catchTag("NotFoundError", (error) =>
|
||||
Effect.succeed(createDefaultTask(error.id))
|
||||
),
|
||||
Effect.catchTag("ValidationError", (error) =>
|
||||
Effect.fail(new BadRequestError(error.message))
|
||||
),
|
||||
// Catch all remaining errors
|
||||
Effect.catchAll((error) =>
|
||||
Effect.fail(new UnknownError(String(error)))
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
### Effect.fail vs throw
|
||||
|
||||
```typescript
|
||||
// ❌ NEVER use throw
|
||||
const bad = () => {
|
||||
if (condition) {
|
||||
throw new Error("Bad!"); // ❌ Not type-safe
|
||||
}
|
||||
};
|
||||
|
||||
// ✅ ALWAYS use Effect.fail
|
||||
const good = () => {
|
||||
if (condition) {
|
||||
return Effect.fail(new ValidationError("Good!")); // ✅ Type-safe
|
||||
}
|
||||
return Effect.succeed(result);
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## pipe vs Effect.gen
|
||||
|
||||
### DEFAULT: Always use pipe
|
||||
|
||||
**Golden Rule: Use `pipe` everywhere by default. Remember to use effects `do` notation
|
||||
|
||||
### ✅ Use pipe for linear chains
|
||||
|
||||
```typescript
|
||||
// ✅ GOOD - Linear transformation chain
|
||||
const updateTaskText = (id: TaskId, newText: string) =>
|
||||
pipe(
|
||||
repo.getById(id),
|
||||
Effect.flatMap((task) => updateText(task, newText)),
|
||||
Effect.tap((updated) => repo.save(updated))
|
||||
);
|
||||
```
|
||||
|
||||
### ✅ Use pipe even with conditionals
|
||||
|
||||
```typescript
|
||||
// ✅ GOOD - Simple conditional with ternary
|
||||
const completeTask = (id: TaskId) =>
|
||||
pipe(
|
||||
repo.getById(id),
|
||||
Effect.flatMap((task) =>
|
||||
task.state._tag === "Done"
|
||||
? Effect.succeed(task) // Already done
|
||||
: pipe(
|
||||
transitionState(task, Done),
|
||||
Effect.tap((updated) => repo.save(updated))
|
||||
)
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
### ❌ Bad: Nested pipes become unreadable
|
||||
|
||||
```typescript
|
||||
// ❌ BAD - Too many nested pipes (hard to read)
|
||||
const complexOperation = (id: TaskId) =>
|
||||
pipe(
|
||||
repo.getById(id),
|
||||
Effect.flatMap((task) =>
|
||||
pipe(
|
||||
repo.getParent(task.parentId),
|
||||
Effect.flatMap((parent) =>
|
||||
pipe(
|
||||
repo.getChildren(task.id),
|
||||
Effect.flatMap((children) =>
|
||||
pipe(
|
||||
validateHierarchy(parent, task, children),
|
||||
Effect.flatMap((valid) =>
|
||||
valid
|
||||
? pipe(
|
||||
updateTask(task),
|
||||
Effect.flatMap(() => notifyParent(parent))
|
||||
)
|
||||
: Effect.fail(new ValidationError("Invalid"))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
); // 😱 Pyramid of doom!
|
||||
```
|
||||
|
||||
## Service Pattern
|
||||
|
||||
### Effect.Service Pattern (Official)
|
||||
|
||||
Use the official `Effect.Service` class pattern from the docs.
|
||||
|
||||
// TODO convert to do notation
|
||||
|
||||
```typescript
|
||||
import { Effect, Layer } from "effect";
|
||||
|
||||
export class TaskQueryService extends Effect.Service<TaskQueryService>()(
|
||||
"TaskQueryService",
|
||||
{
|
||||
effect: Effect.gen(function* () {
|
||||
// 1. Get dependencies from Context
|
||||
const repo = yield* TaskRepository;
|
||||
|
||||
// 2. Define operations as const functions
|
||||
const getTask = (id: TaskId) => repo.getById(id);
|
||||
|
||||
const getAllTasks = () => repo.getAll();
|
||||
|
||||
const getTaskTree = (rootId: TaskId) =>
|
||||
pipe(
|
||||
repo.getAll(),
|
||||
Effect.map((tasks) => buildTaskTree(tasks, rootId)),
|
||||
Effect.flatMap((tree) =>
|
||||
tree
|
||||
? Effect.succeed(tree)
|
||||
: Effect.fail(NotFoundError.make(rootId))
|
||||
)
|
||||
);
|
||||
|
||||
// 3. Return service object as const
|
||||
return {
|
||||
getTask,
|
||||
getAllTasks,
|
||||
getTaskTree,
|
||||
} as const;
|
||||
}),
|
||||
dependencies: [], // Can list dependencies for Layer.provideMerge
|
||||
}
|
||||
) {
|
||||
// 4. Optional: Add Test layer
|
||||
static Test = this.Default.pipe(
|
||||
Layer.provide(InMemoryTaskRepositoryTest)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- Use `Effect.gen` inside `effect` (this is the ONE place it's OK)
|
||||
- Close over dependencies with `yield*`
|
||||
- Return operations as `const` object
|
||||
- `.Default` provides the live layer automatically
|
||||
- `.Test` can provide mock layers
|
||||
|
||||
### Using Services
|
||||
|
||||
```typescript
|
||||
// In application code
|
||||
export const updateName = (
|
||||
task: Task,
|
||||
newName: validName
|
||||
): E.Effect<void, TaskError, PouchDBRepositoryLive> =>
|
||||
pipe(
|
||||
PouchDBRepositoryLive,
|
||||
E.tap((db) => db.create({...task, name: newName})), // the output is still the dep
|
||||
E.tap((db) => db.sync()), // the output is still the dep
|
||||
E.asVoid, // discarding all and returning void
|
||||
);
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const queries = yield* TaskQueryService;
|
||||
const tasks = yield* queries.getAllTasks();
|
||||
return tasks;
|
||||
});
|
||||
|
||||
// With proper layers
|
||||
const AppLive = Layer.mergeAll(
|
||||
PouchDBRepositoryLive(db),
|
||||
TaskQueryService.Default,
|
||||
TaskCommandService.Default
|
||||
);
|
||||
|
||||
Effect.runPromise(program.pipe(Effect.provide(AppLive)));
|
||||
```
|
||||
|
||||
#### Railway-Oriented Programming
|
||||
|
||||
Use Effect's error handling for domain workflows:
|
||||
|
||||
```typescript
|
||||
// Each step can succeed or fail
|
||||
const provisionWorkflow = (spec: VmSpec) =>
|
||||
Effect.gen(function* () {
|
||||
// Validate
|
||||
yield* validateVmSpec(spec) // Effect<void, ValidationError>
|
||||
|
||||
// Allocate resources
|
||||
const allocation = yield* allocateResources(spec) // Effect<Allocation, ResourceError>
|
||||
|
||||
// Provision
|
||||
const vm = yield* provisionVm(spec, allocation) // Effect<VM, ProvisionError>
|
||||
|
||||
// Configure
|
||||
yield* configureVm(vm) // Effect<void, ConfigError>
|
||||
|
||||
return vm
|
||||
}).pipe(
|
||||
// Handle errors at appropriate level
|
||||
Effect.catchTag("ValidationError", (e) => /* handle */),
|
||||
Effect.catchTag("ResourceError", (e) => /* handle */),
|
||||
)
|
||||
```
|
||||
|
||||
### 2. Service Definition Pattern
|
||||
|
||||
Always use `Effect.Service()()` pattern with explicit dependencies:
|
||||
|
||||
```typescript
|
||||
// ❌ BAD: Static methods, context.Tag
|
||||
export class SSHConnection extends Context.Tag("SSHConnection")<...>() {
|
||||
static live = Layer.effect(...)
|
||||
}
|
||||
|
||||
// ✅ GOOD: Service pattern with dependencies
|
||||
export class SSHConnection extends Effect.Service<SSHConnection>()(
|
||||
"SSHConnection",
|
||||
{
|
||||
effect: Effect.gen(function* () {
|
||||
// Implementation
|
||||
return {
|
||||
execute: (cmd: Command) => // ...
|
||||
}
|
||||
}),
|
||||
dependencies: [] // Explicit dependencies
|
||||
}
|
||||
) {
|
||||
static Test = Layer.succeed(SSHConnection, {
|
||||
execute: () => Effect.succeed(mockResult)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Schema: Use Schema.Struct, Not Schema.Class
|
||||
|
||||
```typescript
|
||||
// ❌ BAD: Schema.Class with methods using `this`
|
||||
export class Port extends Schema.Class<Port>("Port")({
|
||||
value: Schema.Number
|
||||
}) {
|
||||
toString(): string {
|
||||
return this.value.toString() // Using `this`!
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ GOOD: Schema.Struct + pure functions
|
||||
export const Port = Schema.Struct({
|
||||
value: Schema.Number.pipe(Schema.int(), Schema.between(1, 65535))
|
||||
})
|
||||
export interface Port extends Schema.Schema.Type<typeof Port> {}
|
||||
|
||||
// Pure function instead of method
|
||||
export const portToString = (port: Port): string =>
|
||||
port.value.toString()
|
||||
|
||||
// Or namespace for organization
|
||||
export namespace Port {
|
||||
export const toString = (port: Port): string =>
|
||||
port.value.toString()
|
||||
|
||||
export const SSH = { value: 22 } as Port
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Never Use `this` - Use Functional Style
|
||||
|
||||
```typescript
|
||||
// ❌ BAD: Using `this`
|
||||
class CommandResult {
|
||||
succeeded(): boolean {
|
||||
return this.exitCode.value === 0
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ GOOD: Pure function
|
||||
export const isSuccess = (result: CommandResult): boolean =>
|
||||
result.exitCode.value === 0
|
||||
|
||||
// Or namespace pattern
|
||||
export namespace CommandResult {
|
||||
export const isSuccess = (result: CommandResult): boolean =>
|
||||
result.exitCode.value === 0
|
||||
|
||||
export const isFailed = (result: CommandResult): boolean =>
|
||||
!isSuccess(result)
|
||||
}
|
||||
```
|
||||
|
||||
### 5. One Concept Per File
|
||||
|
||||
```typescript
|
||||
// ❌ BAD: Multiple concepts in one file
|
||||
// Command.ts
|
||||
export class Command { }
|
||||
export class CommandResult { }
|
||||
export class ExitCode { }
|
||||
export class Stdout { }
|
||||
export class Stderr { }
|
||||
|
||||
// ✅ GOOD: One concept per file
|
||||
// Command.ts
|
||||
export const Command = Schema.Struct({ ... })
|
||||
|
||||
// CommandResult.ts
|
||||
export const CommandResult = Schema.Struct({ ... })
|
||||
|
||||
// ExitCode.ts
|
||||
export const ExitCode = Schema.Struct({ ... })
|
||||
|
||||
// Stdout.ts
|
||||
export const Stdout = Schema.Struct({ ... })
|
||||
|
||||
// Stderr.ts
|
||||
export const Stderr = Schema.Struct({ ... })
|
||||
```
|
||||
|
||||
### 6. No For Loops - Use Functional Iteration
|
||||
|
||||
```typescript
|
||||
// ❌ BAD: Imperative for loop
|
||||
for (const segment of topology.segments) {
|
||||
const result = await test(segment)
|
||||
results.push(result)
|
||||
}
|
||||
|
||||
// ✅ GOOD: Array methods for calculations
|
||||
const totalCpus = vmSpecs.map(spec => spec.cpus.value)
|
||||
.reduce((sum, cpu) => sum + cpu, 0)
|
||||
|
||||
// ✅ GOOD: ReadonlyArray.map for immutability
|
||||
import { ReadonlyArray } from "effect"
|
||||
|
||||
const mapped = ReadonlyArray.map(
|
||||
segments,
|
||||
(segment) => segment.name
|
||||
)
|
||||
```
|
||||
|
||||
### 7. Avoid Primitive Obsession
|
||||
|
||||
```typescript
|
||||
// ❌ BAD: Primitives everywhere
|
||||
function provision(name: string, cpus: number, memory: number)
|
||||
|
||||
// ✅ GOOD: Rich domain types
|
||||
export const VmName = Schema.String.pipe(
|
||||
Schema.pattern(/^[a-z0-9-]+$/),
|
||||
Schema.brand("VmName")
|
||||
)
|
||||
export type VmName = Schema.Schema.Type<typeof VmName>
|
||||
|
||||
export const CpuCount = Schema.Number.pipe(
|
||||
Schema.int(),
|
||||
Schema.between(1, 64),
|
||||
Schema.brand("CpuCount")
|
||||
)
|
||||
export type CpuCount = Schema.Schema.Type<typeof CpuCount>
|
||||
|
||||
function provision(spec: VmSpec)
|
||||
```
|
||||
|
||||
|
||||
### 9. Layer Composition Patterns
|
||||
|
||||
```typescript
|
||||
// Use pipe for clean composition
|
||||
export const AppLayer = Layer.merge(
|
||||
ConfigLayer,
|
||||
LoggerLayer
|
||||
).pipe(
|
||||
Layer.provide(DatabaseLayer),
|
||||
Layer.provide(ResourceLayer)
|
||||
)
|
||||
|
||||
// Factory functions for parameterized layers
|
||||
export const makeSSHLayer = (connection: HostConnection) =>
|
||||
Layer.effect(
|
||||
SSHConnection,
|
||||
Effect.gen(function* () {
|
||||
// Implementation
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Repository Pattern
|
||||
|
||||
```typescript
|
||||
export class UsersRepo extends Effect.Service<UsersRepo>()(
|
||||
"UsersRepo",
|
||||
{
|
||||
effect: Effect.gen(function* () {
|
||||
const sql = yield* Sql
|
||||
|
||||
return {
|
||||
findById: (id: string) =>
|
||||
sql.query(/* ... */).pipe(
|
||||
Effect.map(rows => rows[0])
|
||||
),
|
||||
|
||||
create: (user: User) =>
|
||||
sql.query(/* ... */)
|
||||
}
|
||||
}),
|
||||
dependencies: [SqlLayer]
|
||||
}
|
||||
) {
|
||||
static Test = Layer.succeed(UsersRepo, {
|
||||
findById: (id: string) => Effect.succeed(mockUser),
|
||||
create: (user: User) => Effect.succeed(user)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Checklist for Code Review
|
||||
|
||||
- [ ] No `this` keyword anywhere
|
||||
- [ ] No `for`/`while` loops - use functional iteration and `pipe`
|
||||
- [ ] Schema.Struct, not Schema.Class
|
||||
- [ ] One concept per file
|
||||
- [ ] Services use `Effect.Service()()` pattern
|
||||
- [ ] Branded types for domain concepts
|
||||
- [ ] No primitive obsession
|
||||
- [ ] Layer composition uses pipe
|
||||
- [ ] Tagged errors for error handling
|
||||
- [ ] Resources use Layer.scoped + Effect.acquireRelease
|
||||
|
||||
## Domain-Driven Design Workflow
|
||||
|
||||
Following Scott Wlaschin's approach:
|
||||
|
||||
### 1. Understand the Domain
|
||||
- Talk to domain experts
|
||||
- Learn ubiquitous language
|
||||
- Identify bounded contexts early enough to guide naming and ownership
|
||||
- Treat those contexts as provisional boundaries that may later split or merge under real change pressure
|
||||
|
||||
### 2. Model the Domain
|
||||
```typescript
|
||||
// Start with types that match domain language
|
||||
export const NetworkSegment = Schema.Struct({
|
||||
name: Schema.String, // "Management", "DMZ"
|
||||
vlanId: VlanId, // 10, 20, 30...
|
||||
subnet: SubnetCidr, // "10.10.0.0/16"
|
||||
gateway: IpAddress, // "10.10.0.1"
|
||||
accessRules: Schema.Array(Schema.String)
|
||||
})
|
||||
```
|
||||
@@ -0,0 +1,135 @@
|
||||
# Practicing TDFDDD by Hand
|
||||
|
||||
This tutorial is for humans who want to build judgment before reviewing LLM-generated design artifacts.
|
||||
The goal is to practice the method manually so you know what a good design artifact should feel like.
|
||||
|
||||
## What you are practicing
|
||||
|
||||
You are not trying to write production code yet.
|
||||
You are practicing how to move from a business story to:
|
||||
|
||||
- commands
|
||||
- events
|
||||
- state variants
|
||||
- a policy signature
|
||||
- a final contract
|
||||
|
||||
## How to use this tutorial
|
||||
|
||||
Pick a simple domain story and work through the phases on paper or in a scratch markdown file.
|
||||
Do not look at implementation code until the design is stable.
|
||||
|
||||
A good starter prompt is:
|
||||
|
||||
> A librarian checks out a book to a member. If the member is suspended or the book is already on loan, checkout fails.
|
||||
|
||||
## Step 1: Write the story in plain language
|
||||
|
||||
Write two or three sentences that describe what a person is trying to do.
|
||||
Do not mention framework details.
|
||||
Do not mention databases.
|
||||
Do not mention HTTP.
|
||||
|
||||
Example:
|
||||
|
||||
> A librarian attempts to check out a book to a member. The checkout succeeds only if the member is active and the book is available.
|
||||
|
||||
## Step 2: Identify the command and events
|
||||
|
||||
Write down:
|
||||
|
||||
- the command: what someone is attempting
|
||||
- the success event: what fact becomes true if it works
|
||||
- the failure event: what fact becomes true if it does not
|
||||
|
||||
Example:
|
||||
|
||||
- Command: `CheckOutBook`
|
||||
- Success event: `BookCheckedOut`
|
||||
- Failure event: `CheckoutRefused`
|
||||
|
||||
If you cannot name the events clearly, your understanding is probably still fuzzy.
|
||||
|
||||
## Step 3: Ask what information the decision actually needs
|
||||
|
||||
Now sketch the policy without worrying about perfect types.
|
||||
Use placeholder names if necessary.
|
||||
|
||||
```fsharp
|
||||
checkOut : NeededBookInfo -> NeededMemberInfo -> Result<BookCheckedOut, CheckoutRefused>
|
||||
```
|
||||
|
||||
Then replace the vague placeholders with the actual concepts the rule depends on.
|
||||
This is where the workflow starts shaping the model.
|
||||
|
||||
## Step 4: Model the minimum useful types
|
||||
|
||||
Define the nouns the policy needs.
|
||||
Prefer explicit domain concepts over primitives.
|
||||
|
||||
Ask yourself:
|
||||
|
||||
- Do I need a state distinction here?
|
||||
- Is this really one thing, or a union of stages?
|
||||
- Am I hiding business meaning inside a generic `status` field?
|
||||
|
||||
Example direction:
|
||||
|
||||
```fsharp
|
||||
type ActiveMember = { MemberId: MemberId }
|
||||
type SuspendedMember = { MemberId: MemberId; Reason: SuspensionReason }
|
||||
type AvailableBook = { BookId: BookId }
|
||||
type LoanedBook = { BookId: BookId; DueDate: DueDate }
|
||||
```
|
||||
|
||||
## Step 5: Freeze the contract
|
||||
|
||||
Once the concepts are clear, write the final signatures.
|
||||
|
||||
```fsharp
|
||||
decide : AvailableBook -> ActiveMember -> Result<BookCheckedOut, CheckoutRefused>
|
||||
apply : LibraryState -> BookCheckedOut -> LibraryState
|
||||
workflow : BookId -> MemberId -> Effect<CheckoutResponse>
|
||||
```
|
||||
|
||||
At this point the design should feel boring in a good way.
|
||||
The remaining implementation work should mostly be translation.
|
||||
|
||||
## Step 6: Review your own artifact
|
||||
|
||||
Before looking at a reference example, inspect your work.
|
||||
|
||||
Ask:
|
||||
|
||||
- Did I name the command, events, and states in domain language?
|
||||
- Did I model meaningful lifecycle distinctions explicitly?
|
||||
- Does the policy return facts instead of booleans?
|
||||
- Is the workflow contract clearly separate from the policy contract?
|
||||
- Could another person implement this without inventing missing domain meaning?
|
||||
|
||||
## Step 7: Compare against a reference
|
||||
|
||||
Now compare your work against:
|
||||
|
||||
- `worked-example-truck-loading.md`
|
||||
- `../reference/design-artifact-template.md`
|
||||
- `../reference/review-checklist.md`
|
||||
|
||||
Do not compare for identical names.
|
||||
Compare for shape, clarity, and separation of concerns.
|
||||
|
||||
## Practice loop
|
||||
|
||||
To build review skill, repeat this exercise with three kinds of stories:
|
||||
|
||||
- permission decisions
|
||||
- state transition decisions
|
||||
- allocation decisions
|
||||
|
||||
Examples:
|
||||
|
||||
- approve an expense request
|
||||
- schedule a technician visit
|
||||
- reserve inventory for an order
|
||||
|
||||
The more hand practice you do, the easier it becomes to spot weak LLM output.
|
||||
@@ -0,0 +1,238 @@
|
||||
# Worked Example: Truck Loading
|
||||
|
||||
This tutorial is a complete walkthrough of a TDFDDD design from story to final artifact.
|
||||
Use it as the primary reference example when learning the method or reviewing LLM-generated designs.
|
||||
|
||||
## The story
|
||||
|
||||
A warehouse worker attempts to put a package on a truck.
|
||||
If the package fits and the truck is open for loading, it succeeds.
|
||||
If the truck is full or sealed, it fails.
|
||||
|
||||
Later we learn an additional detail: some trucks may be unavailable because they are in transit or under maintenance.
|
||||
|
||||
## Phase 1: Event storming
|
||||
|
||||
### Command
|
||||
|
||||
`LoadPackage`
|
||||
|
||||
### Business timeline
|
||||
|
||||
1. A warehouse worker attempts to load a package onto a truck.
|
||||
2. The system checks the truck state and capacity rules.
|
||||
3. The system records either a success fact or a failure fact.
|
||||
|
||||
### Events
|
||||
|
||||
- `LoadPackage`
|
||||
- `PackageLoaded`
|
||||
- `LoadRefused`
|
||||
|
||||
### What we learned from the domain
|
||||
|
||||
The truck is not just a generic object with a `status` field.
|
||||
Some states are loadable and some are not.
|
||||
That is a clue that the model should likely distinguish the states explicitly.
|
||||
|
||||
## Phase 2: Core sketch
|
||||
|
||||
Now ask the design question:
|
||||
|
||||
> To decide if a package can be loaded, what information must the policy know?
|
||||
|
||||
Initial sketch:
|
||||
|
||||
```fsharp
|
||||
decideLoad : packageWeight ->
|
||||
packageVolume ->
|
||||
truckRemainingVolume ->
|
||||
truckRemainingWeight ->
|
||||
Result<PackageLoaded, LoadRefused>
|
||||
```
|
||||
|
||||
This sketch is intentionally rough.
|
||||
Its job is to reveal the concepts we need to model properly.
|
||||
|
||||
## Phase 3: Domain modeling
|
||||
|
||||
### Primitives
|
||||
|
||||
```fsharp
|
||||
type Weight = float<kg>
|
||||
type Volume = float<m^3>
|
||||
type PackageId = string
|
||||
type TruckId = string
|
||||
```
|
||||
|
||||
### Compound objects
|
||||
|
||||
```fsharp
|
||||
type Package = {
|
||||
Id: PackageId
|
||||
Weight: Weight
|
||||
Volume: Volume
|
||||
}
|
||||
|
||||
type TruckCapacity = {
|
||||
MaxWeight: Weight
|
||||
MaxVolume: Volume
|
||||
}
|
||||
|
||||
type CurrentLoad = {
|
||||
TotalWeight: Weight
|
||||
TotalVolume: Volume
|
||||
}
|
||||
```
|
||||
|
||||
### State variants
|
||||
|
||||
We split the truck into loadable and non-loadable states instead of hiding the difference in one broad structure.
|
||||
|
||||
```fsharp
|
||||
type LoadingTruck = {
|
||||
Id: TruckId
|
||||
Capacity: TruckCapacity
|
||||
CurrentLoad: CurrentLoad
|
||||
}
|
||||
|
||||
type SealedTruck = {
|
||||
Id: TruckId
|
||||
Status: "Sealed" | "InTransit" | "Maintenance"
|
||||
}
|
||||
|
||||
type Truck =
|
||||
| Loading of LoadingTruck
|
||||
| Sealed of SealedTruck
|
||||
```
|
||||
|
||||
### Events
|
||||
|
||||
The policy returns domain facts, not UI responses.
|
||||
|
||||
```fsharp
|
||||
type LoadFailureReason =
|
||||
| OverWeight of { Limit: Weight; Actual: Weight }
|
||||
| OverVolume of { Limit: Volume; Actual: Volume }
|
||||
| TruckUnavailable of { Status: string }
|
||||
|
||||
type PackageLoaded = {
|
||||
TruckId: TruckId
|
||||
PackageId: PackageId
|
||||
NewLoadState: CurrentLoad
|
||||
Timestamp: DateTime
|
||||
}
|
||||
|
||||
type LoadRefused = {
|
||||
Reason: LoadFailureReason
|
||||
TruckId: TruckId
|
||||
}
|
||||
```
|
||||
|
||||
## Why this model is better than a generic status object
|
||||
|
||||
A single `Truck` object with a generic `status` field would force every caller to remember which combinations are valid.
|
||||
By distinguishing `LoadingTruck` from `SealedTruck`, the design moves that rule into the type system.
|
||||
|
||||
This is the kind of move a reviewer should look for in LLM output.
|
||||
|
||||
## Phase 4: The contract
|
||||
|
||||
```fsharp
|
||||
decide : Package -> LoadingTruck -> Result<PackageLoaded, LoadRefused>
|
||||
apply : LoadingTruck -> PackageLoaded -> LoadingTruck
|
||||
loadPackage : TruckId -> PackageId -> Effect<LoadResponse>
|
||||
```
|
||||
|
||||
Notice the separation:
|
||||
|
||||
- `decide` is pure and domain-focused
|
||||
- `apply` is pure and mechanical
|
||||
- `loadPackage` is the impure workflow boundary
|
||||
|
||||
## Phase 5: Implementation shape
|
||||
|
||||
Once the design is frozen, the code follows the standard orchestration pattern:
|
||||
|
||||
1. Gather data.
|
||||
2. Call the policy.
|
||||
3. Match on the returned event.
|
||||
4. Apply the state transition.
|
||||
5. Persist and return a workflow response.
|
||||
|
||||
That is why a good design artifact makes implementation feel like translation.
|
||||
|
||||
## Final artifact
|
||||
|
||||
This is the kind of finished design artifact a human reviewer should be able to compare against.
|
||||
|
||||
```fsharp
|
||||
// 1. Primitives
|
||||
type Weight = float<kg>
|
||||
type Volume = float<m^3>
|
||||
type PackageId = string
|
||||
type TruckId = string
|
||||
|
||||
// 2. The Inputs (The Objects)
|
||||
type Package = {
|
||||
Id: PackageId
|
||||
Weight: Weight
|
||||
Volume: Volume
|
||||
}
|
||||
|
||||
type TruckCapacity = {
|
||||
MaxWeight: Weight
|
||||
MaxVolume: Volume
|
||||
}
|
||||
|
||||
type CurrentLoad = {
|
||||
TotalWeight: Weight
|
||||
TotalVolume: Volume
|
||||
}
|
||||
|
||||
// 3. The States (The Aggregates)
|
||||
type LoadingTruck = {
|
||||
Id: TruckId
|
||||
Capacity: TruckCapacity
|
||||
CurrentLoad: CurrentLoad
|
||||
}
|
||||
|
||||
type SealedTruck = {
|
||||
Id: TruckId
|
||||
Status: "Sealed" | "InTransit" | "Maintenance"
|
||||
}
|
||||
|
||||
type Truck =
|
||||
| Loading of LoadingTruck
|
||||
| Sealed of SealedTruck
|
||||
|
||||
// 4. The Events (The Outputs)
|
||||
type LoadFailureReason =
|
||||
| OverWeight of { Limit: Weight; Actual: Weight }
|
||||
| OverVolume of { Limit: Volume; Actual: Volume }
|
||||
| TruckUnavailable of { Status: string }
|
||||
|
||||
type PackageLoaded = {
|
||||
TruckId: TruckId
|
||||
PackageId: PackageId
|
||||
NewLoadState: CurrentLoad
|
||||
Timestamp: DateTime
|
||||
}
|
||||
|
||||
type LoadRefused = {
|
||||
Reason: LoadFailureReason
|
||||
TruckId: TruckId
|
||||
}
|
||||
```
|
||||
|
||||
## What to pay attention to when learning from this example
|
||||
|
||||
Do not memorize the truck domain.
|
||||
Pay attention to the structure of the reasoning:
|
||||
|
||||
- the story becomes a command and events
|
||||
- the policy sketch exposes the needed information
|
||||
- the types become more specific as the design clarifies
|
||||
- the final contract separates design concerns cleanly
|
||||
|
||||
That structure is what you should reproduce by hand and review in LLM output.
|
||||
Reference in New Issue
Block a user