A first look at ALA, through a thermometer

September 21, 2026 · ALA · checklist · thermometer

Abstraction Layered Architecture (ALA) is John Spray’s answer to a question most architecture advice dodges: not “how do I split this into modules,” but “when is one piece of code allowed to depend on another.” His answer is narrow and strict, and that is the point.

This post does two things. It explains the core idea on the smallest example that still shows it, a thermometer, and then it distills what ALA asks into a short checklist you can carry in your head. The full instrument is eleven rules, walked one at a time in The ALA Checklist; here I keep to the four that matter most, then run the code through the linter to see what a machine can and cannot judge, which turns out to be most of the interesting part.

The one rule underneath everything

ALA organises code into layers, ordered from concrete to abstract. Knowledge is only allowed to flow down: a function may call another function only if the callee represents a more general, more stable, more widely reusable concept than the caller. A call sideways (to a peer) or upward (to something more specific) is not allowed.

That inverts the usual reading of a call graph. In most code, the “top” is the generic utility layer everyone leans on. In ALA the top is the most concrete thing in the system, the application itself, and it gets more abstract as you go down. The application layer knows the product. The layer below knows a reusable pattern. The layer below that knows nothing about the product at all.

The payoff of the down-only rule is that a lower layer never has to know who uses it, so it can be reused, replaced, and tested in isolation. The cost is that you cannot reach for a peer when it is convenient. You either push the shared thing down into a real abstraction, or you wire the two pieces together from above, in the layer whose job is wiring.

Spray’s constraints, in brief

The down-only rule sits on top of a few specific commitments Spray is strict about. They are worth naming, because most of the checklist later is just checking that you kept them.

Two kinds of dependency, only one allowed. Spray distinguishes a knowledge dependency, where A uses B because B is a more abstract, more stable concept A can rely on, from a communication dependency, where A calls B to move data or events between them as peers. Knowledge dependencies are the whole point; they are how a concrete thing leans on a general one. Communication dependencies are the thing to eliminate. Almost every coupling problem is a communication dependency in disguise, including the sneaky ones that are field accesses rather than calls.

Zero coupling between peers. Two abstractions at the same altitude must not know about each other. Not by call, not by shared mutable state, not by a silent agreement on a message format. If two peers need to interact, a layer above them wires them together; they never reach across.

Layers replace containment. In ALA the unit of structure is the abstraction and its layer, not a module tree or a package hierarchy. A depended-on abstraction has to stay public so it can be reused, so ALA does not hide it inside the thing that uses it. This is why “which functions live in which module” is not itself an ALA rule; altitude is.

Ports are typed by paradigm, not by domain. An abstraction exposes an interface shaped by its kind (a data transform, a filter, a store), not by the product it happens to serve. That is what lets the same abstraction wire into a second consumer unchanged. A port that mentions your domain is a port that cannot be reused.

The diagram is the source of truth. You design by drawing the wiring diagram: boxes are abstractions, lines are the knowledge dependencies, and the top of the diagram reads as the application’s requirements. The code is a faithful transcription of that diagram. When the two disagree, the diagram wins, and good ALA code makes the diagram recoverable by reading the top layer.

Wiring is not an abstraction. The glue that connects abstractions (Spray’s “func1” point) is composition, not a new box on the diagram. If a function only moves a value from one abstraction to another and hides no decision, it should be inline wiring, not a named helper. This is the constraint that keeps a codebase from dissolving into a soup of trivial pass-through functions.

An abstraction may be a little ball of mud inside. ALA is strict about the relationships between abstractions and permissive about the inside of each. Spray’s whole point is that ALA cures the big ball of mud without demanding that every box be pristine within. An abstraction’s interior can be procedural and messy, decomposed into private helpers, as long as it names one concept, stays small, keeps its internals private, and is clean at its boundary. The rules govern the wiring diagram, not the guts of the boxes, which is why the linter measures altitude and helper proliferation between abstractions and leaves a module’s private internals alone.

Everything below is these commitments, made checkable.

The smallest ALA app: a switch and a light

