Control flow

You open the billing file your agent wrote and you cannot find the decision. Here is the same decision on one screen, a menu you read one line at a time, and nobody rewrites the business logic. Hover or tap every branch.

The menu · 0 of 6 branches read

One decision, one line per choice.

Control flow is the one place where AED's usual advice runs out. while, rescue, case, spawn are mechanism keywords: they say how execution moves, never why. No re-spelling makes while @i < @lines.size state an intent. So for control flow the conventions shift their answer: when the syntax can't read like a statement, the names around it must say what the syntax is doing.

billing · the decision
1def perform
2 # Guards first, story second: nothing below runs on an invoice we do not charge.
5
6 charge_the_late_fee_on_every_taxable_line_item
7 tell_the_customer
8end
9
10# The menu. One line per plan, an explicit fallback for the unlisted world.
11private def late_fee_rate_for_the_plan : Float64
12 plan_name = currently_active_subscription.try(&.plan_name) || "standard"
13 case plan_name
18 end
19end

Hover or tap every branch. Six rows, six mechanisms: open one and that line becomes the mechanism it replaced, with the reason under it. The last row is the else, and it replaced nothing, because in the original there was no line for the unlisted world at all.

In chapter 3 the late fee was a single property, late_fee_rate : Float64 = 0.05. It is a table now, because the company grew a fee table. The menu is where a table belongs.

Plain

Loops and chains that state their finish line.

The other two mechanisms in the file were a loop and a chain, and both had the same defect: no finish line you could point at. Before, the loop counted and the chain guessed. After, both say out loud what they are walking and what they are holding.

billing · the loop and the chain
late_fee_service.cr — before
1plan = @invoice.customer.subscriptions.select(&.active?).map(&.plan_name).first?.try(&.downcase) || ""
2
3i = 0
4until i >= @invoice.line_items.size
5 item = @invoice.line_items[i]
6 if item.taxable?
7 @invoice.charge(item.amount * rate, kind: :late_fee)
8 end
9 i += 1
10end
apply_late_fee.cr — after
1# The finish line is the list of taxable line items, and it is said out loud.
2private def charge_the_late_fee_on_every_taxable_line_item
3 list_of_taxable_line_items = invoice.line_items.select(&.taxable?)
4 late_fee_rate = late_fee_rate_for_the_plan
5
6 list_of_taxable_line_items.each do |line_item|
7 invoice.charge(line_item.amount * late_fee_rate, kind: :late_fee)
8 end
9end
10
11private def currently_active_subscription : Subscription?
12 invoice.customer.list_of_all_active_subscriptions.first?
13end

Two things changed and both are naming, not logic. The finish line became a thing you can point at: the loop walks list_of_taxable_line_items, so termination is a fact about a list rather than arithmetic you verify by reading i += 1 and believing it. And the four-link chain became a waypoint with a name. The waypoint is named for what the data is at that point (list_of_taxable_line_items, currently_active_subscription), never for the operation that produced it. filtered, mapped, result2 tell the next reader what you typed; they never tell them what they are holding.

One .try survives, in currently_active_subscription.try(&.plan_name) || "standard". One is the cap. The old line had a .try feeding a || feeding a .downcase, which is three nil-branches hidden inside a noun.

The three readers

The developerwrote one menu, one guard block, and one walk over a named list. No new behavior, no new tests, nothing renamed that the compiler did not confirm.
The ownerreads the late-fee table as the company's fee table, and reads the guard block as the "refuses when" list: it refuses when the invoice has no billable customer, and it refuses inside the grace period.
The agentreads both, and writes the next branch the same way. When the company adds a plan, the change is one row in the menu, and the row is in the same window as the rest of the decision.

The rules this chapter settles

