Process managers

The owner told you the policy in one sentence. Somewhere between that sentence and the file, it came apart. Here it is put back as one class. Scroll the scene, or press a stage.

The owner has been running that policy for years. It is not a requirement anyone had to go and look up. It is the business. Then you go looking for it in the code. The thirty is an if in a controller. The five percent is a float in a helper. The notice is somewhere in a mailer. Nothing in the repository says who decided either number, or that the two belong to the same decision. The unit that can hold a sentence whole is called a process manager.

The engine, reversed

The sentence becomes the class, one part at a time.

Chapter 3 folded a finished file down until it read as the owner's help page. This chapter grows the file back up out of what the owner said. Nothing is invented along the way: every line below comes from a piece of the sentence.

the owner · the policy, said out loud
> When an invoice goes thirty days late, charge five percent and tell the customer.
The policy, said in one sentence. The code kept none of it.

The sentence, alone. One when clause, one then clause, and two numbers that are company policy rather than arithmetic. No code yet.

The whole process becomes the class name. Not the first verb, the whole thing: Billing::ApplyLateFee. The file follows the name to billing/apply_late_fee.cr, and the sentence moves in above the class, where Crystal reads it as documentation.

The when clause becomes initialize. "An invoice goes thirty days late" names what the process must be handed: the invoice, and how many days late it is. The two policy numbers arrive the same way, with the company's answer as the default.

The then clause becomes the steps. "Charge five percent and tell the customer" is two verbs, so it is two named methods, plus the one the sentence implies. Read perform aloud and you get the owner's sentence back.

stage 1 · the sentence, said out loud
Stage 1 · the sentence, alone
1# When an invoice goes thirty days late, charge five percent and tell the customer.
Stage 2 · the whole process becomes the class name
1# billing/apply_late_fee.cr
2#
3# When an invoice goes thirty days late, charge five percent and tell the customer.
4class Billing:::ApplyLateFee
5end
Stage 3 · the when clause becomes initialize
1# billing/apply_late_fee.cr
2#
3# When an invoice goes thirty days late, charge five percent and tell the customer.
4class Billing:::ApplyLateFee
5 # The invoice being charged. It knows its customer, its amount, and its due date.
6 getter invoice : Invoice
7
8 # How many days past the due date the invoice is today.
9 getter how_many_days_late : Int32
10
11 # Days past due before any fee applies.
12 getter grace_period_in_days : Int32
13
14 # The fee, as a share of the amount owed.
15 getter late_fee_rate : Float64
16
17 def initialize(
18 @invoice : Invoice,
19 @how_many_days_late : Int32,
20 @grace_period_in_days : Int32 = 30,
21 @late_fee_rate : Float64 = 0.05
22 )
23 end
24end
Stage 4 · the then clause becomes the steps
1# billing/apply_late_fee.cr
2#
3# When an invoice goes thirty days late, charge five percent and tell the customer.
4class Billing:::ApplyLateFee
5 # The invoice being charged. It knows its customer, its amount, and its due date.
6 getter invoice : Invoice
7
8 # How many days past the due date the invoice is today.
9 getter how_many_days_late : Int32
10
11 # Days past due before any fee applies.
12 getter grace_period_in_days : Int32
13
14 # The fee, as a share of the amount owed.
15 getter late_fee_rate : Float64
16
17 def initialize(
18 @invoice : Invoice,
19 @how_many_days_late : Int32,
20 @grace_period_in_days : Int32 = 30,
21 @late_fee_rate : Float64 = 0.05
22 )
23 end
24
25 def perform
26 stop_unless_the_invoice_is_overdue
27 charge_the_late_fee
28 tell_the_customer
29 end
30
31 # Nothing happens inside the grace period.
32 private def stop_unless_the_invoice_is_overdue
33 return if how_many_days_late < grace_period_in_days
34 end
35
36 # The fee is a share of the amount owed, charged to the same invoice.
37 private def charge_the_late_fee
38 invoice.charge(invoice.amount * late_fee_rate, kind: :late_fee)
39 end
40
41 # The customer gets the notice by email.
42 private def tell_the_customer
43 CustomerMail.late_fee(invoice).deliver
44 end
45end
The ownerhears the same sentence back, in the order it was said.
The developerreads the plan one named step at a time, and opens only the step they need.
Their assistantwrites the next process the same way, from the next sentence.

