The ALA Checklist: eleven rules, applied by hand

September 20, 2026 · ALA · checklist · thermometer

The thermometer post distills ALA to four rules you can keep in your head. This is the full instrument: the ALA Checklist, eleven rules that turn John Spray’s constraints into things you can actually check, some by a machine and some only by a reader. I walk all eleven against the same thermometer, one at a time, because doing the checklist by hand once shows you exactly where a linter helps and where it has to hand the judgement back to you.

If you have not read the thermometer post, read it first: this post assumes its bad and good versions, its notation, and the four-rule core.

A quick recap of the code

The good thermometer splits the computation by altitude. The domain abstractions are generic and know nothing about thermometers:

defmodule OffsetAndScale do
  defstruct [:offset, :scale]
  def apply(%__MODULE__{offset: o, scale: s}, value), do: (value + o) * s
end

defmodule LowPassFilter do
  defstruct [:strength, :last_output]
  def smooth(%__MODULE__{} = f, input) do
    output = f.last_output + (input - f.last_output) / f.strength
    {output, %{f | last_output: output}}
  end
end

The application layer is the only place that knows this is a thermometer, and it does the wiring:

defmodule Thermometer do
  alias AlaLab.Ala.DomainAbstractions.{Display, LowPassFilter, OffsetAndScale, SampleEvery}
  defstruct [:oas, :lpf, :sample, :display]

  def new(opts \\ []) do
    %__MODULE__{
      oas: %OffsetAndScale{offset: -200, scale: 0.2},
      lpf: %LowPassFilter{strength: 10, last_output: Keyword.get(opts, :lpf_initial, 400.0)},
      sample: %SampleEvery{n: 10},
      display: %Display{label: "Temperature", units: "C"}
    }
  end

  def push_reading(%__MODULE__{} = therm, reading) do
    scaled = OffsetAndScale.apply(therm.oas, reading)
    {smoothed, lpf} = LowPassFilter.smooth(therm.lpf, scaled)
    {sampled, sample} = if smoothed, do: SampleEvery.tick(therm.sample, smoothed), else: {nil, therm.sample}
    display = if sampled, do: Display.record(therm.display, sampled), else: therm.display
    %{therm | lpf: lpf, sample: sample, display: display}
  end
end

The eleven rules, applied

I score ALA compliance against these eleven. R1 through R7 and R10 through R11 are checkable, some mechanically and some only by a reader; R8 is pure judgement and marks where automation stops; R9 is half-and-half. Here is the thermometer against each.

R1, every edge drops. Thermometer (application) calls OffsetAndScale, LowPassFilter, SampleEvery, Display (domain). Application is more concrete than the generic abstractions, so every call drops. The abstractions call nothing but the standard library. No peers, no upward calls. Pass.

R2, no shared mutable state between peers. There is none. State lives in the Thermometer struct and is passed explicitly. Elixir’s immutability hands you this rule for free, which is worth knowing: in an imperative language R2 is real work, here it is a language property, so a clean R2 says less about your design than it would in Rust. Pass, with an asterisk.

R3, application literals live at the composition. The magic numbers are all in new/1, the top layer: offset: -200, scale: 0.2, the sample rate, the starting value the composition supplies to the filter. The abstractions themselves hold no literal. That is exactly where requirements should read. Pass. (This is the rule that turns on a judgement the moment a literal is less clear-cut: had a starting value been baked into an abstraction’s struct default, a reader would have to decide whether it is an application literal to hoist or one intrinsic to the abstraction to keep. Here nothing is stranded below the top, so the question does not arise.)

R4, state is threaded, not hidden. push_reading/2 takes the thermometer and returns a new one. Nothing is stashed in the process dictionary or a module attribute. The state is a visible wire. Pass.

R5, no silent contracts. A silent contract is agreement between two functions that no signature shows: a string format one side produces and another parses, a tuple shape, a shared magic key. The thermometer has none; the abstractions communicate through typed structs and return values. Pass.

R6, every abstraction names a learnable concept. OffsetAndScale, LowPassFilter, SampleEvery, Display. Each names something a reader can learn and the language does not already provide. None is a meaningless wrapper. Pass. Spray’s two verify tests confirm it: each separates two worlds (calibration from raw counts; filtering from noise), and each is almost binary (you can use LowPassFilter.smooth without reading its body). This is also the rule an automated check gets wrong most often: my linter first flagged OffsetAndScale.apply as “just wraps arithmetic,” a false positive, because the function combines two named calibration fields into a formula. A human sees that instantly.

R7, every abstraction earns its existence. Four small abstractions, none a trivial rename, and none internally decomposed into sub-parts (each is a cohesive whole readable in isolation, the R7 verify test). Spray never required a second caller, so single use is fine when the thing names a real concept. And reuse is a positive, never a smell. Pass.

