Modeling by Example
Overview
Domain schemas include scenarios that demonstrate operations in context. These scenarios serve dual purposes:
- Specification — Executable documentation of expected behavior
- Test generation — Source for automated tests that run against every target (core, API, CLI, MCP, UI)
Scenario DSL
Scenarios are defined in TypeScript using a fluent builder API:
import { assert, given, ref, scenario, then, when } from "@morphdsl/scenario";
import type { Todo, User } from "@todo/tasks-dsl";
import { completeTodo, createTodo, createUser, listTodos } from "@todo/tasks-dsl";
export const scenarios = [
scenario("Create and complete a todo")
.withActor("Taylor")
.steps(
given(
createUser.call({ email: "taylor@test.com", name: "Taylor", password: "password123" }),
).as("user"),
when(
createTodo.call({ userId: ref<User>("user").id, title: "Buy milk" }),
).as("todo"),
when(completeTodo.call({ todoId: ref<Todo>("todo").id })).as("completedTodo"),
then(
assert("completedTodo", "completed")
.toBe(true)
.withProse("the todo is marked as completed"),
),
),
];
Builder API
| Function | Purpose |
|---|---|
scenario(name) |
Create a named scenario |
.withActor(name) |
Set the actor for prose rendering |
.withTags(...tags) |
Add tags for filtering |
.steps(...steps) |
Add given/when/then steps |
given(op.call(params)).as("binding") |
Precondition — setup step with optional binding |
when(op.call(params)).as("binding") |
Action — the operation under test |
then(assert(...)) |
Verification — check results |
ref<Type>("binding").field |
Type-safe reference to a previous step’s result |
Assertions
assert("binding", "field").toBe(value) // exact equality
assert("binding").toHaveLength(n) // collection length
assert("binding", "field").toBeDefined() // field exists
assert("binding", "field").toContain(item) // collection contains
All assertions support .withProse("human-readable description").
Cross-Step References
The ref() function creates type-safe proxies that resolve at runtime:
// ref<User>("user").id produces "$user.id" at runtime
when(createTodo.call({ userId: ref<User>("user").id, title: "Buy milk" })).as("todo")
The runner maintains a bindings map across steps within a scenario. When a step uses .as("name"), its result is stored and available to later steps via ref("name").
Prose Templates
Each context defines prose templates that convert operation calls into human-readable text:
// fixtures/todo/dsl/prose.ts
export const prose: Prose<typeof ops> = {
createUser: '{actor} creates a user with name "{name}" and email "{email}"',
createTodo: '{actor} creates a todo "{title}"',
completeTodo: '{actor} completes the todo "{$todo.title}"',
deleteTodo: '{actor} deletes the todo "{$todo.title}"',
listTodos: "{actor} lists todos [includeCompleted?including completed]",
};
Template syntax
| Pattern | Replaced with |
|---|---|
{paramName} |
Parameter value |
{actor} |
Scenario actor name |
{$binding.field} |
Value from a previous step’s result |
[paramName? text] |
Conditional text — included only if param is truthy |
Rendering pipeline
interpolate(template, params, { actor })— replaces{param}placeholders and evaluates conditionalsinterpolateBindings(text, bindings)— replaces{$binding.field}with runtime values- The fully resolved prose appears in test output
Design Principles
Scenarios are algebraic, not narrative
The domain schema formalizes the algebra of a system: types, operations, and invariants. Scenarios demonstrate how operations compose to achieve outcomes. They are not user stories or narrative documentation.
Step data is self-documenting
All data relevant to a step should appear in the operation call itself. Prose templates render this data into readable text, but the scenario definition is the source of truth:
// Good — all data visible in the operation call
given(createUser.call({ email: "taylor@test.com", name: "Taylor", password: "password123" })).as("user")
// Bad — data hidden elsewhere, operation call is incomplete
given(createUser.call({ ...someFixture })).as("user")
Actors are identifiers, not personas
Names like “Taylor” in .withActor("Taylor") are identifiers for prose rendering, not persona references. They make output readable (“Taylor creates a todo” vs “the user creates a todo”) and have no special meaning beyond being a string.
Personas are discovery artifacts — they inform what scenarios to write, but they don’t appear in the schema’s algebraic structure.
One Scenario, Many Targets
The same scenario definitions run against every generated target:
| Runner | Execution strategy |
|---|---|
| Core | Direct handler invocation via Effect layers |
| API | HTTP requests to running server |
| CLI | Programmatic command execution |
| CLI-Client | Remote commands against running API |
| Client | Typed HTTP client methods |
| MCP | JSON-RPC protocol over stdio |
| UI | HTTP requests to UI server |
This ensures all targets behave identically. A test file looks like:
import { createLibraryRunner } from "@morphdsl/scenario-runner-core";
import { scenarios } from "@todo/scenarios";
import { HandlersLayer, InMemoryLayer, ops, prose } from "@todo/tasks-core";
const runner = createLibraryRunner({
layer: TestLayer,
operations: { ... },
prose,
});
test("scenarios", async () => {
const result = await runner.runAllAndPrint(scenarios);
expect(result.failed).toBe(0);
});
File Layout
examples/fixtures/<name>/
├── scenarios/scenarios.ts # Hand-written scenario definitions
└── dsl/prose.ts # Hand-written prose templates
examples/<name>/ # Generated output
├── tests/scenarios/ # Compiled scenarios package
└── contexts/*/core/src/test/ # Target-specific test files
Scenario files and prose templates are hand-written fixtures that survive regeneration. Everything else is generated.