Before the thermometer, here is the smallest application that still shows the shape: a switch wired to a light. Two generic domain abstractions and one application that connects them.

The abstractions know nothing about each other or about any product:

defmodule Switch do
  defstruct on: false
  def toggle(%__MODULE__{on: on} = s), do: %{s | on: not on}
  def set(%__MODULE__{} = s, on?) when is_boolean(on?), do: %{s | on: on?}
end

defmodule Light do
  defstruct on: false
  def set(%__MODULE__{} = light, on?) when is_boolean(on?), do: %{light | on: on?}
end

The application is the wiring, and it is the only place that knows a switch drives a light:

defmodule SwitchLight do
  defstruct switch: %Switch{}, light: %Light{}

  def toggle(%__MODULE__{} = app) do
    switch = Switch.toggle(app.switch)
    light = Light.set(app.light, switch.on)
    %{app | switch: switch, light: light}
  end
end

Switch and Light are []: nothing in them mentions the other or the product, so either could be reused in any circuit. SwitchLight is the application, and it carries the one product-specific fact (“a switch drives a light”) in a single function that reads the switch and pushes the result into the light. The switch never calls the light. That is the whole idea. Everything below just adds more abstractions to the same shape.

The example

A thermometer reads an analog-to-digital converter, offsets and scales the raw value, smooths it with a low-pass filter, samples periodically, and formats the string. Spray writes the bad version first, in C, as a chain of procedures that call each other and share global state. Here it is ported faithfully to Elixir (its application literals are Spray’s; the good version below uses my own, which is why the numbers differ):

defmodule BadThermometer do
  def smooth_temperature(t) do
    filtered = Process.get(:filtered, 0.0) * 9 / 10 + t / 10   # hidden shared state
    Process.put(:filtered, filtered)
    filtered
  end

  def display_temperature(t), do: IO.puts("Temperature: #{Float.round(t, 1)} C")

  def resample_temperature(t) do
    counter = Process.get(:counter, 0) + 1
    if counter >= 15 do
      Process.put(:counter, 0)
      display_temperature(t)                                   # peer call
    else
      Process.put(:counter, counter)
    end
  end

  def process_temperatures(adcs) do
    Enum.each(adcs, fn adc ->
      t = (adc + 4) * 8.3                                      # baked application literal, inline
      t = smooth_temperature(t)                                # peer call
      resample_temperature(t)                                  # peer call
    end)
  end
end

It works, and it is instructive precisely because of how it fails the constraints above. process_temperatures calls smooth_temperature and resample_temperature; resample_temperature calls display_temperature. Those are peer calls, three deep, communication dependencies with no drop in abstraction. The application literals (+ 4, * 8.3, the count of 15) are baked inline, so the requirements are scattered through the procedure. And the filter and counter are held in the process dictionary, a hidden mutable channel shared between calls.

That last point is worth dwelling on. Spray wrote the bad version in C because C makes global mutable state effortless and unmarked, which is exactly how the coupling “crept in.” Elixir fights you: there is no module-level mutable variable, so to reproduce it you have to reach for Process.put/get, a well-known smell. The language makes the second coupling channel possible but conspicuous. That is a language property doing some of ALA’s work for you, and it is worth knowing which parts you get for free.

The ALA version keeps the same computation but splits it 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

OffsetAndScale is not a thermometer part. It is arithmetic anyone can reuse. Same for the filter, the sampler, and the display. None of them mention temperature, Celsius, or a sensor.

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

Read new/1 and you read the requirements. The application literals that make this this thermometer (offset: -200, scale: 0.2, the ten-reading sample rate, the “Temperature” label) all sit in one place, at the top. push_reading/2 is wiring: it threads the reading down through the abstractions and threads the updated state back up (with two nil-guards I will come back to). There is no hidden state, no process, no global.

Writing it down: the notation

Before checking the rules, it helps to encode the design in a small notation. The point of the notation is to turn ALA compliance into shapes you can see rather than judgements you have to argue. It has a handful of marks, and everything else is indentation:

