Structuring Causl Graphs

Causl will let you register ten thousand inputs and a hundred thousand derivations on a single Graph instance and it will, mechanically, continue to work. The Phase D Kahn topo recompute (SPEC §5.1) visits only the affected frontier on each commit, so the engine does not collapse under graph size. What collapses under graph size is your mental model. This page is about preserving it.

The shape of a causl graph is not architecture for its own sake. It is a load-bearing decision: it controls what the static checker sees, what the DevTools timeline shows, what subscribes to what, and which commits can land in parallel without conflict. The four sections below cover the patterns that hold up at scale, in roughly the order they tend to bite.

1. The god-graph anti-pattern

The most common shape new causl users reach for, almost without thinking, is one module exporting one giant graph with every input and derivation in the application registered on it.

// store.ts — DO NOT DO THIS import { createCausl } from '@causl/core'; export const graph = createCausl({ name: 'app' }); export const currentUser = graph.input('currentUser', null); export const cartItems = graph.input('cartItems', []); export const cartTotal = graph.derived('cartTotal', g => { /* … */ }); export const searchQuery = graph.input('searchQuery', ''); export const searchResults = graph.derived('searchResults',g => { /* … */ }); export const checkoutStep = graph.input('checkoutStep', 'address'); export const addressDraft = graph.input('addressDraft', { line1: '', city: '' }); export const paymentDraft = graph.input('paymentDraft', { card: '' }); export const orderHistory = graph.input('orderHistory', []); // …200 more, indented to follow.

The first symptoms are mild. The file gets long. Imports get noisy. A new feature has to grep the file for "does someone already own a node like this?". These are aesthetic complaints, easy to dismiss.

The real costs land later. The name namespace is flat, so collision risk grows linearly with the module's size. graph.exportModel() — the bridge to causl-check — emits a single IR document that now must be scanned in its entirety for every lint run. The DevTools timeline merges unrelated commits into one stream: a commit that only touched the checkout draft is visually indistinguishable from a commit that only touched the search query. Most importantly, when a feature is deleted, the nodes stay registered until you remember to call dispose on each one, because they are reachable from the module's top-level const exports.

The fix is not to split into N graphs. The fix is to split into N feature modules that each own a slice of one graph — or, when the feature is genuinely independent, into N graphs that each have a clear owner.

2. Per-feature decomposition

A feature in causl is a module that exposes a small bundle of nodes plus the operations that read or write them. The graph handle is an argument, not an import:

// features/cart.ts import type { Graph } from '@causl/core'; export function createCart(graph: Graph) { const items = graph.input<CartItem[]>('cart.items', []); const couponCode = graph.input<string | null>('cart.couponCode', null); const subtotal = graph.derived('cart.subtotal', g => g.read(items).reduce((sum, item) => sum + item.priceCents * item.qty, 0), ); const discount = graph.derived('cart.discount', g => { const code = g.read(couponCode); if (!code) return 0; return Math.floor(g.read(subtotal) * discountRate(code)); }); const totalCents = graph.derived('cart.total', g => g.read(subtotal) - g.read(discount), ); function addItem(item: CartItem) { graph.commit('cart/addItem', tx => { tx.set(items, [...graph.read(items), item]); }); } function applyCoupon(code: string) { graph.commit('cart/applyCoupon', tx => { tx.set(couponCode, code); }); } return { items, subtotal, discount, totalCents, addItem, applyCoupon }; }

Two conventions in that example carry most of the structural weight:

  1. Stable, dotted node IDs. Every ID is prefixed with the feature name (cart.items, cart.subtotal). The dotted segments are not parsed by the engine — the ID is opaque — but they give humans, the DevTools, and migration codemods a reliable per-feature search prefix. The GRAPH_ID_REGEX exported from @causl/core defines the allowed character set; within those rules, pick a hierarchy and stick to it.
  2. Commit intents are namespaced operations, not actions. The first argument to graph.commit is the intent string that the commit log records and that DevTools displays. Use 'cart/addItem', not 'set items'. When you grep the commit log for a bug, the slash convention means the feature owner is the first thing you see.

A feature module like the one above never imports a global graph. It receives one. That single change — passing the graph as a parameter to a factory — is what makes the rest of the patterns on this page possible.

Detailed explanation: why factories, not modules? A module's top-level const is evaluated once, at first import. If you register a node at the top level of features/cart.ts, you have to make sure cart.ts is imported exactly once and that the graph it registers against exists by then. That seems easy until tests ask for a fresh graph per case, or SSR asks for a fresh graph per request, or HMR re-runs the module against the old graph and throws DuplicateNodeError. A factory function takes the graph as a parameter and is safe to call repeatedly against different graphs.

3. Mounting and unmounting subgraphs

