Methods are the unit of reasoning. Every rule here keeps that unit small, named, and honest: one job, one level of abstraction, asserted at both ends, with the happy path flush left. The 25-line cap is the floor of the discipline; aim far below it.
# frozen_string_literal: true
# typed: strict
sig { params(order: Order, now: Time).returns(Receipt) }
def settle_order(order, now:)
Assert.that(order.line_items.any?, "order #{order.id} has no line items")
Assert.that(order.status == :open, "order #{order.id} is not open")
subtotal = sum_line_items(order.line_items)
tax = tax_for(subtotal, jurisdiction: order.customer.jurisdiction)
total = Money.parse(subtotal.cents + tax.cents)
Assert.that(total >= subtotal, "total cannot fall below subtotal")
Assert.that(total >= tax, "total cannot fall below tax")
Receipt.new(order_id: order.id, subtotal:, tax:, total:, settled_at: now)
end
sig { params(items: T::Array[LineItem]).returns(Money) }
def sum_line_items(items)
cents = items.reduce(0) do |acc, item|
Assert.that(item.unit_price.cents >= 0, "negative unit price on sku #{item.sku}")
acc + item.unit_price.cents * item.quantity
end
Money.parse(cents)
end
sig { params(subtotal: Money, jurisdiction: Symbol).returns(Money) }
def tax_for(subtotal, jurisdiction:)
rate = TAX_RATES.fetch(jurisdiction) { Assert.fail("no tax rate for #{jurisdiction}") }
Money.parse((subtotal.cents * rate).ceil)
endMoney is generated in exactly one place — Money.parse, the parse-constructor that validates the integer-cents value before returning an immutable value object. Nothing else creates a Money. The public method sits above the two helpers it calls, so the file reads top-down (5.4). Guard clauses assert the preconditions and leave the happy path flush left (5.3). The keyword argument now: keeps the call site legible without a positional slot that callers can transpose (5.4 rule). A postcondition pair verifies the sum from both of its parts before return (5.12). Each method holds one level of abstraction — settle_order orchestrates named steps and touches no arithmetic itself (5.2). The helpers reach a Money only through Money.parse, the single parse-constructor that validates (per chapter 03).
Reasoning, step by step:
- A method you can hold in your head fits on half a screen. Past ~15 lines it stops being one idea and starts being a list of them.
- The cap is 25 lines, cop-enforced (blank lines counted, comments excluded). It is set deliberately tight — the Ruby-scaled sibling of Go's 70 and Kotlin's 60. Hitting it is already a smell; brushing against it is a prompt to extract.
- The test is linguistic: if the honest one-sentence summary of a method needs an "and," it is two methods wearing one name. Split on the "and."
- Every callable counts — public methods, private helpers, module methods, and block bodies alike. A 25-line block passed to
eachis the same defect as a 25-line public method.
Worked example: a process_checkout that parses the payload, and validates inventory, and charges the card, and persists the order is four methods. Extract parse_checkout_payload, assert_inventory_available, charge_payment, persist_order; the orchestrator drops to a handful of readable lines.
Enforcement: RuboCop Metrics/MethodLength: Max: 25 (CountAsOne for heredocs; blank lines counted).
Reasoning, step by step:
- A method either orchestrates named steps or performs primitive work. Mixing the two forces the reader to shift altitude mid-body — re-deriving what a high-level call means while parsing arithmetic or string slicing.
- Pick the altitude from the name.
settle_ordernames a workflow, so its body is calls to named helpers.sum_line_itemsnames a computation, so its body is a loop over values. - When an orchestrator sprouts arithmetic, a regex, or a conditional that belongs in a helper, that fragment wants a name. Extracting it makes the helper's name document the step you removed, replacing a comment.
- Depth is a symptom: a body mixing abstraction levels grows extra nesting as the low-level logic branches. If
max-depthfires, first ask whether the culprit deserves its own method.
Worked example: in the exemplar, settle_order never multiplies or calls .ceil — it delegates to sum_line_items and tax_for. Inlining either would drop two levels of abstraction into one body and the workflow would vanish into arithmetic.
Enforcement: review; surfaced by 5.1's line cap and RuboCop Metrics/BlockNesting (mixing levels nests deeper).
Reasoning, step by step:
- Handle the exceptional at the top and return (or raise) early. What survives the guards is the main case, and it reads as a straight column down the left margin.
- Early return beats nesting: every
ifyou invert into a guard is an indentation level the happy path never pays for. Anelsebranch is usually a guard you failed to take. - Deep nesting hides the spine of a method inside pyramids of
endkeywords. Flat code has one obvious path. Ruby'sreturnat the top of a method is idiomatic and clear; use it without hesitation.
Worked example:
# frozen_string_literal: true
# typed: strict
sig { params(sku: Sku, cart: Cart).returns(Money) }
def price_for(sku, cart)
item = cart.item_for(sku)
return Money.parse(0) if item.nil? || item.quantity.zero? # guard
Money.parse(item.unit_price.cents * item.quantity) # happy path, flush left
endEnforcement: RuboCop Metrics/BlockNesting: Max: 3; aim for ≤ 2. Reviewer rejects inversion of guard into deep else.
Reasoning, step by step:
- A call site must read without opening the signature.
ship(order, true, false, 3)is a row of unlabeled values;ship(order, expedited: true, insured: false, attempts: 3)says what each means. - Keyword arguments are named at the call site and order-free. Adding a new keyword argument is backward-compatible; inserting a positional breaks every caller.
- Never give a positional parameter a default value — the default is invisible at the call site and becomes a silent time bomb when the signature changes. A keyword with a default is visible in the call because callers omit it deliberately.
- An options hash (
def foo(options = {})) is the defeated form: it loses type coverage, destroys IDE navigation, and lets unknown keys pass silently. Use keyword arguments.
Worked example: settle_order(order, now:) vs settle_order(order, Time.now) — the keyword call says what now is; the positional call is a riddle unless you memorize the signature.
Enforcement: RuboCop Style/OptionHash (bans options hashes); Style/KeywordParameters; reviewer rejects any positional default.
Reasoning, step by step:
- A method with five or more parameters is usually doing more than one thing. Extract a value object or split the responsibility before reaching the fourth parameter.
- A configuration boolean can live as a keyword argument:
search(query, fuzzy: true). But a behaviour-forking boolean — one whose truth value selects between two fundamentally different algorithms — is two methods hiding in one body. Split them. parse(input, strict: true)sounds like configuration, but if thestrictbranch runs a completely different code path, the shared name is a lie.parse_strict(input)andparse_lenient(input)each tells the truth; shared work moves to a private helper both call.- Each extracted method now obeys rules 5.1 and 5.2 on its own; the fork that prevented that is gone.
Worked example:
# Before — behaviour fork masquerading as configuration
sig { params(input: String, strict: T::Boolean).returns(Money) }
def parse_amount(input, strict:)
if strict
# entirely different validation and parsing path
else
# lenient path with fallbacks
end
end
# After — two honest names, shared private core
sig { params(input: String).returns(Money) }
def parse_amount_strict(input)
raw = parse_amount_raw(input)
Assert.that(raw[:remainder].empty?, "trailing characters: #{raw[:remainder]}")
Money.parse(raw[:cents])
end
sig { params(input: String).returns(Money) }
def parse_amount_lenient(input)
Money.parse(parse_amount_raw(input).fetch(:cents))
endEnforcement: RuboCop Metrics/ParameterLists: Max: 4; reviewer splits behaviour-forking booleans.
Reasoning, step by step:
- A conditional body inside a loop that wraps most of its content is an indentation tax on every line of the happy case. Inverting it into a
nextkeeps the meaningful work flush left. nextin a block is the guard-clause principle (5.3) applied inside an iteration: skip the degenerate cases at the top; everything below is the normal case.- The same principle applies inside
map/select/reduceblocks — guard withnextrather than wrapping the transform in anif.
Worked example:
# Before — nested conditional buries the work
order.line_items.each do |item|
if item.in_stock?
if item.quantity > 0
process_line_item(item)
end
end
end
# After — guards, then work flush left
order.line_items.each do |item|
next unless item.in_stock?
next unless item.quantity > 0
process_line_item(item)
endEnforcement: RuboCop Style/Next (enforces next over wrapped conditionals); reviewer enforces the pattern in block bodies.
Reasoning, step by step:
def price; @price; endon one line suppresses the visual boundary between methods and makes the file harder to scan. One body per line is the rule; theendgoes on its own line.- Ruby 3's endless method (
def total = subtotal + tax) is acceptable for a trivial pure expression with no guard clause, no local variable, and no side effect — the kind you would write as a read-onlyattr_readerif the value were stored. Anything beyond that goes on its own lines. - The bar for "trivial" is high: if the body contains a method call with arguments, a conditional, or any local binding, use the traditional
def/endform so the body can be read andsig-annotated properly.
Worked example:
# Acceptable endless — pure delegation, trivially readable
sig { returns(Integer) }
def cents = amount.cents
# Unacceptable endless — has logic; use traditional form
# def total = line_items.sum(&:subtotal) + tax ← no: too much for one expression
sig { returns(Money) }
def total
subtotal = line_items.sum(&:subtotal)
Money.parse(subtotal + tax.cents)
endEnforcement: RuboCop Style/SingleLineMethods; reviewer rejects endless methods with conditional or multi-step logic.
Reasoning, step by step:
- In Ruby the last expression in a method is its return value. Writing
return valueat the bottom of a method adds a keyword that the language already infers, and signals to the reader that something exceptional is happening — a signal that turns to noise if used everywhere. - Reserve
returnfor early exits: guards at the top of a method that escape before the main body runs. That givesreturna single, recognizable role: "stop here, the rest does not apply." - This asymmetry is load-bearing: when a reviewer sees
returnmid-method, it should mean "guard or unusual exit" — not "this author typesreturneverywhere." Consistency makes the meaning visible.
Worked example:
# Guard exit — explicit `return` is correct
sig { params(order: Order).returns(T.nilable(Discount)) }
def discount_for(order)
return nil if order.customer.loyalty_tier == :standard # guard: explicit return
return nil unless order.total >= Money.parse(10_000) # guard: explicit return
Discount.new(rate: LOYALTY_RATE) # happy path: implicit return
endEnforcement: RuboCop Style/RedundantReturn; reviewer enforces the guard-only idiom for explicit return.
Reasoning, step by step:
- Parentheses after
defsignal "this method has a contract to inspect." Their absence signals "nothing to pass." The visual distinction is a scanning aid — readers know at a glance whether to look for arguments. - This is consistent with Sorbet
sigblocks: asig { params(...).returns(...) }always accompanies adefwith parentheses, while asig { returns(...) }always accompanies adefwithout. The two forms reinforce each other. - Never omit parentheses on a definition that takes parameters, even if all parameters have defaults. The parentheses are not about whether callers must provide values; they are about whether there is a parameter list at all.
Worked example:
sig { returns(Money) }
def subtotal # no params, no parens
line_items.sum(&:amount)
end
sig { params(rate: Float).returns(Money) }
def apply_discount(rate) # has params, has parens
Money.parse((subtotal.cents * (1 - rate)).ceil)
endEnforcement: RuboCop Style/DefWithParentheses and Style/MethodDefParentheses.
Reasoning, step by step:
- A pure method — same input, same output, no observable effect — is trivially testable, safe to call from property tests, and safe to memoize. Make it the default; the testable surface of the codebase grows for free.
- Side effects belong at the edges: a thin shell does the I/O or the mutation and delegates the logic to a pure core. This is the same instinct as root rule 6 (transform, don't mutate) applied at the method boundary.
- Name the effect. A side-effecting method carries an effect verb:
write_ledger,emit_event,persist_order,notify_customer. A name likedataorprocesshiding a database write is a lie at the call site (chapter 02). - Inject time, randomness, and external state as parameters so the core remains pure and the tests remain fast.
Time.nowinside a method is a hidden dependency;now:is explicit.
Worked example:
# pure core — no I/O, injectable time
sig { params(order: Order, now: Time).returns(Receipt) }
def build_receipt(order, now:)
settle_order(order, now:)
end
# thin shell — does the I/O, delegates to the pure core
sig { params(order: Order).returns(Receipt) }
def record_sale(order)
receipt = build_receipt(order, now: Time.now) # pure core
LedgerWriter.write(receipt) # effect, at the edge
receipt
endEnforcement: review; effect verbs enforced at code review (naming taxonomy in 02-naming-conventions.md).
Reasoning, step by step:
sendbypasses Ruby's visibility rules: it can callprivateandprotectedmethods as if they were public. Using it in application code erases the encapsulation the visibility modifiers were meant to enforce.public_sendcalls only public methods and raisesNoMethodErroron a private or protected receiver — exactly the behaviour visibility is supposed to provide.- If you find yourself reaching for
sendto access a private method, the real fix is to ask why that method is private. Either make it public, extract it, or redesign the boundary.sendto a private method is a design smell, not a technique. - The only legitimate use of
sendin application code is whenpublic_sendis genuinely insufficient — typically in test helpers that deliberately exercise private internals, or in framework-level introspection. Annotate those sites with a comment explaining why.
Worked example:
# Prefer
sig { params(processor: Processor, event: Symbol).void }
def dispatch(processor, event)
processor.public_send(event) # respects visibility; raises if not public
end
# Avoid
def dispatch(processor, event)
processor.send(event) # bypasses private/protected — do not use
endEnforcement: RuboCop custom cop or Lint/SendWithMixinArgument; reviewer rejects bare send in non-test application code.
Reasoning, step by step:
- Assertions are executable preconditions and postconditions. Check arguments at entry and results before return. The average across a module should land at two or more per method; anything lower means whole regions of behaviour are unguarded.
- The runtime-checked
sigcovers types. Add explicit value-level assertions on top: thesigcannot express that an array must be non-empty, that a monetary total must be non-negative, or that a computed sum must be at least as large as any of its parts. - Assert positive and negative space: not just that the expected holds, but that the impossible is absent. A total must never fall below its subtotal and never fall below its tax — both assertions rule out separate failure modes (negative tax, negative subtotal).
- Pair-assert: verify one property two independent ways so that when the two derivations disagree, a bug surfaces at the assertion rather than three layers downstream in the customer's receipt.
- Define an
Assertmodule once, project-wide. Give it a dedicated error class so callers can distinguish a broken invariant (programmer error) from an operational failure they might recover from.
Define it once, project-wide:
# frozen_string_literal: true
# typed: strict
module Assert
class InvariantViolation < StandardError; end
sig { params(condition: T::Boolean, message: String).void }
def self.that(condition, message)
raise InvariantViolation, message unless condition
end
sig { params(message: String).returns(T.noreturn) }
def self.fail(message)
raise InvariantViolation, message
end
endAssert.that is the Ruby parallel to the TypeScript invariant(): asserts helper. Use it for preconditions, postconditions, and any invariant that sig cannot express. Assert.fail is the unconditional form for exhaustive case analysis and unreachable branches.
Enforcement: review and code review; density is a target, not a lint rule. Assert.that is the sole sanctioned assertion primitive (see 08-error-handling.md).
- Line-length cap,
Metrics/MethodLength,Metrics/BlockNesting, andMetrics/ParameterListscop configuration: 01-formatting-and-tooling.md. - Effect-verb naming, predicate suffixes, and the naming taxonomy for side-effecting methods: 02-naming-conventions.md.
sigon every method,T.let/T.castdiscipline, andMoney.parseas the parse-constructor: 03-type-safety-and-nil-discipline.md.- Local variable scope,
attr_reader, and memoization caveats that affect method bodies: 04-variables-and-declarations.md. Data.definevalue objects,class << selfgrouping, and module-of-functions pattern: 06-classes-and-data-modeling.md.- Enumerable pipelines,
nextin block idioms, and pattern matching as an alternative to branching: 07-ruby-idioms.md. Assert::InvariantViolation,StandardErrorsubclasses, and the error taxonomy: 08-error-handling.md.- Pure method design and deterministic testing with injected
Time: 11-testing.md. - Public API surface,
sigon every public method, and keyword-argument contracts at boundaries: 10-api-design.md.