DefLayout

CIHex.pmHex Docs

Lays out a module's functions via mix format: callbacks first, public functions alphabetical, each private function just below its bottom-most caller.

Why

Where a public function sits in a file changes nothing at runtime. Ordering it by hand is a recurring decision with only an aesthetic payoff, one you then maintain forever and re-litigate in review. DefLayout removes the decision. A formatter, for one specific thing. The same pass puts every private helper directly below its bottom-most caller - the part hardly anyone argues with, and the part hand-ordering never keeps true for long.

If you think public function order carries meaning worth maintaining by hand, this isn't the tool for you. That's by design.

The layout

The rule is to use the most meaningful order a formatter can deliver and fall back to alphabetical.

  1. Callbacks (@impl) come first and keep their source order. init, handle_call, terminate is a lifecycle; that's the one place order carries information. A callback you haven't tagged with @impl sorts with the publics.
  2. Public functions follow, alphabetical by name and arity. Their position is behavior-neutral, so it sorts. A defdelegate not tagged @impl is just a public and sorts by its local name.
  3. Private functions sink just below their bottom-most caller, transitively. A helper lives directly under what uses it, even if that puts it between two callbacks.

Macros and guards have a rule of their own: they're define-before-use, so their position is load-bearing exactly when the module itself uses one. Used ones are pinned, in source order, above the functions; a public one the module provably never uses is just another public and sorts with the rest. __using__ is the classic case - defined for other modules, inert at home - which is why a Phoenix _web.ex lays out.

Nested modules don't move. When they bracket the functions - above the first or below the last - they're frozen anchors, and the functions lay out between them. When one sits among the functions instead, the outer module is skipped: moving a def across it can silently change which module a name resolves to.

Inside a nested defmodule or defimpl, the body is just a module body and gets the full layout of its own. That makes defimpl blocks unremarkable: implementations tagged @impl keep their source order, untagged ones sort with the publics, privates sink. (defprotocol bodies are signatures, not implementations; those stay untouched.)

Here's the sharpest case, the reorder a skeptic points at first:

# before (hand-ordered)
def start_link # the entry point, conventionally first
@impl GenServer
def init
@impl GenServer
def handle_call({:get, key}, _from, state) do
{:reply, fetch(state, key), state}
end
def take
def get
defp fetch
# after
@impl GenServer # callbacks first, in source order
def init # (alphabetical would flip these two)
@impl GenServer # the attribute rides along
def handle_call({:get, key}, _from, state) do
{:reply, fetch(state, key), state}
end
defp fetch # private, sinks under its caller
def get # ┐ publics,
def start_link # │ alphabetical
def take # ┘

Yes, start_link lands mid-pack, wherever the alphabet drops it. That's the honest cost of the rule, shown up front. Once you know publics are alphabetical you stop scanning for start_link; you jump to it. (For why start_link sorts but init doesn't, see the FAQ.)

Public alphabetization is the half people argue about. The other half is harder to argue with: each private function directly under its bottom-most caller means you read a function and its helpers are right there, in call order, instead of scattered across the file or piled at the bottom. This half pays for itself on any module with helpers.

The output is a deterministic, idempotent fixed point, not a canonical form: callbacks keep their source order, and a private whose caller DefLayout can't see (it only sees local calls, matched by name and arity) drops to the bottom in source order. What's guaranteed is that formatting twice gives you the same file.

Why this doesn't exist already

Styler and Quokka reorder the directive block (@moduledoc, use, import, alias, require) and leave the functions below in source order. They work by rewriting the AST, and Elixir's AST doesn't hold comments: they ride in a separate list, anchored by line number. Re-homing them survives a directive shuffle at the top of a module; move whole functions around and comments land on the wrong code.

DefLayout orders by AST but moves source text by line span, so comments and attached @doc/@spec/@impl/attr/slot ride along with their function. That's the whole trick, and the reason def reordering can exist at all. Quokka orders your directives; DefLayout orders the rest. They compose (this repo runs both).

Credo's StrictModuleLayout warns rather than rewrites, and the layout it can check for is a different one: contiguous tiers, all publics then all privates, where DefLayout deliberately interleaves. The two coexist as long as you don't add function parts to its :order (the default leaves them out).

Tested on real code

Fourteen codebases: Phoenix and LiveView apps, macro-heavy DSLs (Ash, Absinthe), libraries from Plug to Nx, and my own production code - 5,168 source files. Every file came out reorder-only against a freshly formatted baseline (the diff is positions, never content), def-for-def intact, and stable under a second pass. Ten of those codebases also ran end to end through the real mix format: 1,459 files came out reordered, and every one still compiled. Same results on Elixir 1.19 and 1.20; CI runs the suite on four Elixir/OTP pairs, 1.16/OTP 26 through 1.20/OTP 29.

