V35, composed inputs: making one coupling impossible instead of lint-able
The V36 vernacular core stays vernacular and leans on a linter to enforce the layering; V30 pins the diagram with committed codegen. V35 builds on V30’s spine and makes a sharper bet on one specific coupling. Instead of trusting discipline and checking it after the fact, it changes the structure so the coupling cannot be written in the first place, and adds a detector so any attempt to sneak it back shows up.
The coupling in question is the one that quietly wrecks multi-feature LiveView pages: a feature reading another feature’s state.
The sneaky dependency
A page like a cart has several features sharing one session: cart items, wishlist, checkout, saved items. Each feature owns a slot of the session. In V35 a feature declares its slot when it opts into the intents behaviour:
defmodule CheckoutFlow.Intents do
use ZeroCoupled.Feature.Intents, slot: :checkout
# ...
end
Now checkout needs to do something that depends on stock. The stock levels are not checkout’s data. They belong to another part of the system. The path of least resistance is to reach across and read them off the session:
# tempting, and wrong
def pay(session, _args) do
levels = session.inventory # reading a peer's slot
# ...
end
That one line is a peer dependency wearing a disguise. Nothing about it looks like a call between features, because it is not a call, it is a field access. But two sibling features now share the meaning of a third’s state. Rename or restructure the inventory slot and checkout breaks, silently, with no edge in any call graph to warn you. This is exactly the communication dependency ALA’s second rule forbids, and it is invisible to the usual tools precisely because it is data, not a call.
The fix: the composition supplies the input
ALA’s answer is that a join between peers belongs in the layer above them, the composition. So the value checkout needs does not get read by checkout. It gets resolved by the page composition and passed in:
# checkout reads only its own slot; the cross-slot value arrives as an argument
def pay(session, stock_levels) do
# session.checkout only; stock_levels came from above
# ...
end
The feature’s signature now tells the truth. pay needs stock levels, and it declares that need as a parameter. It no longer knows where stock lives or who owns it. The composition, which is allowed to know about every feature because wiring is its job, reads the inventory slot and threads the value down. The dependency did not disappear. It moved to the one place it is legitimate, and it became visible in a signature instead of hiding in a field access.
This is why the variant is called composed inputs. A feature’s inputs are composed for it from above, rather than fetched by it from the shared state.
Making it a check, not a hope
Moving the coupling is necessary but not sufficient, because the next developer can always reach across the session again. So V35 adds a detector. It walks each feature’s intents module, learns the feature’s own slot from the use line, and flags any session.<other_slot> access:
A feature’s
Intentsmodule must not read a peer slot off the session. It may read and write only its own slot. Anysession.<other_slot>access is a cross-slot read, two sibling features sharing the meaning of a third’s state, the dependency R2 forbids. The composition is where cross-slot joins belong, so it resolves the value and passes it in.
The detector runs two ways: as a Credo check in the editor and the build, and as a standalone measurement so I can score a whole codebase for cross-slot reads at once. That dual form matters. The Credo check keeps new code honest; the measurement told me the design worked, by showing the count of peer-slot reads drop to zero once the composed-inputs pattern was applied across the features.
Adding a feature, the V35 way
V35 ships a verified end-to-end walkthrough (building a “recently viewed” list, the last five products removed from the cart). Condensed, it shows what the discipline asks of you and, just as importantly, what the checkers hold you to so you do not have to remember it.
1. One feature file, four sibling modules of a standard shape. The pure module owns state and its projection ports (values shaped for rendering, never a raw domain struct). The Intents module declares the feature’s slot, owns its browser events, and holds its reactions.
defmodule ZeroCoupled.Features.RecentlyViewed do
defstruct products: []
def init(_opts), do: %__MODULE__{}
def record(%__MODULE__{} = rv, product),
do: %{rv | products: [product | Enum.reject(rv.products, &(&1.id == product.id))] |> Enum.take(5)}
# projection ports, the presentation reads these, never the struct
def render_data(%__MODULE__{products: p}), do: %{count: length(p)}
def row(p), do: %{id: p.id, product: %{thumbnail: p.thumbnail, name: p.name, amount: p.amount}}
end
defmodule ZeroCoupled.Features.RecentlyViewed.Intents do
use ZeroCoupled.Feature.Intents, slot: :recent
intent :clear_recent
def clear_recent(session, _args),
do: {put_slot(session, %RecentlyViewed{}), [Effects.stream_reset(Contracts.stream_name(:recent), [])]}
# a reaction: (own slot, payload) -> {own slot, effects}. It reads only :recent.
def track_removed(%RecentlyViewed{} = rv, %{item: item}),
do: {RecentlyViewed.record(rv, item.product), [Effects.stream_insert(Contracts.stream_name(:recent), RecentlyViewed.row(item.product), 0)]}
end
2. Name the contract. Event, stream, and hook names are declared in Web.Contracts, never written as literals in markup or emitters. That is R5 (no silent contracts) made structural: one source for a name both sides agree on.
3. Wire it in the manifest, not by a call. The feature reacts to cart removals, but it never calls the cart. The manifest routes the cart’s typed :item_removed fact to track_removed. Cross-feature effects travel as declared facts through the composition, so no feature ever names a peer.
4. Regenerate and render. mix zc.gen splices the slot, its events, and its reactions into the generated glue; zc.gen --check fails the build if the committed glue has drifted from the manifest. The view renders one call site, reading the projected render_data port, not the feature’s struct.
The payoff of all this ceremony is that the rules become checks, not memory. From the walkthrough’s own list of what the tools enforce:
A feature reads and writes only its own slot; it never reads
session.<peer>(the composition resolves cross-slot data and passes it in). Cross-feature effects go out as typed facts wired in the manifest, never a direct peer call. No literal event or stream names in markup. Presentation reads projected ports, never a domain struct.
Adding the feature above touched a feature file, the contracts module, the manifest, and one call site. The checkers held the rest: zc.gen --check, the contracts check, the purity Credo checks, a reaction-placement audit, and the full test suite all had to stay green before it counted.
That suite is not slow, despite being a whole Phoenix app. V35’s 162 tests run in about 0.8 seconds. Because the features are pure functions over their own state and the presentation reads projected ports, most of the suite exercises plain data transforms, not a LiveView process or a database. The purity that ALA asks for is the same purity that makes the tests cheap.
What it costs, and where it sits
V35 is not free. Compared to V36 it asks a developer to learn more: the slot model, the intents behaviour, the composed-inputs convention, and the detector that enforces it. On my familiarity scale it sits at 3, where V36 sits at 5. That is the trade. You spend some approachability to make a class of bug structurally impossible and mechanically caught, rather than merely discouraged.
On the compliance side the spend pays off. V35 ties for the top of my composite score across all variants. It carries the whole zero-coupled machinery (committed code generation, presentation purity, the flows channel) and adds the composed-inputs resolution of R2 on top, which lifts its encoding-adherence score to the highest in the set. When ala_lint runs against it with a layer map, it reports zero peer coupling and zero upward edges among the features, and the cross-slot detector reports zero peer-slot reads.
Because V35 rides the same committed-codegen spine as V30, the same information-theoretic reading applies: its generated glue carries near-zero essential information (a language model reconstructs it at a fraction of a bit per token once the generator is in context), while the manifest is the dense, essential message. V35 therefore competes on the same small gap to the architecture-invariant domain-entropy floor — see the V30 post for the measured bits-per-token.
Which bet should you make
V35 and V36 are the same architecture viewed from two ends. Both drive feature-to-feature coupling to zero. V36 does it by keeping features pure, returning typed outcomes, and wiring everything in a plain composition, then trusting a CI linter to catch drift. V35 does it by keeping the session-and-slots model that larger teams often already have, forbidding peer reads within it, and enforcing that with a detector built into the build.
If your team is starting fresh and prizes a design a new hire reads on day one, V36. If your team already thinks in terms of a shared session with per-feature slots and wants the peer-read foot-gun removed at the structural level, V35. Neither is more ALA than the other on the rule that matters. They differ in what they ask you to learn and in how much of the guarantee lives in the code versus in the tooling around it.
A later refinement, and what a strict lint taught me
Running ala_lint --strict on V35 later prompted one real change and a couple of instructive non-changes. The real one: the 608-line diagram compiler was split into three single-concept modules: ManifestResolver (reads the manifest into data), FlowsGenerator (the V33 flows channel), and PageGenerator (emits the page code, now depending downward on the other two). The generated output is byte-identical, the 162 tests stay green, and each generator now reads and tests in isolation. It barely moved the score, since module size is lightly weighted. That is the point. The win is readability, and the number is too coarse to see it.
The non-changes were as informative. Strict mode first reported fifteen “dead” functions; every one was a false positive: live HEEx components invoked as <.tabs/> from inside ~H template strings, a macro-generated defp, and functions called through &name/1 captures, none of which a naive AST call graph sees. That was a bug in the tool, now fixed, not debt in V35. So were most of the “pass-throughs”: the Ecto context functions are %Struct{} |> changeset() |> Repo transforms, not bare renames, and the tool now recognises the difference. What genuinely remains is a little top-layer branching, V33’s deliberate URL-and-sequencing thesis, which the linter now scores only under --super-strict, where “no logic at the top” is treated as an aspirational ideal rather than a gate. The distance from V35’s strict score to V36’s is not refactorable debt; it is the cost of carrying codegen machinery, which is exactly what V36 declines to carry.