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.
> When an invoice goes thirty days late, charge five percent and tell the customer.
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.
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.
> 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.
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 >= 30→ stop_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.
property→ getter - 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.
LockCustomers→ PerformCustomerAccountLockingApplyFee→ Billing::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
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.
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.