Nine rules, and every one of them is a name around a mechanism keyword.

  • CF-1 · "Case is a menu, not a novel."Use case … when when branching a single named value across three or more outcomes. Two outcomes is an if/else: a menu of two is just a choice. Each when body is at most about three lines or a single named method call; if a branch needs a paragraph, extract it to a method whose name is the branch's intent. The case subject must be a named local or a plain method call, never an inline expression chain. Always write an explicit else that states the fallback: false, a raise, or a named default.
  • CF-2 · "Every loop names its finish line and shows its step."Three blessed loop shapes and nothing else: the collection walk, the cursor loop over an explicit cursor that visibly advances, and the forever loop, which may only live inside a method whose name says it loops. If a loop body exceeds about eight lines, extract the body to a method named for one iteration's intent, so the loop line reads "while there are lines, consume the next block." until is banned: until done? forces the reader to negate in their head; write while more?.
    until i >= sizelist_of_taxable_line_items.each
  • CF-3 · "Guards first, story second."All precondition guards sit in a contiguous block at the top of the method, before the first line of the happy path. One guard per line, one idea per guard.
  • CF-4 · "unless takes one idea."unless may wrap one positive condition: a single predicate call, a single comparison, or a single presence check. Never unless with &&, ||, or !; never unless … else.
    unless a && b && cthe_invoice_is_chargeable?
  • CF-5 · "Ternary is a value with two spellings, and it gets a name."Both arms simple values, no nesting, no side effects, and the result immediately named. Choosing between two actions is always an if/else.
    a ? 0.02 : (b ? 0.035 : 0.05)case plan_name
  • CF-6 · "Two links spoken, three links named."Three or more links, or any multi-line block mid-chain, gets named waypoints: intermediate variables named for what the data is at that point (active_users, member_emails), not for the operation (filtered, mapped, result2). One .try maximum per expression.
    .select.map.first?.trycurrently_active_subscription
  • CF-7 · "Raise nouns, rescue by name."Raising is always a typed error from the domain vocabulary; rescuing names the narrowest type that states what you forgive. A bare rescue with no binding and no comment is banned. A broad rescue ex is permitted only at boundaries, and must bind, log, and carry a why comment.
    rescuerescue ex : Mail::DeliveryError
  • CF-8 · "Two operators is a sentence; three is a method."A condition may contain at most two boolean operators, and never a mix of && and || without extraction. The extracted name must be the positive form of the question, never not_invalid?.
  • CF-10 · "The suffix is a promise."def foo? returns Bool, full stop, with one blessed exception: the stdlib maybe-lookup convention, where ? means nil instead of raising. def foo! means it raises where foo returns nil, or that it mutates the receiver, and its doc comment states which danger it warns about.

Chapter 06 of the conventions also fences concurrency (CF-9, every fiber gets a job title) and macros (CF-11, compile-time branches speak for both worlds); the billing example does not exercise either, so they are left to the source repository. And one honest line: chapter 06 ships as a release candidate. The rules hold; the exact thresholds (the three-branch floor, the eight-line loop body, the one-.try cap) are still under review, and the repository lists the open questions.

The gold frame is the context window; the tiles it slides over are tokens. One window holds the whole decision.

Why it matters to the agent

A case on a named local puts the whole decision inside one token window.

A model reads code through a sliding token window. A case on a named local puts the whole decision inside one window: the subject, every branch, and the fallback are adjacent text. A nested ternary puts the decision somewhere else entirely: in operator precedence, which lives in the language specification and not in the file. The agent can only recover it by parsing, and a small model parsing is a small model not reasoning.

The same is true of the guard block. return unless the_invoice_is_chargeable? carries its meaning in the window because the name is in the window. A three-term unless carries its meaning in De Morgan's laws.

Then the payoff the conventions care about most: these names are the source of documentation headings. Guard blocks become "Refuses when…", one bullet per guard line, with the guard's why comment as the explanation. Case menus become decision tables: the subject is the title, the when labels are the rows, and the mandatory else is the documented fallback row. Typed errors become "What can go wrong": the error subclass tree is the failure catalog. ? predicates become a glossary of business rules. What does not surface is deliberate: ternary values, waypoint locals, and cursor predicates stay local, because the selection rule is mechanical: a control-flow name surfaces to docs if and only if it is a method name. Which means the linter's tokens and the doc generator's tokens are the same tokens.

Reading with an agent? Hand it the machine edition of this chapter. The source is chapter 06 of the conventions repository.

The whole file

Before and after: the file as it was, and the file as it reads now. Switch the tab.

billing · the whole file
1class LateFeeService
2 def initialize(invoice, days_late)
3 @invoice = invoice
4 @days_late = days_late
5 end
6
7 def process
8 unless @invoice.customer && @invoice.customer.subscriptions.size > 0 &&
9 @days_late >= 30
10 return
11 end
12
13 plan = @invoice.customer.subscriptions.select(&.active?).map(&.plan_name).first?.try(&.downcase) || ""
14 rate = plan == "enterprise" ? 0.02 : (plan == "pro" ? 0.035 : 0.05)
15
16 i = 0
17 until i >= @invoice.line_items.size
18 item = @invoice.line_items[i]
19 if item.taxable?
20 @invoice.charge(item.amount * rate, kind: :late_fee)
21 end
22 i += 1
23 end
24
25 begin
26 CustomerMailer.late_fee(@invoice).deliver
27 rescue
28 nil
29 end
30 end
31end

The file is longer than the window, so the pane scrolls. Billing::UnknownPlanError is new domain vocabulary, minted under the module's base error, and it is the line the menu's else row points at.