This is where Spray’s “little balls of mud” idea lives. ALA governs the relationships between abstractions, not the purity of each one’s insides. The interior of an abstraction may be a little ball of mud: procedural, branchy, decomposed into private helpers. That is fine as long as the abstraction names one concept, stays bounded in size, keeps its internals private behind a small public surface, and is clean at its boundary. So the structural checks measure the graph of abstractions (modules and their public functions), not the raw call graph. A call from one private helper to another in the same module is internal decomposition; it adds no abstraction height and is never counted as a pass-through. The guardrails that keep a little ball of mud from becoming a big one are the first three: a module that stops being nameable, blows the size cap, or exposes a wide public API has leaked its mud outward, and that is the real defect, not the mess itself.

R8, the composition reads as the requirements. Read new/1 and push_reading/2 and you can state what the thermometer does and how it is calibrated. This is judgement, not shape, and it is where a reader has to actually think. Pass, in my reading.

R9, ports carry paradigm-typed data; no abstraction names its own I/O endpoints. Each domain abstraction takes a plain value and returns one; none knows where its input comes from or where its output goes (LowPassFilter does not know a thermometer feeds it). The ports are numbers, not sibling identities or domain structs. Pass.

R10, no shared entity. This rule needs features to violate, so a single-application thermometer cannot break it. It matters the moment you have several features on a page: they must share only an identity key, not a common data struct. It is the rule the LiveView variants live by, and the reason V36 gives each feature its own private struct. Not applicable here, which is itself the point.

R11, the application layer is composition only. new/1 is pure configuration. push_reading/2 is wiring, but it carries two ifs: the sampler emits a value only every tenth reading, and the display updates only when there is one. That is conditional propagation of an optional value, so the linter stamps (branches) on it, and because two of the codebase’s six functions sit in the application layer, it also notes the layer is 33% of functions. Both are the aspirational face of R11 (--super-strict), which encodes Spray’s ideal of “no if-statements at the top” literally, so it flags every branch above the leaves for a reviewer to confirm. Here the answer is that these are Maybe-binds, not business logic, so a reviewer clears them; the design passes at the default and --strict levels and scores 100. The LiveView variants relax this further, letting a handle/3 branch to sequence cross-feature follow-ups. (To make the top literally branch-free you would change the code, not the reading: have each leaf no-op on a “no sample this tick” value, or introduce a generic maybe wiring helper, pushing the conditional below the composition.)

Required, advisory, judgement

Not every rule carries the same weight, and being honest about that is half the point of the checklist.

That split is why the thermometer scores a clean 100 on the machine-checkable rules yet still leans on a reader for the ones that matter most: whether the abstractions are the right ones (R6/R7) and whether the composition reads as the requirements (R8). The four-rule core is what you internalize; these eleven are how you check it precisely; and the linter runs the subset a machine can prove so your attention goes to the rest.

Adopting it gradually, tier by tier

You do not turn all eleven rules on at once. The three enforcement tiers are the adoption path: each is a strictness level of the linter and a set of checklist rules, and — because the encoding carries the same rules — a set of notation marks to get right. Start at the floor and climb only when the tier below is quiet.

Tier 1 — Required (mix ala.lint, the default). The structural facts: R1 edges drop, R2 no shared mutable state, R4 state on a wire, R5 no silent contracts, R10 no shared entity, and layer validity once you declare a layer map. A violation here is a defect; this is the gate you put in CI first. In the encoding: get the [tag]s and @levels right so edges read as drops, confirm each $ and q, and resolve every {app-literal?} to {app-literal} or {intrinsic-literal}. A clean default run plus a completed encoding at this tier is a real, defensible floor: no gross coupling smells.

Tier 2 — Advisory (--strict). The obtainable prompts: R7 (earned existence), module size, abstraction height, pass-through helpers (~>), and the reference-level R1 signal. Reported by default, so treat them as a reading list first; when the team agrees a signal is worth holding the line on, promote it — --strict scores them all, or --enforce height (equivalently a .ala_lint.exs entry) scores one. Spray never forced reuse, so nothing here fails your build until you opt in.

Tier 3 — Aspirational (--super-strict). The purity targets: R11 (no logic at the top, the (branches) mark and the app-layer share), the public-surface check, and the shared-domain-aggregate reading of R10 (&aggregate). Real apps do not always reach these — the thermometer itself only near-passes R11 — so they are a super-strict bar you aim at on greenfield or mature code, not a CI gate. Reach for this tier last, and read each flag as a question to answer rather than a defect to fix.

Underneath all three: judgement. R3’s application-vs-intrinsic call, R6’s “is this a real concept,” R8 in full, and the shareable-layer half of R9/R10 are never scored at any tier. The tiers automate what they can so this judgement is where your attention lands. Per-check --enforce/--disable and a .ala_lint.exs file let you cherry-pick within a tier once the coarse levels feel too blunt.

← all posts