MarkWhat it means
name [tag]a function, labelled by its name (or a bare fN when the name is noise), carrying its tag
[thermo]a filled tag: the function knows a product-specific requirement (here, “thermometer”); its text would change if the product changed
[]the empty tag: fully generic, nothing in it would change if the product changed. A reusable abstraction. Most of the code should be []
@name-Lidxthe ALA level: the named tier a function is assigned to, with its depth (top is L0). Optional and tool-assigned from a layer map. The [tag] says what a function knows; this says which tier it sits in, and the two should agree. Small examples like this one omit it
pNa data value flowing between functions (a wire). p2 <- Filter(p1)
*pNshared/by-reference data: caller and callee hold the same mutable value. A peer-to-peer channel, so a candidate R2 smell
{app-literal?}an application-literal candidate lives in this function (a literal that may need hoisting). Value opaque on purpose; the linter has its file:line. Resolve it to one of the next two
{app-literal}asserted application literal. Belongs at the composition; on a lower-layer function that is an R3 hoist, at the top it is correct
{intrinsic-literal}asserted intrinsic to the implementation (an identity, a physical or mathematical constant). Correctly local, never hoisted
$the function keeps hidden state between calls. Write it on the tag: [x]$ (R4)
qNa silent contract: a shape two functions must agree on that no signature shows (R5)
(p) ->an anonymous wiring lambda. No name, because it is composition, not an abstraction (Spray’s “func1” point)
indentation“uses”: a dependency edge from the line above
&entity / &aggregateon a module line, tool-stamped: it shares a domain entity with a peer (R10), or with too many peers (the R10 aggregate)
~>on a function line, tool-stamped: a pass-through, one caller and one cross-module callee, renaming a call without hiding a decision (R7-adjacent)
(branches)on a function line, tool-stamped: an application-layer function that itself branches. The top tier should compose, not decide (R11)
(private)tool-stamped: the function is private, so it does not count toward a module’s public surface and cannot be a pass-through

The last four are different from the marks above them: those need a human judgement (is this a product tag? a genuine hidden channel? an application literal?), but these are facts the linter can decide from the source, so mix ala.encode stamps them into the draft for you. That is what lets a completed encoding, fed back to mix ala.lint.encoding, reproduce the same findings the linter gives on the source: you fill in the tags and resolve the literals, and the stamped marks carry the checks a plain notation used to miss (R10, R11, pass-throughs, public surface).

Two things this notation deliberately is not. It is not code: it shows how abstractions relate, so it drops the arithmetic, the struct syntax, and the control flow, none of which is an ALA signal. And it does not carry the values of literals. It flags only that an application-literal candidate exists in a function ({app-literal?}) and, once a human or an LLM has judged it, whether that literal is an application literal ({app-literal}) or intrinsic to the abstraction ({intrinsic-literal}). If you want the value, the linter hands you the exact file:line. The reason for the opacity is discipline: the encoding records the judgement R3 turns on (an application literal to hoist, or an intrinsic one to keep), not the constant itself, so the encoding never drifts into being a second copy of the code.

A word on identity, because short names and fN numbers do not survive a large codebase. On a page, OffsetAndScale or a bare f2 reads fine and is unique within the one example. In a real system with several Carts, it is not, and hand-tracking numbers so they stay consistent is exactly the kind of clerical work you do not want. The escape is to let the identity be the language’s own: the fully-qualified Module.function/arity, which is unique across the whole codebase by construction, so nothing needs tracking. That is what the tool’s own encoder writes (Shop.Cart.add_item/2, not Cart), which keeps a machine-generated encoding unambiguous and grep-able. The short forms in this post are a convenience for a small example, not the form you would use at scale. Wires (pN) are gentler: a wire lives inside one function’s block, so its number only has to be unique there, the same way a local variable is. When you hand-encode, using the code’s actual variable names for wires reads better than numbering them and keeps that locality.

The only judgement is the tag, and there is one question that assigns it: if this were a different product, would this function’s text change? If yes, tag it with the noun that would change. If no, it is [], generic. For the thermometer, only the application knows it is a thermometer; everything below is [].