That is the whole test for a process manager, and the reason the convention exists: the sentence survives the trip into the code, and survives the trip back out.

the conventions · 03_process_managers.md
> When a list of Customer ID's is provided, then lock each customers account.
Processes start with a "when" keyword, always. Because a process is "when" something happens!
> When an array of Integers that represent Customer IDs is provided then loop through each Customer account using the ID to find the correct record and update the necessary attribute that will prevent the Customer from accessing their account.

Reading a When-statement

The when clause is the parameter list. The then clause is the method list.

The when keyword is not decoration. It forces you to name the trigger, and a process that cannot name its trigger is usually two processes wearing one name.

"A list of Customer ID's is provided" is a collection, so the parameter is a collection and says so: array_of_customer_ids_to_lock. Everything the process needs is named here, because everything it needs has to arrive at initialize. No step goes fetching a missing input halfway through the run.

The clause after then is where the jargon lives, and 03_process_managers.md expects it to be questioned. The third line of that excerpt is the file's own expansion of "lock". That expansion is a step, not a formality: write it before any code exists, and let your agent write it back to you. It will ask about the jargon it cannot resolve, which is the conversation you wanted with the owner anyway.

Try it on a second sentence from the same business: "When a subscription's payment method expires, then warn the customer and pause the subscription at the end of the period." Two verbs, two steps, one input, and one policy number hiding inside "at the end of the period."

Middle managers

One late fee is one process manager. The nightly run is where the second layer shows up.

The batch process needs a decision of its own: which overdue invoices the company excuses this month. That decision is not the late fee, it is not reusable anywhere else, and it is too big to sit inline. It becomes a middle manager: a class namespaced under its parent process, used by nothing else, and calling no manager of its own.

billing · the nightly run
apply_late_fees_to_every_overdue_invoice.cr
1# billing/apply_late_fees_to_every_overdue_invoice.cr
2#
3# When the nightly billing run finds overdue invoices, charge every one the company
4# is not excusing this month.
5class Billing:::ApplyLateFeesToEveryOverdueInvoice
6 # Every invoice the nightly run found past its due date.
7 getter collection_of_invoices_that_are_past_due : Array(Invoice)
8
9 # What is left after this month's exemptions are set aside.
10 getter collection_of_invoices_that_are_still_chargeable : Array(Invoice) = [] of Invoice
11
12 # The invoices that actually took a fee.
13 getter collection_of_invoices_that_were_charged : Array(Invoice) = [] of Invoice
14
15 def initialize(@collection_of_invoices_that_are_past_due : Array(Invoice))
16 end
17
18 def perform
19 set_aside_the_invoices_this_month_exempts
20 charge_every_remaining_overdue_invoice
21 end
22
23 # The second layer of business logic, handed to the middle manager.
24 private def set_aside_the_invoices_this_month_exempts
25 decide_the_exemptions = DecideWhichOverdueInvoicesAreExempt.new(
26 collection_of_invoices_that_are_past_due: collection_of_invoices_that_are_past_due
27 )
28 decide_the_exemptions.perform
29 @collection_of_invoices_that_are_still_chargeable =
30 decide_the_exemptions.collection_of_invoices_that_are_still_chargeable
31 end
32
33 # Each invoice is charged by the process manager that owns one late fee.
34 private def charge_every_remaining_overdue_invoice
35 collection_of_invoices_that_are_still_chargeable.each do |invoice_that_is_past_due|
36 apply_the_late_fee = Billing:::ApplyLateFee.new(
37 invoice: invoice_that_is_past_due,
38 how_many_days_late: invoice_that_is_past_due.how_many_days_late
39 )
40 apply_the_late_fee.perform
41 collection_of_invoices_that_were_charged << invoice_that_is_past_due
42 end
43 end
44
45 # The middle manager. Namespaced to its parent, used nowhere else, and it calls
46 # no manager of its own.
47 class DecideWhichOverdueInvoicesAreExempt
48 getter collection_of_invoices_that_are_past_due : Array(Invoice)
49 getter collection_of_invoices_that_are_still_chargeable : Array(Invoice) = [] of Invoice
50
51 def initialize(@collection_of_invoices_that_are_past_due : Array(Invoice))
52 end
53
54 def perform
55 keep_every_invoice_whose_customer_is_not_on_a_payment_plan
56 end
57
58 private def keep_every_invoice_whose_customer_is_not_on_a_payment_plan
59 @collection_of_invoices_that_are_still_chargeable =
60 collection_of_invoices_that_are_past_due.reject do |invoice_that_is_past_due|
61 invoice_that_is_past_due.customer.is_on_a_payment_plan
62 end
63 end
64 end
65end

