V38, lint-guided evolution: making a conventional app ALA

September 24, 2026 · ALA · V38 · LiveView · ala_lint

Every other variant in this project started the same way. Design a target architecture (dual capsules, committed codegen, composed inputs, a vernacular core), build to it, then measure. Each answered “what should an ALA LiveView app look like?” and constructed that shape, usually with some machinery to hold the shape in place.

V38 asks the question a real team actually has. “I already have a normal app. Can I get to ALA from here, incrementally, without adopting any of that machinery?” The only instrument is the linter. Each finding is a prompt, each fix is a small refactor, and the tests stay green the whole way. If V36 is ALA reached by construction, V38 is the test of ALA reached by evolution.

The starting point

V38 is a fork of the oldest, plainest app in the collection: v09, the “disciplined monolith.” It is a normal Phoenix shopping cart. Contexts under GoodDeal.Foundation, business logic under GoodDeal.Domain, LiveViews under GoodDealWeb, typed state partitions for the cart page. No ALA layering was ever imposed on it. Forty-two modules, a hundred and sixty-odd functions, a running app with Ecto persistence and an async checkout flow behind a swappable payment gateway.

The rule I set for the experiment: use only ordinary modules and functions, one declared layer map, and ala_lint. No manifest, no code generation, no typed-outcome vocabulary, no bespoke Credo checks. If ALA needs that machinery to be reachable, V38 gets stuck and says so. If it does not, V38 is the cheapest adoption path for an app you already have.

Then the first surprise. I declared a layer map, which turned on the altitude checks, expecting a pile of upward and cross-feature calls to fix. The linter reported up=0, peer=0. The disciplined monolith was already zero-coupled. Knowledge already flowed down, and no feature reached into a peer.

That reframed the whole exercise. The hard ALA property, the one most designs fight for, was already satisfied by a well-structured conventional app. What the checklist actually had to offer was the other commitment: getting the product-specific knowledge to the top so the abstractions below go generic.

The loop

Each iteration was a small change that kept mix test green and moved one finding. The layer map came first, because declaring the intended altitudes is itself the first act of ALA. After that:

Hoist the application literals. The business constants (shipping rates, promo codes, a gift-wrap fee, a low-stock threshold) were baked into the Domain modules. The checklist wants them at the composition so the abstractions stay reusable. So they moved to one module, GoodDeal.Catalog, and the domain functions became generic, taking their calibration as arguments:

defmodule GoodDeal.Catalog do
  @moduledoc "The store's application literals, gathered at the composition."
  def shipping_methods do
    [%{method: :standard, label: "Standard (5-7 days)", cost: 599, free_above: 5000},
     %{method: :express, label: "Express (2-3 days)", cost: 1299, free_above: nil}]
  end
  def gift_wrap_cents, do: 299
end

The composition reads Catalog and threads the values down into the feature state. Nothing below reaches up for them, which would be an upward dependency:

def recompute(cart) do
  sub = Pricing.subtotal_cents(cart.items)
  {discounted, disc} = Pricing.apply_discount(sub, cart.promo_percentage || 0)
  ship = Shipping.cost(Shipping.find(cart.shipping_methods, cart.shipping_method), discounted)
  # ... folds shipping and gift wrap into the total
end

Shipping.cost/2 and Pricing.gift_wrap_total/2 now hold no store-specific number. They would drop into a different shop unchanged, which is the whole point of an abstraction.

Fold in features to have more code to check. To keep applying the checklist to fresh, non-trivial code, I ported two user-visible features from V35: shipping-method selection and gift wrapping. Both existed as domain functions in v09 but were never wired to the UI. Wiring them, in the app’s own conventional style, gave the calibration hoist above its real payload.

Single-source the silent contracts. The linter flagged four duplicated string literals as possible R5 silent contracts. Three were false alarms, and recognizing that is the point of doing this by hand: ~p"/cart" routes are compile-checked against the router, not silent, and a "products" string was a PubSub topic in one place and an Ecto table name in another, a coincidence, not a contract. The one real one was the cart-id session key, written as an atom by a plug and read as a string by three LiveViews. That became a small module both ends depend on.

Refactor the presentation once. The low-stock threshold was used seven times in a template as a display helper. Threading a bare number through all seven calls would have read worse, so instead the status is computed once at the composition and flows down as data, which removed the sevenfold recomputation and made the badge components pure.

What the linter guided, and what it could not

The linter is a fast gross filter, and V38 is a good demonstration of the line between what it proves and what it cannot. It proved the altitude was clean, caught every scattered literal, and named every duplicated string. It could not tell me which of those duplicated strings was a real contract and which was a coincidence. It could not tell me the low-stock threshold deserved a presentation refactor rather than a thread-through. And it could not judge whether the abstractions were the right ones.

The experiment also flushed out real bugs in the tool. Working on a component-heavy LiveView surfaced false positives a naive call graph produces: HEEx function components invoked from inside ~H template strings, functions called through &capture/1, ~p route strings mistaken for contracts, and multi-step pipes mistaken for renames. Each got fixed in ala_lint itself, because the honest response to a false positive is to teach the tool, not to contort the app. That is the reciprocal benefit of running a linter against real code: the code gets cleaner, and so does the linter.

Where it landed

After five iterations, V38 scores 96 out of 100 normally and 96 under --strict, with R1 altitude still up=0, peer=0 and 102 tests passing. Under --super-strict it drops to 90, and that gap is entirely top-layer branching: a LiveView’s handle_event and mount branch by nature, and the strict ideal of “no logic at the top” is one V38 does not chase, because reaching it means adding indirection the charter forbids.