The full encoding, both versions

The notation earns its keep when you encode the bad and good versions and compare the shapes.

The bad version

process_temperatures  [thermo]
  foreach reading, (p) ->
    p1 <- calibrate  [thermo]  {app-literal}      -- ⚠ R3: application literal below the top, hoist it (value in source)
    p2 <- smooth     [thermo]$ (p1) {app-literal}  -- ⚠ R1 peer, ⚠ R4 hidden :filtered, ⚠ R3
          resample   [thermo]$ (p2) {app-literal}  -- ⚠ R1 peer, ⚠ R4 hidden :counter, ⚠ R3
            display  [thermo]  (p2)          -- ⚠ R1 peer (a three-deep chain)

You can read the violations straight off the shape, without arguing about taste:

shaperulewhat it is in the code
every edge is [thermo] → [thermo]R1nothing was made generic; the pieces only collaborate to “be a thermometer,” so you must read all of it to understand any of it
$ on tagged smooth, resampleR4per-reading state hidden in the process dictionary, coupling calls invisibly through time
the process → resample → display chainR1the story of one reading spans a three-deep chain of thermometer-knowing functions
{app-literal} on deep, non-composition functionsR3application literals a reviewer confirmed as arbitrary, scattered through the procedure instead of hoisted to the top

Strip the tags and this tree does not look so different from the good one below. The tag column plus the literal rule is exactly what makes the difference mechanical rather than aesthetic. The encoding needs that much semantics and no more.

The ALA version

Now the same computation, split by altitude. Two functions carry the [thermo] tag, the application’s literals and its wiring; everything they use is generic.

Thermometer.new  [thermo]  {app-literal}          -- application literals, correctly at the composition ✓ (values in source)
  OffsetAndScale  []
  LowPassFilter   []
  SampleEvery     []
  Display         []

Thermometer.push_reading  [thermo]  (branches)   -- the composition: wiring, plus two nil-guards ⚠ R11
  p1 <- OffsetAndScale.apply  []  (reading)
  p2 <- LowPassFilter.smooth  []  (p1)       -- returns value + new state; state threaded, not $  (R4 ✓)
  p3 <- SampleEvery.tick      []  (p2)       -- likewise: new counter threaded, not hidden
        Display.record        []  (p3)

Read the shapes and the compliance is visible. Every edge drops [thermo] → []: the two tagged application functions use only generic leaves, and no leaf uses another leaf. The tree is one level deep below the composition. The application literals are marked {app-literal} on new, at the top where it belongs, and you don’t see its values here on purpose. Each pN is a wire that appears only in the composition, where wiring belongs.

The most interesting mark is the one that is absent. The bad version carried $ on smooth and resample, hidden state in the process dictionary. The ALA version has no $ anywhere. LowPassFilter.smooth does not stash its last output; it returns it, and the composition catches it as the threaded state on the p2 line. The state that was hidden became an explicit wire. That is the functional-programming reading of ALA: the down-only rule plus immutability turns the stateful-leaf $ into a p, and the whole thing collapses toward a pipe. (Spray’s original C version keeps that $, because C makes the hidden channel the easy path; the good shape there tolerates $ on a generic leaf whose concept is genuinely stateful. Elixir simply removes the temptation.)

Notice there is no {app-literal?} anywhere. Every literal in this design lives in new, marked {app-literal} at the composition, and the leaves are pure [], the shape of a clean 100 with no candidate literal stranded in a lower abstraction for a reviewer to adjudicate. When the encoder does find one, it seeds {app-literal?} and hands you the file:line to judge: an application literal to hoist to the composition, or one intrinsic to the abstraction to keep local. The value never enters the encoding; only the judgement does.

The one mark the tool does stamp here is (branches) on push_reading, which is worth dwelling on because it is the honest edge of the “no logic at the top” rule. push_reading has two ifs: the sampler only emits a value every tenth reading, and the display only updates when there is one. That is conditional propagation of an optional value, the same thing the functional reading writes as a maybe(...) bind in a pipe, not business logic. R11 flags it anyway, because a linter cannot tell a Maybe-bind from a real decision, so it is reported as aspirational (only --super-strict scores it) and left for a reviewer to clear. The design still scores 100/A at the default and strict levels. This is the pattern to expect from R11: it points at every branch above the leaves and asks you to confirm each is wiring; here the answer is yes, and you move on.

