Skip to main content
@craft-ts
LearnLearn with EffectAI agentsGuideReference
@craft-ts/core@craft-ts/component@craft-ts/dev-tools@craft-ts/effect@craft-ts/style@craft-ts/style-testing@craft-ts/i18n@craft-ts/i18n-effect@craft-ts/mcp
ExamplesMigrationEffect compatibility & maturityAdopting CraftTS progressivelyPress kitRoadmapBacklog
Beta
  • Learn
  • Learn with Effect
  • AI agents
  • Guide
  • Reference
  • Packages
  • Resources
  • Guide overview
  • Create a project
i.Core concepts
  • The mental model
  • Which primitive should I use?
  • Anatomy of a primitive
  • Generators and yield*
  • Insertions
  • Typed insertion pipes
  • Exceptions as values
ii.Managing state
  • Local state
  • State machines
  • query
  • Mutations
  • queryParams
  • asyncProcess
  • Selecting
  • Reacting to mutations
  • Collections
  • Collection utilities
  • Persistence
  • GlobalPersisterHandler
  • Pagination placeholders
  • Schema validation
iii.Recommended approaches
  • Inject at the point of use
iv.Structuring the app
  • craftService
  • Service scopes
  • Shaping the public API
  • Abstract services
  • App start
  • Lazy services
  • Server functions
  • craftRegisterFor
  • Target wrapper
v.Routing & type-safe DI
  • Setup
  • CLI automation
  • ESLint rules
  • Route providers
  • Route guards
  • Route exception handling
  • Non-blocking navigation
  • Global error component
  • Route load errors
  • Scaling routes
vi.Components & templates
  • Components
  • Activating the style system
  • Typed styles
  • Defining a design system
  • Tokens and variables
  • Global foundation and fonts
  • Pseudo-elements and animations
  • Axes and the matrix
  • Context obligations
  • Testing visual states
  • Text contrast
  • Visual attestation
  • Template obligations
  • Typed CSS variables
  • Fine-grained reactivity
  • Progressive rendering
  • Directives and .pipe(...)
  • settledValue & pendingNode
  • Accessibility
  • Customization
  • Content projection
  • Styling a component
  • Template migrator
vii.Forms
  • Overview
  • Validators
  • Submitting
  • Nested forms
  • Exception handling
  • Complete examples
viii.Testing
  • Testing services
  • Testing components
  • Type-level tests
  • Browser boundaries
  • Page stability
  • Architecture rules
    • Overview
    • Declarative baseline
    • Unique identities
    • HTTP endpoint ownership
    • Computed purity
    • Dependency cycles
    • Mutation reactions
    • Route DI proofs
    • Route component files
    • Path boundaries
    • Exclusive links
    • Persisted identities
    • insertSelect keys
    • craftEffect and network
    • craftEffect and imperative sync
    • Interactive element names
    • Metric thresholds
    • Server-state loaders
    • Primitive loader requirements
  • Extensible architecture graph
  • Graph insights
  • Craft graph vs Nx
ix.Reactivity utilities
  • craftComputed
  • craftEffect
  • craftMethod
  • source$
  • on$
  • fromEventToSource$
  • sourceFromEvent
  • afterRecomputation
x.Internationalisation
  • Type-safe i18n
  • The catalogue
  • Tokens
  • The runtime
  • With Effect
xi.Deployment (experimental)
  • Overview
  • Manifest reference
  • Diagnostics
  • Providers
  • Alchemy provider
xii.Going further
  • SSR and hydration
  • Effect integration
  • Program operators
  • Pattern matching
  • Observability
  • AI agents
  • Temporal runtime
  1. Testing
  2. Architecture rules
  3. Declarative baseline

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:

ts
export function keepTheAppDeclarative(graph: ArchitectureGraph) {
  assertDeclarativeArchitecture(graph.graph);
}

The same test protects eight different failure modes:

RuleIf it is missing, this can happen
assertCraftUniquetwo persisted resources restore from the same storage slot
assertHttpEndpointUniquetwo services own GET users and evolve it differently
assertCraftComputedPurereading a derived value writes state or starts work
assertNoDependencyCyclesservice construction loops through A → B → A
assertMutationHasReactOna successful write leaves the visible list stale
assertPrimitiveMethodsUsedOnceone exposed method silently serves several call sites and loses their context
assertNoUnusedPrimitiveMethodsan exposed method is never called and adds noise to the primitive interface
assertInputActionFormsa 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:

typescript
// features/users/user-list.ts
insertStoragePersister(
  craftUnique({ storeName: 'shop', key: 'user' }),
);
typescript
// 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:

typescript
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:

typescript
// users-api.ts
const users = yield* CraftHttpClient.get(({ response }) => ({
  url: 'users',
  success: response<User[]>(),
}));
typescript
// 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:

typescript
const users = yield* UsersApi();
const admins = craftUse(craftComputed('admins', function* () {
  return (yield* users.list()).filter((user) => user.role === 'admin');
}));

See HTTP endpoint ownership.

3. A computed value performs work while being read#

The purpose of craftComputed is to derive a value:

typescript
const remaining = craftUse(craftComputed('remaining', function* () {
  return (yield* tasks()).filter((task) => !task.done).length;
}));

This version changes the meaning of a read:

typescript
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:

typescript
// 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:

typescript
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:

typescript
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:

typescript
// todo-store.ts — avant
const title = yield* state('title', '');
const addTodo = yield* mutation('addTodo', {
  method: function* () {
    return { title: yield* title() };
  },
  loader: saveTodo,
});
typescript
// 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:

typescript
// 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:

typescript
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;

  • craftEffect network 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:

typescript
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.

See also#

  • Architecture rules

  • Craft graph vs Nx

←OverviewUnique identities→

On this page

  • 1. Two persisted resources share one identity
  • 2. Two services call the same HTTP endpoint
  • 3. A computed value performs work while being read
  • 4. Two services depend on each other
  • 5. A mutation succeeds but the list never refreshes
  • 6. An input-driven action bypasses a form
  • What the aggregate test does — and does not do
  • Explicit exceptions
  • See also

@craft-ts · MIT licensed

GitHubnpm

Search