Per-feature decomposition handles the static shape of the application: features that exist for the lifetime of the process. Many real applications also have dynamic subgraphs: one node per row in a virtualised list, one bundle of nodes per open document tab, one bundle per WebSocket subscription. These come and go.

The wrong instinct is to lazily register nodes inside a render function or effect. That works once. The second mount throws DuplicateNodeError, the second unmount leaks the registration, and the DevTools timeline fills with DerivedRegistrationStackOverflowError entries because a node was created during a commit it had no business participating in.

The right pattern in React is useCauslFamily(key, factory) from @causl/react. It gives you a stable Node<T> per key, identity-shared across consumers, and disposes the node when the last consumer unmounts.

import { useCauslFamily, useCausl } from '@causl/react'; import type { Graph, Node } from '@causl/core'; type Row = { id: string; label: string; checked: boolean }; function rowNode(graph: Graph, key: string): Node<Row> { return graph.input(key, { id: key.slice('row:'.length), label: '', checked: false }); } function RowView({ rowId }: { rowId: string }) { const node = useCauslFamily(`row:${rowId}`, rowNode); const row = useCausl(g => g.read(node)); return <li>{row.checked ? '[x]' : '[ ]'} {row.label || row.id}</li>; }

Three properties of useCauslFamily are worth pinning down. First, the factory runs exactly once per key: the first mount with a new key invokes it; later mounts return the cached node. Second, the key is opaque to causl — the engine sees whatever ID the factory passes to graph.input / graph.derived. Third, dispose is deferred, not synchronous; if the same key remounts in the next microtask, the node is reused. That last property is what makes scroll-driven virtualisation cheap: a row that scrolls out and back in does not pay re-registration cost.

Outside React, the same pattern is a hand-rolled registry that reference-counts each key and calls the dispose hook on the adapter layer when the count reaches zero. The mechanics live in SPEC §9 (lifecycle) and the worked source is packages/react/src/useCauslFamily.ts; copy the bones, swap React's useEffect for your own lifecycle hook.

Composing a subgraph, not a single node

Many dynamic features need more than one node per key — a row has an input for the raw record plus a derivation for its computed display state. The convention is to return both from the factory inside a small record:

function rowBundle(graph: Graph, key: string) { const raw = graph.input(`${key}.raw`, defaultRow(key)); const display = graph.derived(`${key}.display`, g => formatRow(g.read(raw))); return { raw, display }; } // The family hook holds the bundle as the value; identity stays stable. const { raw, display } = useCauslFamily(`row:${rowId}`, rowBundle);

The disposal contract still holds: when the last consumer of the bundle unmounts, both nodes are released through the adapter-layer dispose hook and the namespace is freed.

4. One graph or many?

Causl supports both shapes. createCausl() returns an independent Graph handle — you may have ten of them in the same process, each with its own commit log, its own DevTools timeline, and its own snapshot file. The question is which costs you are willing to pay.

The single-graph default is the right one for most applications. A commit spans only one graph; if two features are correlated — "when the cart changes, the checkout step might invalidate" — they must share a graph, because there is no cross-graph transaction primitive. Splitting them means writing your own coordination code, which is exactly the thing causl exists to take off your hands.

Multiple graphs are the right answer in a small number of situations:

SituationWhy a separate graph helps
SSR per request Each request needs an isolated graph that lives only for the response. Reuse the same factory; just call createCausl() per request.
Embedded plugin / iframe The plugin owns its own state and never participates in the host's transactions. A separate graph keeps the namespaces disjoint and makes the trust boundary explicit.
Background worker pool A worker that computes a derivation off the critical commit path may keep its own graph and forward results via the host's bridge. The two graphs never share a commit.
Independent tabs of the same product A multi-document interface where each tab is genuinely independent — closing the tab disposes the graph. Cheaper than tagging every node with a tab ID.

The honest cost of multiple graphs: subscriptions cross a process boundary you now own, commits can't atomically affect more than one graph, the checker runs once per graph (not once for the union), and DevTools shows one panel per graph. Reach for it when the isolation is the point. Avoid it when you only wanted namespacing — that is what dotted IDs and feature factories are for.

When causl is not the right tool. If the work you are doing is "pipe one stream of events into one rendering target" — a feed, a chat tail, a log viewer — the transactional commit model is more machinery than the problem needs. Causl's win is when many cells depend on many cells, and a single user gesture has to leave the graph in a consistent state. If you do not have that shape, a simpler reactive primitive will serve you better.

5. A short checklist

Before you ship a feature, walk this list. Each item maps to a bug class that an early reader of this page has actually hit.

A graph that follows these rules tends to stay legible at ten thousand nodes. A graph that ignores them tends to need a rewrite somewhere around five hundred.

Related reading