A field guide: which technique solves which checklist rule
The variants in this project were whole designs, each answering “what should an ALA LiveView app look like?” from scratch. But if you already have an app and you are iterating it toward ALA, you almost never want a whole variant. You want the one move that clears the one finding in front of you: the linter flagged a peer call, or a literal in the wrong layer, or a struct two features share. So the useful residue of twenty-odd experiments is not the designs. It is a catalog of techniques, each of which pays down one or two rules of the ALA Checklist.
This is that cross-reference, rule by rule. Most techniques help more than one rule, so a few appear twice. Where a technique came from a specific variant I have named it, but treat these as ingredients, not recipes. The point is to pick the lightest one that clears your rule and move on. Each technique has a tiny Elixir sketch — the shape, not runnable code.
R1: every edge drops (no peer or upward calls)
This is the rule everything else serves, and it has the most techniques.
Emit an outcome instead of calling a peer. A feature returns a description of what should happen (V10’s and V36’s typed outcomes, V14’s signals) rather than calling the sibling that should do it. The call that would have crossed sideways never exists. This is the single most effective structural move for R1, because it removes the temptation rather than policing it.
# Wishlist doesn't call Cart; it returns what should happen.
def remove(%Wishlist{} = wl, id),
do: {drop(wl, id), [Outcome.stream_delete(:wishlist, id)]}
Wire cross-feature work in one place. V26, V28, and V36 put every join between features in the composition, a handle/3 clause or an aggregate whose whole job is wiring. Features stay ignorant of each other; the one module allowed to know both does the connecting.
# only the composition is allowed to name two features:
def handle(:item_removed, item, page),
do: page |> Cart.remove(item.id) |> RecentlyViewed.record(item.product)
Make the wiring declarative. V29’s manifest and V30’s committed codegen route one feature’s typed fact to another’s reaction through generated glue. A feature never names a peer even in the composition, because the composition is generated from a data table.
# a data table, not hand-written code, connects the features:
reactions: [{Cart.Facts.ItemRemoved, RecentlyViewed.Intents, :track_removed}]
Enforce it in CI. V27 shipped a custom Credo check (AlaLayerBoundary) that fails the build on an upward or cross-peer call; ala_lint’s R1-altitude check does the same given a layer map. Neither prevents the coupling, but both catch it the moment it appears, which is what turns discipline into a guarantee.
# fail the build on a sideways/upward edge (needs a layer map):
mix ala.lint --layers-module MyApp.AlaLayers --min-score 90
R2: no shared mutable state between peers
Give each feature a private struct. V24’s isolated capsules and V26’s and V36’s per-feature structs mean there is no shared mutable thing for two peers to fight over. Elixir’s immutability does the rest.
defmodule Wishlist do
defstruct products: [] # only Wishlist reads or writes this
end
Keep one source of truth and derive the rest. V13’s reactive split (source fields, derived fields recomputed from them) kills the classic bug where a handler updates items but forgets total. There are no two mutable copies to diverge, because the second is computed, never stored-and-updated.
# `items` is the source; `total` is always derived, never stored:
def total(%Cart{items: items}), do: Enum.reduce(items, 0, &(&1.amount + &2))
Let the composition supply cross-slot values. V35’s composed inputs is the sharpest R2 move. A feature that needs a sibling’s data does not read the shared session slot; the composition reads it and passes the value in as an argument. The shared mutable channel is gone, and a detector flags any session.<peer_slot> access that tries to bring it back.
# checkout declares the need as a parameter; it never reads session.inventory:
def pay(session, stock_levels), do: ... # composition resolved stock_levels
R3: application literals live at the composition
Put the store’s constants in one module at the top. V34’s manifest holds calibration per feature; V36 keeps it in a @pricing attribute on the composition; V38 uses a GoodDeal.Catalog module. All three do the same thing: gather the product-specific numbers in one readable place at the top of the diagram.
defmodule GoodDeal.Catalog do
def shipping_rates, do: %{standard: 500, express: 1500}
def gift_wrap_cents, do: 299
end
Make the domain take calibration as an argument. Once the constants live at the top, the abstractions below take them as parameters (Shipping.cost(rate_info, subtotal)), so they hold no store-specific number and drop into a different product unchanged. The literal flows down; it is never read up.
# no store-specific number inside — the rates arrive from the top:
def cost(rates, method, subtotal), do: ...
R4: state is threaded, not hidden
Write a functional core. V10’s typed TEA, V36, and V38 all keep feature logic as pure functions over a struct: state comes in as an argument, new state goes out as a return value. No GenServer stashing, no process dictionary, no module attribute holding mutable data. In Elixir this is also the path of least resistance, which is why it barely reads as a technique.
# state in, new state out — nothing hidden between calls:
def add(%Cart{} = cart, item), do: %{cart | items: [item | cart.items]}
Name your state partitions. V9’s split of the page struct into CartState, UIState, and CheckoutState makes every piece of state a visible, named field rather than a bag. It costs nothing and makes R4 self-evident on reading.
defstruct cart: %CartState{}, ui: %UIState{}, checkout: %CheckoutState{}
R5: no silent contracts
A silent contract is an agreement two modules share that appears in neither’s signature: a magic string, a tuple shape, a session key.
Type your effects. V10’s and V36’s outcome constructors turn “return {:flash, :info, msg} and hope the other side matches” into Outcome.flash(:info, msg), where a typo is a compile error and the shape is documented by the struct. This is the highest-leverage R5 move if you are starting fresh.
Outcome.flash(:info, "Order placed") # a typo is now a compile error
# ...not {:flash, :info, msg}, a shape the other side merely hopes to match
Declare names once. V35’s Contracts module holds every event, stream, and hook name, so they are never written as literals in two places. A rename happens in one file. V38 does the same for one-off contracts with tiny single-source modules (CartSession for a session key, CheckoutMetadata for a payment-metadata field).
defmodule Web.Contracts do
def stream_name(:cart), do: "cart-items" # one source; both sides call this
end
Check the places signatures cannot reach. V32’s component-purity checker looks inside HEEx templates, where a phx-click="save" string is a contract the compiler never sees. Committed codegen (V30) helps too, by making the generated contract explicit in reviewable source.
~H|<button phx-click="save">Save</button>|
# "save" is a contract the compiler never sees — V32's checker verifies it exists
R6: every abstraction names a learnable concept
This one resists tooling; it is mostly judgment. But two habits help.
Slice by feature, and name the slice. V4’s feature slices, V23’s composable units, and V36’s one-module-per-feature all force you to name the concept before you write it. A module called Wishlist has to earn the name.
defmodule Wishlist do ... end # you must name the concept before writing it
Name domain abstractions by their kind, not their use. OffsetAndScale, Boiler, LowPassFilter: each names what it is, so a reader learns it once and reuses it. The linter’s R6 heuristic will flag a function that just wraps a primitive, which is a useful nudge, but the real check is whether you can say what the thing is in a sentence.
defmodule LowPassFilter do ... end # what it is — not "TemperatureSmoother"
R7: every abstraction earns its existence
Carry the least machinery that works. V36’s vernacular core is the argument by example: it reaches full compliance with almost no apparatus, so there is little to justify. When you find yourself adding a layer to hold a layer, that is the smell R7 names.
# V36: a plain function returning a typed outcome — no manifest, no macro:
def remove_item(cart, id), do: {Cart.drop(cart, id), [Outcome.stream_delete(:cart, id)]}
Let the linter catch proliferation. ala_lint’s pass-through and dead-code detectors (refined during V38) flag the one-in, one-out forwarding function that adds a name and a hop but hides no decision. Inline it, or confirm it is a real port and keep it. Either way you made the call on purpose.
def save(x), do: Repo.insert(x) # 1-in/1-out, hides no decision → ala_lint flags it
R8: the composition reads as the requirements
Make the wiring a document. V29’s and V30’s manifest is literally the page’s requirements as data: which features, which reactions, which config. Reading it tells you what the page does. V33’s flows channel does this for wizards, a transition table you can read as the spec.
# the manifest reads as the page's spec:
page :cart, features: [Cart, Wishlist, Checkout], reactions: [...]
Keep the top layer legible. V36’s handle/3 clauses read top to bottom as the page’s behaviour, one clause per user action. When the composition is a handful of straight-line clauses, R8 comes for free; when it is full of branching logic, it does not, which is exactly what the checklist’s R11 is about.
def handle(:remove_item, %{id: id}, page), do: Cart.remove(page, id)
def handle(:checkout, _args, page), do: Checkout.start(page)
R9: ports carry paradigm-typed data, not domain identities
Return instructions, not events. The difference between V36’s Outcome.stream_insert(:cart, item) and a domain event like %CartItemAdded{} is the whole rule. An outcome is a paradigm-level instruction the shell knows how to run; it does not make another feature learn cart-specific keys. Typed outcomes (V10, V36) and signals (V14) are the technique.
Outcome.stream_insert(:cart, item) # an instruction the shell knows how to run
# not %CartItemAdded{} — that makes peers learn cart-specific keys
Collapse I/O into one paradigm value. The coffee maker’s SensorReading (all inputs) and HardwareCommand (all outputs) give the application a port shaped by its kind, not by any particular device. V25’s projections do the same for cross-feature reads: a feature exposes a projection shaped for reading, and peers read that, never its struct.
# one value shaped by kind, not by any particular device:
%HardwareCommand{boiler_heater: :on, warmer_heater: :off, relief_valve: :closed}
R10: no shared entity
Give features private structs and share only a key. V24’s isolated capsules, V26’s dual capsules, and V36’s per-feature structs mean two features on a page never hold the same data struct. They share an identity (a product id) and keep their own data private. This is the by-construction answer, and it is why V36 satisfies R10 without trying.
%Wishlist{product_ids: [id]} # share the id...
%Cart{items: [%{product_id: id}]} # ...not each other's struct
Mediate cross-feature reads through a projection or the composition. When feature A genuinely needs something feature B knows, V25 hands A a projection of B (not B’s struct) and V35 has the composition resolve the value and pass it in. The shared entity never forms.
# A reads B's projection, never B's struct:
def projection(%Inventory{count: n}), do: %{in_stock?: n > 0}
R11: the application layer is composition only
Generate the glue. V30’s committed codegen makes the composition a generated file, so the hand-written page module stays thin and the logic lives in features. The top layer cannot accumulate cleverness because you do not write it by hand.
mix zc.gen # write the composition from the manifest
mix zc.gen --check # fail the build if the committed glue drifted from it
Interpret effects at a thin edge. V36’s shell reduces a list of outcomes into view state and does nothing else; the composition above it is wiring. V20’s interceptor pipeline and V1’s page-and-dispatch are older takes on the same idea, a top layer that only dispatches.
# the shell folds outcomes into view state and does nothing else:
Enum.reduce(outcomes, socket, &apply_outcome/2)
A caution on this one. R11’s strict reading, “no if-statements at the top,” is the one rule a real LiveView cannot fully obey, because handle_event and mount branch by nature. That is why ala_lint scores R11 only under super-strict. Aim for a top layer that branches to sequence work, never to compute it, and do not chase the last of it if the only way there is machinery you would not otherwise want.
How to use this when starting from scratch
Do not adopt a variant. Adopt the checklist, run ala_lint against what you have, and let the findings send you shopping in this list. Most well-structured Elixir apps are already close on R1, R2, and R4, because immutability and ordinary module boundaries hand you a lot. The work that remains is usually R3 (get the constants to the top) and R5 (name the contracts), and both have cheap, vernacular fixes above. Reach for the heavier techniques, typed outcomes, a manifest, committed codegen, only when a rule you actually care about is not holding by discipline alone. The lightest technique that clears the rule is the right one.