Declarative architecture baseline#
assertDeclarativeArchitecture is the first architecture test to add to a Craft app. It is not a style check and it does not test the DOM. It reads the static Craft graph and verifies eight relationships that are easy to lose during a refactor:
export function keepTheAppDeclarative(graph: ArchitectureGraph) {
assertDeclarativeArchitecture(graph.graph);
}
The same test protects eight different failure modes:
| Rule | If it is missing, this can happen |
|---|---|
assertCraftUnique | two persisted resources restore from the same storage slot |
assertHttpEndpointUnique | two services own GET users and evolve it differently |
assertCraftComputedPure | reading a derived value writes state or starts work |
assertNoDependencyCycles | service construction loops through A → B → A |
assertMutationHasReactOn | a successful write leaves the visible list stale |
assertPrimitiveMethodsUsedOnce | one exposed method silently serves several call sites and loses their context |
assertNoUnusedPrimitiveMethods | an exposed method is never called and adds noise to the primitive interface |
assertInputActionForms | a button directly triggers an input-dependent mutation or async process instead of using the form submission boundary |
The following examples show the actual code shape that each rule rejects.
1. Two persisted resources share one identity#
Two feature files can both look reasonable in isolation:
// features/users/user-list.ts
insertStoragePersister(
craftUnique({ storeName: 'shop', key: 'user' }),
);
// features/users/user-detail.ts
insertStoragePersister(
craftUnique({ storeName: 'shop', key: 'user' }),
);
They do not create a list cache and a detail cache. They create one storage identity with two call sites. Restoring the detail can overwrite the value that the list expects, and the bug only appears after a reload or cache restore.
assertCraftUnique fails with both file locations. The fix is to give the resources distinct identities:
craftUnique({ storeName: 'shop', key: 'user-list' });
craftUnique({ storeName: 'shop', key: 'user-detail' });
See Unique identities and Persisted identities.
2. Two services call the same HTTP endpoint#
This duplication is also invisible to TypeScript:
// users-api.ts
const users = yield* CraftHttpClient.get(({ response }) => ({
url: 'users',
success: response<User[]>(),
}));
// admin-api.ts
const users = yield* CraftHttpClient.get(({ response }) => ({
url: 'users',
success: response<AdminUser[]>(),
}));
Both are GET users. One feature can add pagination or change the response shape while the other keeps the old assumption. The two call sites are now competing owners of one transport contract.
assertHttpEndpointUnique forces one owner. The other service must depend on that owner and derive its own view:
const users = yield* UsersApi();
const admins = craftUse(craftComputed('admins', function* () {
return (yield* users.list()).filter((user) => user.role === 'admin');
}));
3. A computed value performs work while being read#
The purpose of craftComputed is to derive a value:
const remaining = craftUse(craftComputed('remaining', function* () {
return (yield* tasks()).filter((task) => !task.done).length;
}));
This version changes the meaning of a read:
const remaining = craftUse(craftComputed('remaining', function* () {
yield* audit.log('recomputed');
yield* tasks.set(normalizeTasks(yield* tasks()));
return (yield* tasks()).filter((task) => !task.done).length;
}));
Now a template read can write state, call a method or trigger another graph branch. Depending on recomputation order, this can produce a loop, duplicate work or state that changes merely because it was displayed.
assertCraftComputedPure rejects both direct writes and calls through another method binding. Put the work in a method, on$, query or mutation, and let the computed only read.
See Computed purity.
4. Two services depend on each other#
The graph for this pair is enough to fail the suite:
UserList ──depends-on──▶ UserMutation
UserMutation ──depends-on──▶ UserList
In source, the cycle usually comes from two factories each yielding the other:
// features/users/user-list.ts
function* userListFactory() {
const mutation = yield* UserMutation();
return { mutation };
}
// features/users/user-mutation.ts
function* userMutationFactory() {
const list = yield* UserList();
return { list };
}
The surrounding craftService(...) declarations are omitted here; the important part is the two dependency edges created by the yield* calls.
The application may not fail until the route first constructs the services. Then it can recurse forever or expose a partially constructed value.
assertNoDependencyCycles identifies the path. Break it by extracting a small shared contract, passing an input, or moving a derived value into the consumer. Two features depending on a common Auth service are not a cycle:
UserList ──▶ Auth ◀── Checkout
See Dependency cycles.
5. A mutation succeeds but the list never refreshes#
The orphan mutation is the most user-visible failure:
const createTask = yield* mutation('createTask', {
method: (input: NewTask) => input,
loader: saveTask,
});
const tasks = yield* query('tasks', {
params: () => filters(),
loader: loadTasks,
});
The server has the new task, but the query has no declared relationship with the mutation. The user clicks “Create”, gets a success response, and still sees the old list until a hard reload.
Declare the relationship on the query:
const tasks = yield* query(
'tasks',
{
params: () => filters(),
loader: loadTasks,
},
insertReactOnMutation(createTask, {
reload: { onMutationSuccess: true },
}),
);
The graph records a triggers edge from createTask to tasks. The exact policy can be a reload, an optimistic patch or another supported insertion; the important part is that it is declared where the query is defined.
See Mutation reactions.
6. An input-driven action bypasses a form#
This relationship can cross files. The component may own the input and button, while a service owns the mutation. The graph follows both sides:
// todo-store.ts — avant
const title = yield* state('title', '');
const addTodo = yield* mutation('addTodo', {
method: function* () {
return { title: yield* title() };
},
loader: saveTodo,
});
// todo-page.ts — avant
input('TodoTitleInput', { value: store.title });
button('AddTodoButton', { click: function* () {
yield* store.addTodo.mutate();
} }, 'Add');
The mutation receives no argument at the click site, but it still depends on the input because its method reads the same state. assertInputActionForms rejects this shape. A file-local ESLint rule can catch the direct version in one file, but only the architecture graph can follow the input, button, service and resource method across files.
The accepted shape makes the boundary explicit:
// todo-store.ts — après
import type { ValidatedFormValue } from '@craft-ts/core';
const addTodo = yield* mutation('addTodo', {
method: (title: NonNullable<ValidatedFormValue<string>>) => ({
title: title.trim(),
}),
loader: saveTodo,
});
// todo-page.ts — après
const titleForm = yield* state(
'titleForm',
'',
insertForm(
insertFormAttributes(() => ({ validators: [cRequired()] })),
insertFormSubmit(addTodo),
),
);
form('AddTodoForm', {
*submit(event) {
event.preventDefault();
yield* titleForm.form.submit();
},
}, [
input('TodoTitleInput', { type: 'text' }).pipe(
CraftFieldDirective(titleForm.form),
),
button('AddTodoButton', { type: 'submit' }, 'Add'),
]);
For an object-valued form, add insertSelectFormTree and bind the native input to titleForm.form.selectTitle() instead. The insertFormAttributes insertion is where validators and field attributes belong; insertFormSubmit(addTodo) connects the validated value to the mutation. The architecture assertion rejects a direct button call even when a form and insertFormSubmit are also present, so there is only one submit boundary. insertForm() alone is valid, but it is insufficient for this mutation-backed example: the submit insertion is what passes the validated form value to addTodo.
What the aggregate test does — and does not do#
The aggregate test is now understandable as a compact CI gate:
it('protects the baseline graph invariants', () => {
assertDeclarativeArchitecture(graph.graph);
});
It does not cover every architecture policy. Add focused assertions for:
route DI and error-screen proofs:
assertRouteDiProofs;route/page file boundaries:
assertRouteComponentsInSeparateFiles;folder ownership:
assertPathBoundaries;Effect loader boundaries:
assertPrimitiveLoaderRequirements;interactive control names:
assertInteractiveElementNamed;craftEffectnetwork and imperative-sync constraints: Effect rules.input-driven mutations and async processes:
assertInputActionForms(this page).
Keep the aggregate assertion for the common baseline, and keep focused rules for policies whose failure message should explain a product or team boundary.
Explicit exceptions#
Some mutations really are fire-and-forget: logout, telemetry or an export with no cached query. Name those exceptions instead of weakening the whole rule:
assertDeclarativeArchitecture(graph.graph, {
allow: ['logout', 'sendTelemetry'],
});
An allow entry is a documented decision. It should be narrow enough that a new orphan mutation cannot hide inside it.