Code Organization
This page covers how to lay out a causl application on disk — where the graph lives, how to split it into per-feature modules, where the seam with your UI should fall, and the naming conventions we use across the engine and the migration codemods. The goal is a codebase that a new contributor can navigate by reading folder names.
causl is unopinionated about file layout. Nothing in the engine requires a particular folder structure, and the static checker reads the program as a whole. The conventions here are what we have found to scale; you should feel free to deviate when a feature is genuinely shaped differently.
1. Start with a feature-first folder structure
Group code by feature, not by kind. A common
anti-pattern, especially for teams arriving from Redux, is to
grow parallel inputs/, derivations/,
and selectors/ folders that mirror the engine's
vocabulary. This forces every feature change to edit three or
four directories and makes it hard to see what a feature
actually owns.
A workable layout for a medium-sized application looks like:
src/
├── app/
│ ├── graph.ts // createCausl() lives here
│ └── App.tsx // top-level <CauslProvider>
├── features/
│ ├── cart/
│ │ ├── nodes.ts // inputs + derivations
│ │ ├── actions.ts // transaction-shaped helpers
│ │ ├── selectors.ts // derived nodes exposed to UI
│ │ ├── Cart.tsx // React components
│ │ └── nodes.test.ts
│ ├── catalog/
│ │ ├── nodes.ts
│ │ ├── resources.ts // @causl/sync fetches
│ │ ├── selectors.ts
│ │ └── Catalog.tsx
│ └── auth/
│ ├── nodes.ts
│ ├── persistence.ts // @causl/persistence wiring
│ └── SignIn.tsx
└── shared/
├── ids.ts // branded ID types
└── format.ts // pure helpers
Each feature folder is read-mostly: it exports its public nodes,
a small set of actions, and the components that render them.
Cross-feature coupling — and there will always be some — happens
in app/graph.ts or in a deliberately-named seam
module, never by reaching into a sibling's nodes.ts.
2. One graph, per-feature modules
You almost always want exactly one createCausl()
call per application instance. The graph is the unit of
atomicity: anything you want to commit together has to live in
the same graph. Splitting into multiple graphs splits your
transactions, which defeats the central reason to use causl in
the first place (see SPEC §5.1 on phases A–H).
But "one graph" does not mean "one file". Each feature should own its own module that registers its inputs and derivations against the shared graph:
// app/graph.ts
import { createCausl } from '@causl/core';
export const graph = createCausl({ name: 'app' });
// features/cart/nodes.ts
import { input, derived } from '@causl/core';
import { graph } from '../../app/graph';
export const items = input<CartItem[]>(graph, [], { name: 'cart.items' });
export const couponCode = input<string | null>(graph, null, { name: 'cart.couponCode' });
export const subtotal = derived(graph, g =>
g.read(items).reduce((sum, it) => sum + it.price * it.qty, 0),
{ name: 'cart.subtotal' },
);
export const total = derived(graph, g => {
const sub = g.read(subtotal);
const code = g.read(couponCode);
return code ? sub * 0.9 : sub;
}, { name: 'cart.total' });
Three things are doing work here. First, every node has a
name option — these names appear in devtools traces,
in graph.explain() output, and in checker
diagnostics, so spending five seconds picking a good one pays
back many times over. Second, the nodes.ts module
only declares state and computation; it does not
mutate. Third, derivations are pure functions of
g.read(...) calls — they never call
g.set, never start fetches, and never depend on
anything outside the graph.
Detailed explanation: why pure derivations. The Kahn topological recompute in Phase D (SPEC §5.1) relies on derivations being deterministic functions of their inputs. A derivation that callsfetch()or readsDate.now()is observably non-deterministic — the same input state can yield different outputs across two recomputes, which is the textbook glitch-freedom violation. If you find yourself wanting to do this, the answer is almost always an@causl/syncresource (see SPEC.async.md).
3. Keep mutations in actions.ts
Components should not call graph.commit directly.
Wrap every mutation in a named function in the feature's
actions.ts:
// features/cart/actions.ts
import { graph } from '../../app/graph';
import { items, couponCode } from './nodes';
export function addItem(item: CartItem) {
graph.commit(() => {
const current = graph.read(items);
graph.set(items, [...current, item]);
});
}
export function applyCoupon(code: string) {
graph.commit(() => {
graph.set(couponCode, code);
});
}
Three benefits. The vocabulary of state change for the
feature is enumerable — read actions.ts and you
know everything that can happen to the cart. Tests can call the
same functions the UI does. And when a future commit needs to
change two or three inputs together (the moment where atomicity
actually earns its keep), the wrapping transaction already
exists.
4. Separate UI from graph at the selector seam
The boundary between React and the graph belongs in
selectors.ts. A selector is a derivation — usually
a thin wrapper — that the UI subscribes to via
useCauslNode. Components import from
selectors.ts, not from nodes.ts:
// features/cart/selectors.ts
import { derived } from '@causl/core';
import { graph } from '../../app/graph';
import { items, total } from './nodes';
export const cartLineItems = derived(graph, g =>
g.read(items).map((it) => ({
id: it.id,
label: `${it.name} × ${it.qty}`,
price: it.price * it.qty,
})),
{ name: 'cart.cartLineItems' },
);
export const cartSummary = derived(graph, g => ({
count: g.read(items).length,
total: g.read(total),
}), { name: 'cart.cartSummary' });
// features/cart/Cart.tsx
import { useCauslNode } from '@causl/react';
import { graph } from '../../app/graph';
import { cartLineItems, cartSummary } from './selectors';
import { addItem } from './actions';
export function Cart() {
const lines = useCauslNode(graph, cartLineItems);
const summary = useCauslNode(graph, cartSummary);
return (
<section>
<ul>{lines.map((l) => <li key={l.id}>{l.label} — ${l.price}</li>)}</ul>
<footer>{summary.count} items · ${summary.total}</footer>
</section>
);
}
The seam matters because selectors are where you shape data
for rendering. Components do not slice raw
items arrays; they consume the shape the selector
hands them. When you later want to memoize differently, push
work to @causl/sync, or render in a worker, you
change the selector and the component does not move.
Reference identity is not contractual.graph.read()does not guarantee referential equality across reads — even for unchanged values (SPEC §15.1). If your component depends on stable identity (e.g. for React keys, or as auseEffectdependency), select the primitive fields you actually need rather than the whole object.useCauslNodehandles shallow comparison for you; deeper invariants belong in the selector.
5. Naming conventions
Consistent naming makes the devtools trace, the checker output, and code review all easier:
| Kind | Convention | Example |
|---|---|---|
| Input | noun, lowercase | items, couponCode |
| Derivation | noun or computed adjective | subtotal, isCheckoutReady |
| Action | verb in present tense | addItem, applyCoupon |
Resource (@causl/sync) | noun + Resource | productCatalogResource |
| Selector | noun describing the view | cartLineItems, cartSummary |
name option | feature-dot-node | 'cart.items' |
Two things to call out. Booleans as derivations should read like
assertions: isCheckoutReady, not
checkoutReadyFlag. And the name option
should be feature-prefixed because devtools sort lexically — a
flat list of items, total, user
is unnavigable once you have more than two features.
6. Avoiding god-graphs
A "god-graph" is what you get when every feature reaches into every other feature's nodes and the dependency graph becomes a single ball that nobody can refactor. The symptom is usually a derivation that reads from eight different feature modules and a commit that nobody is sure is atomic for the right reason.
Three habits keep it from happening:
(a) Cross-feature dependencies go through selectors.
If checkout needs to know about cart.total,
it imports cartSummary from
features/cart/selectors, not the raw
items input. The selector is the published surface;
the input is private. The convention is enforceable by lint
(see @causl/checker's import-boundary pass).
(b) Periodically run graph.explain().
It prints the node names, their dependencies, and the recompute
order. If a single derivation depends on more than a handful of
inputs across multiple features, that derivation is doing too
much — split it.
(c) Keep one bidirectional edge per feature pair.
Two features can absolutely depend on each other (cart depends
on inventory; inventory depends on cart for reservations). But
that dependency should travel through exactly one named
selector in each direction. If you find three or four, you are
growing a god-graph and the right move is to extract a
shared/ sub-feature that owns the common nodes.
When to break these rules
Prototypes. Small apps where the whole graph fits in one file
and one person's head. Demos for a talk. The shape above pays
back at maybe 30 nodes and a second contributor; below that, a
single graph.ts with everything in it is fine and
will not haunt you.
causl is not the right tool for every state problem (see the homepage note on where it fits) — but where it is the right tool, the layout above is what we have seen survive feature growth, team growth, and the kind of refactor that turns up six months in.