The sweep is scripts/standalone.exs - point it at any codebase (mix run scripts/standalone.exs PATH) and check the claims yourself.

Setup

Requires Elixir 1.16+.

Add def_layout to your dependencies:

# mix.exs
defp deps do
[
{:def_layout, "~> 0.1.0", only: [:dev, :test], runtime: false}
]
end

Then add it to your .formatter.exs plugins:

# .formatter.exs
[
plugins: [DefLayout],
# ...
]

mix format now lays out your modules. If you also run Quokka or Styler, list DefLayout first so the other plugin formats last and its conventions win the final output.

On an existing codebase, run the first pass on a branch and read the diff: if the code was already formatted, the pass only moves lines, it never edits them, so the diff is easy to judge. On code the formatter has never touched, the same pass also settles the usual formatter churn - that part isn't the reordering. If a module you expected to change didn't, mix def_layout.skipped lists what was left alone and why.

Using Styler? Pin line_length

DefLayout formats through the standard formatter, which wraps at 98 columns by default; Styler defaults to 122. If your .formatter.exs pins neither, the two disagree and your first run drowns in line-wrapping churn that has nothing to do with reordering. Pin a width and both respect it:

# .formatter.exs
[
plugins: [DefLayout, Styler],
line_length: 122
]

What gets skipped

DefLayout reorders a module only when it can do so safely. A module with anything it can't confidently classify next to a moving function - an unrecognized construct above the first def, expressions interleaved among the functions, a nested module among them - is skipped. In practice that includes most DSL-heavy modules (an Ecto schema, a plug pipeline). Skips are per module body, not per file: sibling modules in the same file still get the full layout, and a skipped outer doesn't stop its nested modules' bodies from getting theirs. A skipped module still gets formatted, just not laid out, so the worst case is exactly what mix format already does. The skip is silent: if a module you expected to change didn't, it contains something DefLayout won't move things across. Run mix def_layout.skipped to list which modules are being skipped and why.

One macro placement triggers the skip: the module uses one of its own macros or guards, but the definition sits below the first def (think a defmacrop next to the queries it powers). That adjacency is usually deliberate, and DefLayout won't guess. Extracting macros to a dedicated module - the pattern the defguard docs themselves model - restores the full layout on both sides: the consumer's compile-time dependencies become header directives, and the extracted macros are inert at home, so they sort. The cost is that private macros must go public to cross the module boundary.

The fine print, for macro-heavy modules: when DefLayout can't tell whether a macro is used (a name that only appears inside a quote, a variable sharing the name), it pins the macro - the macro stays above the functions, as if used, and the module still lays out. The cost of that caution is a macro that needlessly stays pinned, never a broken build. A module that calls one of its own defmacros heightens the caution: the expansion runs during the module's compile and produces code the formatter never sees - code that could invoke any macro in the module - so every macro stays pinned and none sorts. And when the expanding macro's own expansion-time code - its body outside quote, an unquote argument - calls one of the module's functions, the module is skipped: that function has to stay above the expansion site, a constraint placement can't honor.

One limit DefLayout can't see past is use. It can register an @on_definition hook invisibly (decorator-style libraries do). That hook fires per def in definition order and can read per-def attributes, so a reorder changes what it observes. A module declaring @on_definition directly is skipped; one acquiring it through use looks ordinary.

FAQ

start_link should be first. It's the entry point.

start_link isn't a callback. It's not in GenServer's @callbacks; the generated child_spec references it by name (start: {__MODULE__, :start_link, [init_arg]}), so its position in the file is behavior-neutral. "First" is reading habit, not a contract.

That's the principle behind the whole layout: if the framework formalized it (a real @impl callback), it keeps its order; if it's convention (invoked by name, placement irrelevant), it sorts.

It puts application before project in mix.exs.

Same thing. Mix.Project isn't a behaviour; Mix calls project/0 and application/0 reflectively by name. Convention, so they sort. This repo's own mix.exs is laid out that way.

Why alphabetical and not most-important-first?

Because "important" is subjective and not machine-derivable; a deterministic tool can't honor it without growing a config surface that defeats the point. Alphabetical is the one public order that's stable, zero-config, and findable.

Can I get a setting to preserve my public order?

No. A preserve-order toggle only serves people who reject the premise, and using a tool while fighting its one opinion isn't worth the config surface. The opinion is the product.

Have you actually run this on your own code?

I was skeptical of alphabetical publics myself. I expected to hate seeing start_link sort away from the top of a GenServer, and I came in willing to add a config knob to preserve public order. Then I ran it across my own production codebase: the private-helper layout was an immediate win, and the public reordering bothered me far less than I expected. The one thing that nagged, start_link not on top, turned out to be a convention I'd been hand-maintaining for no reason; the framework doesn't care where it sits. Looking at my own code talked me out of the knob.

And this repo formats itself with DefLayout.