The ALA Checklist in four rules (the beginner’s core)

The full checklist has eleven rules, but if you remember nothing else, remember these four. They are the essence; the other seven sharpen and mechanize them.

  1. Knowledge flows down. Every dependency points at something more general and more stable than the thing depending on it. Never a peer, never something more specific. In the thermometer, the application uses LowPassFilter; LowPassFilter uses nothing but arithmetic. That is the whole structural rule, and the layers fall out of it.
  2. A part knows one thing, and not who it talks to. Each abstraction names a single concept it can be understood by (“a low-pass filter”, “an offset-and-scale”) and never names where its input comes from or its output goes. LowPassFilter does not know a thermometer feeds it. The layer above does the connecting.
  3. State travels on a wire, not underground. Whatever a part remembers between calls comes in as an argument and goes out as a return value, in the open, where the layer above can see it. Nothing is stashed in a global, a process dictionary, or a shared mutable cell that two parts quietly reach into. In the good thermometer, the filter’s memory is threaded through push_reading; in the bad one it hides in the process dictionary, and that is precisely the coupling.
  4. The top reads as the requirements. The application layer is wiring and application literals, nothing else. It holds the product-specific knowledge (the application literals, which parts connect to which) and no real logic. Reading Thermometer.new and push_reading tells you what this thing is and how it is tuned.

If a design honors those four, it is most of the way to ALA. The remaining rules, walked one at a time in The ALA Checklist, are how you check each precisely and catch the ways they quietly break. That companion post runs this same thermometer against all eleven.

This is also how to adopt the checklist without drowning in it. These four are essentially the Required tier — the structural facts a plain mix ala.lint scores and fails on. Get them (and the corresponding encoding marks: honest tags, dropping edges, no stray $/q, literals resolved) clean first. Only then reach for the Advisory tier (--strict: earned-existence, height, pass-throughs — a reading list you promote when ready) and last the Aspirational tier (--super-strict: R11’s no-logic-at-the-top, public surface, the shared-entity aggregate — purity targets, not gates). The checklist post lays out that tier-by-tier path; the point is you climb one strictness level at a time, not all at once.

What the exercise teaches

When I run the linter on this code with a layer map, it scores 100 out of 100: every edge drops, no state is shared or hidden, and every literal that makes this this thermometer sits in new/1, at the composition, where R3 wants it. The abstraction stack is shallow, the application over its generic parts, and the check takes milliseconds.

The clean number is not the interesting part, though. Notice, in the full checklist walk, how much of the pass came from a reader, not the tool. R6 needed a human to overrule a false positive: the linter first flagged OffsetAndScale.apply as “just wraps arithmetic,” which it plainly does not. R7 turned entirely on “does this name a concept,” which no static check decides. R8 is judgement by definition. And R3, clean here, hangs on a judgement the moment a literal is less clear-cut. Is a value an application literal to hoist to the composition, or intrinsic to the abstraction and correctly kept local? The linter flags the candidate; only a reader settles it.

That split is the honest shape of ALA tooling. A linter is a fast gross filter: it can prove edges drop given a declared layering, catch shared mutable state, and flag scattered application literals. It cannot tell you the layering is the right one, or that an abstraction is worth its name. The checklist is the full instrument; ala_lint is the part of it you can run in CI. The next posts look at two designs that push this further in a real LiveView app, where the interesting couplings hide in places the compiler never sees.

If you want a coding agent to apply the checklist for you, the ala_lint_elixir repo ships an AGENTS.md guide under examples/ (drop it in as AGENTS.md for Codex or CLAUDE.md for Claude Code). It restates the rules as do/avoid directives an LLM can follow while writing, and a self-review it runs when the linter is not installed, so the human instrument travels even where the tool does not.

← all posts