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 calls fetch() or reads Date.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/sync resource (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 a useEffect dependency), select the primitive fields you actually need rather than the whole object. useCauslNode handles 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:

KindConventionExample
Inputnoun, lowercaseitems, couponCode
Derivationnoun or computed adjectivesubtotal, isCheckoutReady
Actionverb in present tenseaddItem, applyCoupon
Resource (@causl/sync)noun + ResourceproductCatalogResource
Selectornoun describing the viewcartLineItems, cartSummary
name optionfeature-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.