V36, the vernacular core: ALA that reads like ordinary Phoenix
Every earlier variant in this project bought ALA compliance with machinery: manifests, code generation, custom Credo checks, a signals vocabulary. Each works, and each costs familiarity. A new developer has to learn the apparatus before they can read a feature. V36 asks a different question. How little of that apparatus can you keep and still be ALA?
The answer turns out to be: almost none, if you are willing to hold the line by hand and let a linter watch the line for you.
The shape
V36 has three kinds of module and nothing else.
A functional core, one module per feature, each a plain struct with pure functions over it. Cart, Wishlist, Undo, Checkout. No processes, no framework, no knowledge of any other feature.
defmodule Shop.Cart do
defstruct [:items, :pricing, :shipping, :promo, :gift_wrap]
def new(opts), do: %__MODULE__{items: [], pricing: Keyword.fetch!(opts, :pricing), ...}
def add_item(%__MODULE__{} = cart, item) do
cart = %{cart | items: [item | cart.items]}
{cart, [Outcome.stream_insert(:cart_items, item)]}
end
end
A feature takes state and arguments, returns new state and a list of outcomes. Outcomes are typed structs describing what should happen to the outside world, and they are the only vocabulary that crosses a boundary:
defmodule Shop.Outcome do
defmodule Flash do defstruct [:level, :message] end
defmodule StreamInsert do defstruct [:name, :item] end
defmodule Patch do defstruct [:to] end
# ... StreamDelete, StartTimer, CancelTimer
end
Notice what an outcome is not. It is not a domain event with cart-specific keys that another feature must understand. It is a paradigm-level instruction (insert into a stream, show a flash, start a timer) that the shell knows how to run. A feature never names another feature. And each feature owns a private struct that no other feature reads, which is the checklist’s R10 (no shared entity) satisfied by construction: features share only the composition’s coordination, never a common data struct.
The composition, one module, is the only place that knows the page is made of these features and how they connect:
defmodule ShopWeb.CartPage do
alias Shop.{Cart, Wishlist, Undo, Checkout}
@pricing %{
shipping: %{standard: %{cost: 599, free_above: 5000}, express: %{cost: 1299, free_above: nil}},
promo: %{"SAVE10" => 10, "HALF" => 50},
gift_wrap: 299
}
defstruct [:cart, :wishlist, :undo, :checkout]
def new(opts \\ []) do
%__MODULE__{cart: Cart.new(Keyword.put(opts, :pricing, @pricing)), wishlist: Wishlist.new(), ...}
end
def handle(:remove_item, %{item_id: id}, s) do
{cart, removed, outs} = Cart.remove_item(s.cart, id)
if removed do
{undo, undo_outs} = Undo.capture(s.undo, removed) # cross-feature follow-up, in plain sight
{%{s | cart: cart, undo: undo}, outs ++ undo_outs}
else
{%{s | cart: cart}, outs}
end
end
end
And a thin shell that touches the framework and interprets outcomes at the edge:
defmodule ShopWeb.CartShell do
def dispatch(page, view, action, args) do
{page, outcomes} = CartPage.handle(action, args, page)
{page, Enum.reduce(outcomes, view, &apply_outcome/2)}
end
defp apply_outcome(%Outcome.Flash{} = f, v), do: %{v | flashes: v.flashes ++ [{f.level, f.message}]}
defp apply_outcome(%Outcome.StreamInsert{name: n, item: i}, v), do: put_in(v, [:streams, n], ...)
end
In a real LiveView the shell is three lines: read the action, run CartPage.handle, assign the result. Everything above it is pure and framework-free.
Why this is ALA, not just tidy Elixir
The down-only rule holds, and you can see it without a diagram. The shell (application) calls the composition. The composition (application) calls features. Features call the domain (pricing) and the platform (outcome constructors). Nothing calls sideways: no feature mentions another feature, because the one place cross-feature coordination happens, CartPage.handle, is the composition, whose entire job is wiring. When removing an item needs to also capture an undo, that is two explicit calls in one clause, in the layer that is allowed to know both.
The requirements live in one place too. @pricing is the diagram config: the shipping costs, promo codes, and gift-wrap price that make this a particular store. The generic Shop.Pricing calculations take it as a parameter and stay product-free. Change a shipping cost and you touch one map.
State is a wire, never hidden. Every feature function takes state and returns state. There is no GenServer stashing, no process dictionary, no module attribute holding mutable data.
That purity shows up in the test suite. V36’s 12 tests run in about 0.05 seconds, with no Phoenix, no database, and in fact no dependencies at all: the variant compiles and tests offline, because everything above the thin shell is a pure function over a struct. Fast, framework-free tests are not a bonus you bolt on here; they fall out of the down-only rule keeping the framework at the very top.
When I run ala_lint against V36 with a layer map, it reports 94 out of 100, zero peer coupling, zero upward edges, an abstraction height of 3, and only two application literals in the whole codebase. It is the cleanest of all the variants on the tool’s measures, and it is also the one a Phoenix developer can read cold.
Adding a feature, end to end
The whole design is teachable from one worked example. Say you want a wishlist toggle.
1. Write the feature as a plain module: a private struct and pure functions that return {state, outcomes}. No behaviour to use, no macro, no framework.
defmodule Shop.Wishlist do
defstruct [:ids]
def new, do: %__MODULE__{ids: MapSet.new()}
def member?(%__MODULE__{ids: ids}, id), do: MapSet.member?(ids, id)
def toggle(%__MODULE__{} = w, id) do
if member?(w, id) do
{%{w | ids: MapSet.delete(w.ids, id)}, [Outcome.flash(:info, "Removed from wishlist")]}
else
{%{w | ids: MapSet.put(w.ids, id)}, [Outcome.flash(:info, "Saved to wishlist")]}
end
end
end
2. Add a slot to the composition and one handle/3 clause per action. The composition is the only module that names the feature.
# in ShopWeb.CartPage
def handle(:toggle_wishlist, %{item_id: id}, s) do
{wishlist, outs} = Wishlist.toggle(s.wishlist, id)
{%{s | wishlist: wishlist}, outs}
end
3. If the action has a cross-feature consequence, write it as explicit calls in that one clause. This is where ALA puts the wiring, and it stays readable because it is literally two function calls in the layer allowed to know both features. Removing a cart item that should also become undoable:
def handle(:remove_item, %{item_id: id}, s) do
{cart, removed, outs} = Cart.remove_item(s.cart, id)
if removed do
{undo, undo_outs} = Undo.capture(s.undo, removed)
{%{s | cart: cart, undo: undo}, outs ++ undo_outs}
else
{%{s | cart: cart}, outs}
end
end
4. Render in the shell, never in the feature. The feature already returned its outcomes (a flash, a stream insert). The shell interprets them; the LiveView template reads the shell’s view state. Nothing about rendering leaks into Shop.Wishlist.
That is the entire recipe. The rules of thumb that keep it ALA are short enough to hold in your head: a feature never names another feature; cross-feature coordination lives only in a handle/3 clause; the application literals live in @pricing at the composition; state is always threaded, never stashed. If you break one, the linter’s layer-aware R1 catches it in CI.
What it gives up, and how to get it back
Nothing mechanical enforces any of this. There is no manifest that fails to generate, no custom check that rejects a bad edge at compile time. A developer in a hurry can write Wishlist.member?(...) inside the Cart feature, and the compiler will not stop them. V36’s compliance is a discipline, and disciplines erode.
The recovery is the point of the tool. Gate the repository with ala_lint in CI: a layer map, a minimum score, and the layer-aware R1 check that fails the build when a feature calls a feature. That turns the discipline into something the build enforces, without putting any machinery into the code a developer reads. It is arguably a better trade than baking the checks into a bespoke framework, because the code stays vernacular and the enforcement lives where enforcement belongs, in CI.
There is one real limit worth stating plainly. The linter reads Elixir, and a cross-feature call inside a ~H template is invisible to it. V36 keeps rendering in the shell and out of the features, so this does not bite here, but it is the reason the tool also runs an advisory reference-level check and the reason a human still has to read the templates. A green run is necessary, not sufficient.
If your team values one thing above all when picking an architecture, and that thing is that a new hire can read a feature on day one, V36 is the variant. The next post looks at the opposite end of the same project, a variant that spends familiarity to make one specific coupling impossible rather than merely lint-able.