The honest lesson is the one the starting point already hinted at. A well-structured conventional Phoenix app was zero-coupled before I touched it. What the ALA Checklist added was not decoupling but requirements-locus: pushing the store’s specifics to the top until Pricing, Shipping, and Inventory were genuinely reusable. The linter earned its keep less by finding coupling and more by forcing a clear judgment on every literal and every duplicated string, real or not. ALA by evolution reached essentially the same place as ALA by construction, which is a reassuring result for anyone who has an app already and no appetite to rewrite it.

V38 next to V36 and V35

That last claim invites a direct comparison, because V36 (the vernacular core) and V35 (composed inputs) are two by-construction versions of the same idea. All three converge on a shared skeleton: a pure, generic domain with calibration injected from the top, immutable structs threaded with no processes, cross-feature work kept out of the features themselves, and side effects expressed as data that an edge interprets. Where they differ is how much machinery each wraps around that skeleton, and it turns out to be a clean spectrum from lightest to heaviest: V38, then V36, then V35.

The sharpest fork is the effect vocabulary. V36 uses a small set of typed structs, built by constructors and matched in a dedicated shell:

# V36: a feature returns typed outcomes
{c, item, [Outcome.stream_delete(:cart, item)]}

# and the shell interprets them, framework-free
defp apply_outcome(%Outcome.StreamDelete{name: n, item: i}, v),
  do: update_in(v, [:streams, n], &Map.delete(&1 || %{}, i.id))

V38 uses plain tagged tuples, interpreted inside the LiveView:

# V38: the handler builds an effects list
effects = [{:stream_delete, :cart_items, removed}, {:flash, :info, "Item removed"}]

defp apply_effect(socket, {:stream_delete, col, item}), do: stream_delete(socket, col, item)
defp apply_effect(socket, {:flash, level, msg}), do: put_flash(socket, level, msg)

This is the classic Elixir trade. V36’s structs give compile-checked effects, so a typo is a compile error, and they document themselves, at the cost of an Outcome module to learn. V38’s tuples are lighter and completely idiomatic, the kind of thing most Elixir developers already write, but a mistyped tag just falls through at runtime.

V35 sits at the far, heaviest end of the same axis. Its features return {session, effects} too, but the effects are built by an Effects module and, crucially, their names come from a Contracts module rather than string literals, which single-sources the contract that V38 leaves as a bare tuple tag:

# V35: a feature's intent returns {session, effects}; names come from Contracts, not literals
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)]}

And where V36 and V38 wire cross-feature work with an explicit hand-written call in one place, V35 makes even that declarative: a feature never calls a peer, the manifest routes one feature’s typed fact to another’s reaction, and any value a feature needs from a sibling slot is resolved by the composition and passed in as an argument.

# V35: the cross-slot value arrives as a parameter, resolved above; the feature never reaches across
def pay(session, stock_levels) do
  # reads session.checkout only; stock_levels came from the composition
end

That buys the strongest guarantee of the three. The peer coupling V38 keeps clean by discipline and a linter, and V36 keeps clean by returning outcomes, V35 makes structurally impossible and checks at build time. The price is the most to learn: slots, an intents behaviour, a manifest, a Contracts module, an Effects module, and committed codegen. It is the least vernacular of the three by a wide margin.

The second difference is where interpretation lives. V36 puts it in a dedicated, framework-free shell, so its features, composition, and shell all compile and test with no Phoenix at all (its tests run in about a twentieth of a second). V38 has no separate shell. The LiveView is the shell, and apply_effect calls put_flash and stream_delete directly and even does its Ecto writes inline. More conventional, less separable.

Calibration, though, looks nearly identical in both, which is the convergence in miniature. A module attribute in one, a module in the other:

# V36
@pricing %{shipping: %{standard: %{cost: 599, free_above: 5000}}, gift_wrap: 299}
# V38 (GoodDeal.Catalog)
def shipping_methods, do: [%{method: :standard, cost: 599, free_above: 5000}]

So which reads better? For familiarity, V38 wins clearly. It is a textbook LiveView with handle_event, streams, put_flash, Ecto contexts, and an effects-tuple list. There is nothing new to learn. The cost is that its handle_event mixes the decision, the effect-building, and the framework calls, so the top layer is less pure, which is exactly its remaining super-strict R11.

V36 is slightly less vernacular but, to my eye, the more elegant design. It spends one unit of unfamiliarity, the typed-outcome vocabulary and the shell split, and buys back framework-free, compile-checked, sub-second-testable features and a composition that reads as pure wiring. A Phoenix developer has to absorb “features return outcomes, a shell interprets them” before they are fluent, but that is a small, well-motivated idea rather than apparatus for its own sake.

V35 is the one you reach for when discipline is not enough and you want the machine to make the mistake impossible. It costs the most to learn and reads the least like plain Phoenix, but it is the only one of the three where a developer physically cannot write the peer-slot read, because there is no peer slot to read and the build fails if they try. That is the right trade for a large team on a long-lived codebase, and the wrong one for a small app a single developer holds in their head.

The genuinely interesting result is how close the skeletons are across all three. V38, evolved from a plain monolith with no goal of imitating either, arrived at the same shape on its own, just written in the loosest idiom. Read the three in order and you are really watching one dial turn: V38 optimizes for “a Phoenix developer reads it instantly,” V36 spends one unit of unfamiliarity on purity and type-safety, and V35 spends several more on build-enforced guarantees. Familiarity falls and enforcement rises as you go, and every point on that line is defensible. Which one is right is a fact about your team and your codebase, not about ALA.

One caveat on fairness. V36’s cart is a compact demonstration with no database, and V35’s is a fuller zero-coupled app, while V38 is a conventional running app doing real Ecto writes and an async checkout behind a payment gateway. Some of V38’s extra noise, the persistence tuples and the async checkout, is the cost of being real, not a stylistic choice.

← all posts