Note what the parent is allowed to do that the middle manager is not. The parent calls Billing::ApplyLateFee, a process manager in its own right, and that is fine. The restriction is on middle managers, not on the process managers that own them.

That gives you the test. The obvious first instinct in this file is a middle manager called ChargeASingleOverdueInvoice. Apply the rule and it fails immediately: the controller action charges a single overdue invoice too, so you would be reusing it, and a middle manager you want to reuse is not a middle manager. It is already Billing::ApplyLateFee. Promote it, or discover it was promoted a chapter ago.

The rules this chapter settles

A When-statement is the whole specification, so the class can be written from it without guessing.

  • A process manager is where a business process begins and ends.The source, word for word: "a starting point in a business process where a workflow of one or more steps begins and ends, with the final product being the end of the computational process for the business."
    Billing::ApplyLateFee
  • Every process starts from a when statement.Always, because a process is when something happens.
    When an invoice goes thirty days late…
  • initialize receives everything the process needs.Any data organization happens there, and named parameters are preferred, so no step goes looking for a missing input.
    initialize(@invoice, @how_many_days_late)
  • perform takes no arguments.It is the one entry point, and it performs every method the business task needs in a single call. Everything it needs already arrived.
    perform(invoice, days)perform
  • perform reads like pseudocode, one named step per line.The source, word for word: "A well written perform method will read almost like psuedo code when outlining each step that's being performed." It is published verbatim, the author's spelling included.
    charge_the_late_fee
    tell_the_customer
  • Branching or looping inside perform means a step is missing.That logic belongs in a named step method, where the name says what the branch decides.
    if days_late >= 30stop_unless_the_invoice_is_overdue
  • Public accessors are read-only.Whenever the object is used for anything other than returning a single result, the caller reads and does not set.
    propertygetter
  • Middle managers are namespaced, unshared, and childless.A middle manager belongs to one process manager, is not reused across the code base, and uses no other manager. One you want to reuse was a process manager all along.
    ApplyLateFeesToEveryOverdueInvoice::
    DecideWhichOverdueInvoicesAreExempt
  • Class names state the whole process, namespaced by feature.A short statement or phrase, not a verb and an object.
    LockCustomersPerformCustomerAccountLocking
    ApplyFeeBilling::ApplyLateFee
  • The words "process" and "manager" in the name are optional.The source, word for word: "This is a process manager, but it does not use "process" or "manager" in the name. It is acceptable with or without including those details." Pick one and hold it across the code base.
    Billing::ApplyLateFee
    Billing::ApplyLateFeeProcess
  • Non-RESTful routes validate, delegate, and render.The action receives and validates the incoming parameters, uses a process manager to perform the logic, and renders a response. CRUD actions keep the bare minimum logic.
    apply_the_late_fee.perform
  • The file is the snake case of the primary class.In a folder named for the namespace, so the sentence can be found from the file tree alone.
    billing/apply_late_fee.cr
What the agent can hold at once. The tiles are tokens; the gold outline is the context window, sliding one token at a time.

Why it matters to the agent

The largest unit an agent can write without guessing is a sentence the owner already said.

The when clause is the parameter list. The then clause is the method list. The name is the whole sentence. Give an agent the sentence and the convention, and there is nothing left to invent. That is why the AED plugin can scaffold one from a sentence at all, and why its checklist is short enough to verify mechanically.

It matters again three weeks later, when the agent opens the file cold. A perform that reads like the sentence means the policy fits on one screen, inside one token window, with no call graph to walk. The intent is the file's first screen.

In a comprehension benchmark (2026-07-07) Claude Haiku answered blind probes about matched pairs of Crystal, and the AED-style variants scored 60 of 60 against 54 of 60 for conventional style. The whole gap sat in defect probes and intent probes, with modification probes tied. Ten pairs, one small model, one run, a model grader: directional, not proof, and the report says so first.

