4. Compose services
Goal: understand yield* — the one idea the whole library is built on.
This is the step that makes everything else obvious. Take your time here.
The problem yield* solves
Classic Angular injection hides the dependency graph:
class TaskList {
private api = inject(TaskApi); // invisible from the outside
}Nothing in TaskList's type says it needs TaskApi. The compiler cannot tell you when you forget to provide it, and a test cannot tell you what to mock.
Craft makes the same call visible in the type, by yielding it:
export const { TaskList } = craftService(
{ name: 'TaskList', scope: 'function' },
function* () {
const api = yield* TaskApi(); // ← tracked
const tasks = yield* state('tasks', [] as Task[], /* … */);
return tasks;
},
);Now TaskList's type carries TaskApi as a dependency. Everything downstream — the DI check on routes, the testing register, the dependency snapshot — reads that type.
Why a generator?
A generator is just a function that can hand control back to its caller at each yield. Craft uses it as a collection channel: each yield* reports "I need this" to the runtime driving the factory, which resolves it and folds it into the graph.
You don't manage that channel yourself. In practice the whole rule is:
Inside a
function*craft factory, drive everything withyield*— services and primitives alike.
const api = yield* TaskApi(); // a service
const tasks = yield* state('tasks', []); // a primitiveA primitive is single-use
Each state(...) / query(...) call produces one generator, consumed exactly once. Don't store one and yield* it twice.
Composing two services
const { TaskApi } = craftService(
{ name: 'TaskApi', scope: 'global' },
() => ({
// raw fetch, only to keep this example about composition —
// see the note below
fetchAll: () => fetch('/api/tasks').then((r) => r.json()),
}),
);
const { TaskList } = craftService(
{ name: 'TaskList', scope: 'function' },
function* () {
const api = yield* TaskApi();
const tasks = yield* state('tasks', [] as Task[], ({ set }) => ({
// For this demo only; we'll later see why this belongs in a mutation instead.
load: async () => set(await api.fetchAll()),
}));
return tasks;
},
);
const { TaskStats } = craftService(
{ name: 'TaskStats', scope: 'function' },
function* () {
const tasks = yield* TaskList();
return { done: computed(() => tasks().filter((t) => t.done).length) };
},
);Note the factory of TaskApi is a plain arrow — a service with no dependencies doesn't need to be a generator.
Don't call fetch directly in real code
It is used here only to keep the example about composition. HTTP goes through CraftHttpClient, which is yieldable — so the request is tracked like any other dependency, it is mockable at the browser boundary in tests, and above all it is what turns a failed response into a typed craftException you can handle.
A raw fetch gives you none of that: no tracking, no boundary, and a rejected promise instead of a declared failure. Step 5 uses CraftHttpClient for real, and step 6 shows the exceptions it produces.
The craft-ng/prefer-craft-http-client ESLint rule flags direct HttpClient usage for the same reason.
Taking only what you need
TaskStats only reads the array. Say so, and the graph records only that:
const { TaskStats } = craftService(
{ name: 'TaskStats', scope: 'function' },
function* () {
const fetchAll = yield* TaskApi.fetchAll(); // one property
// …
},
);A test for TaskStats then has to mock fetchAll and nothing else.
What you gained
The mental model: declare with a name, drive with yield*, derive the rest. Every remaining step is a variation on it — query yields, mutation yields, guards yield, route providers yield.
Going deeper
craftGen lets you write a standalone generator outside a service — useful for guards and route helpers. See Generators.