At codebase scale, the pet-tracker build-off (2026-08-11) ran the same task twice in each arm of one Crystal template, with and without AED adopted. All four runs type-checked green; the two runs without AED shipped zero of five working user journeys each, and the two with it shipped two and four. Two runs per arm, one task, one model: a demonstration, not a study. No agent in any arm executed a write path, so conventions narrowed the gap between "it compiles" and "it works," and only running the software closed it.

Reading with an agent? Hand it the machine edition of this chapter. The source is 03_process_managers.md in the conventions repository.

Before and after

First the controller with the policy buried inside it, then the same behavior as a process manager.

the late fee · before and after
invoices_controller.cr — before
1# before -- the sentence is nowhere. The thirty is an `if`, the five percent is a
2# float, and no name in the file says "late fee policy".
3class InvoicesController < ApplicationController
4 def apply_late_fee
5 invoice = Invoice.find(params["id"])
6 days_late = (Time.utc - invoice.due_date).total_days.to_i
7
8 if days_late >= 30
9 fee = invoice.amount * 0.05
10 invoice.charge(fee)
11 CustomerMail.late_fee(invoice).deliver
12 respond_with { json({"status" => "charged", "fee" => fee}) }
13 else
14 respond_with { json({"status" => "ok"}) }
15 end
16 end
17end
billing/apply_late_fee.cr — after
1# billing/apply_late_fee.cr
2#
3# When an invoice goes thirty days late, charge five percent and tell the customer.
4class Billing:::ApplyLateFee
5 # -- What this needs (the "when" clause) --
6
7 # The invoice being charged. It knows its customer, its amount, and its due date.
8 getter invoice : Invoice
9
10 # How many days past the due date the invoice is today.
11 getter how_many_days_late : Int32
12
13 # -- Company policy --
14
15 # Days past due before any fee applies.
16 getter grace_period_in_days : Int32
17
18 # The fee, as a share of the amount owed.
19 getter late_fee_rate : Float64
20
21 # -- What the caller may read afterward --
22
23 # The fee actually charged, or zero if the invoice is still inside its grace period.
24 getter the_late_fee_that_was_charged : Float64 = 0.0
25
26 # Whether the notice reached the customer.
27 getter has_the_customer_been_told : Bool = false
28
29 def initialize(
30 @invoice : Invoice,
31 @how_many_days_late : Int32,
32 @grace_period_in_days : Int32 = 30,
33 @late_fee_rate : Float64 = 0.05
34 )
35 end
36
37 # -- How it runs (the "then" clause) --
38
39 def perform
40 stop_unless_the_invoice_is_overdue
41 charge_the_late_fee
42 tell_the_customer
43 end
44
45 # Nothing happens inside the grace period.
46 private def stop_unless_the_invoice_is_overdue
47 return if how_many_days_late < grace_period_in_days
48 end
49
50 # The fee is a share of the amount owed, charged to the same invoice.
51 private def charge_the_late_fee
52 @the_late_fee_that_was_charged = invoice.amount * late_fee_rate
53 invoice.charge(the_late_fee_that_was_charged, kind: :late_fee)
54 end
55
56 # The customer gets the notice by email.
57 private def tell_the_customer
58 CustomerMail.late_fee(invoice).deliver
59 @has_the_customer_been_told = true
60 end
61end
62
63# The non-RESTful action now validates, delegates, and renders -- nothing else.
64class InvoicesController < ApplicationController
65 def apply_late_fee
66 invoice = Invoice.find(params["id"])
67 apply_the_late_fee = Billing:::ApplyLateFee.new(
68 invoice: invoice,
69 how_many_days_late: invoice.how_many_days_late
70 )
71 apply_the_late_fee.perform
72 respond_with do
73 json({"the_late_fee_that_was_charged" => apply_the_late_fee.the_late_fee_that_was_charged})
74 end
75 end
76end

Two things changed from the file chapter 3 showed you, and both come from this chapter's rules. property became getter, because this object is read after it runs and its results are not the caller's to set. And the policy numbers moved off the properties and onto initialize's named parameters, so the company's answer is still the default while one call site can state an exception without editing the policy.

One honest note about stop_unless_the_invoice_is_overdue: as written, its early return leaves the step, not the process. It is kept in chapter 3's shape on purpose, so the two chapters are looking at the same file. How a process manager stops early without putting a branch back into perform is chapter 7's subject, Control flow.