# Chapter 4 · Process managers

Machine edition of https://agentc.consulting/agent-enhanced-development/chapter-4/process-managers
Part of Agent-Enhanced Development: https://agentc.consulting/agent-enhanced-development.md
Source: https://github.com/AgentC-Consulting/aed-conventions/blob/main/03_process_managers.md

## The moment

You asked the owner what happens when a customer pays late, and the answer came in
one sentence, without pausing: "When an invoice goes thirty days late, charge five
percent and tell the customer." 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. Somewhere between that sentence and the file, it came apart.

This chapter puts it back together as one class. It is not a rewrite of chapter 3's
file. It is the same file, reached from the other end. Chapter 3 folded a finished
file down until it read as the owner's help page. Chapter 4 starts from what the
owner said and grows the file up out of it. The unit that can hold a sentence whole
is called a process manager.

## The engine, reversed: the sentence becomes the class

On the page the class assembles around the owner's sentence one part at a time, as
you scroll the scene or press a stage. Four stages, and nothing is invented along
the way: every line comes from a piece of the sentence already said out loud.

1. **Stage 1, the sentence, alone.** "When an invoice goes thirty days late, charge five
   percent and tell the customer." No code yet. One `when` clause, one `then`
   clause, and two numbers that are company policy rather than arithmetic.
2. **Stage 2, the whole process becomes the class name.** Not the first verb, the whole
   thing: `Billing::ApplyLateFee`. `Billing` is the feature it belongs to, and
   `ApplyLateFee` is the process stated as a phrase. The file follows the name to
   `billing/apply_late_fee.cr`.
3. **Stage 3, the `when` clause becomes `initialize`.** "An invoice goes thirty days late"
   names what the process must be handed before it can run: the invoice, and how
   many days late it is. They arrive as named parameters. The two numbers, thirty
   days and five percent, arrive the same way, with the company's answer as the
   default, so the policy is legible at the top of the file and still overridable
   at one call site.
4. **Stage 4, 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: nothing happens inside the grace period. `perform` calls them in order
   and does nothing else.

What stage four leaves you with is the payoff:

```crystal
def perform
  stop_unless_the_invoice_is_overdue
  charge_the_late_fee
  tell_the_customer
end
```

Read that aloud and you get the owner's sentence back. That is the whole test for a
process manager, and it is the reason the convention exists: the sentence survives
the trip into the code, and survives the trip back out.

Three readers meet at that `perform`. The owner hears the same sentence back, the
developer reads the plan one named step at a time, and the agent writes the next one
the same way.

## Reading a When-statement

`03_process_managers.md` works this with its own example, a different business
process:

> `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!

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.

The clause after `when` tells you the inputs. "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`. "An invoice goes thirty days late" is one invoice
and one count of days, so the parameters are `invoice` and `how_many_days_late`.
Everything the process needs is named here, because everything it needs has to
arrive at `initialize`. No step is allowed to go fetch a missing input halfway
through the run. If the process has to look something up, the lookup is data
organization and it belongs in `initialize` too.

The clause after `then` is where the jargon lives. "Lock each customers account"
and "charge five percent" are business words, not code words, and the conventions
expect them to be questioned. The source file's own expansion of its example spells
out what "lock" turned out to mean:

> `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.`

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 exactly 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 that charges every overdue
invoice is another, and it 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.

```crystal
# billing/apply_late_fees_to_every_overdue_invoice.cr
#
# When the nightly billing run finds overdue invoices, charge every one the company
# is not excusing this month.
class Billing::ApplyLateFeesToEveryOverdueInvoice
  # Every invoice the nightly run found past its due date.
  getter collection_of_invoices_that_are_past_due : Array(Invoice)

  # What is left after this month's exemptions are set aside.
  getter collection_of_invoices_that_are_still_chargeable : Array(Invoice) = [] of Invoice

  # The invoices that actually took a fee.
  getter collection_of_invoices_that_were_charged : Array(Invoice) = [] of Invoice

  def initialize(@collection_of_invoices_that_are_past_due : Array(Invoice))
  end

  def perform
    set_aside_the_invoices_this_month_exempts
    charge_every_remaining_overdue_invoice
  end

  # The second layer of business logic, handed to the middle manager.
  private def set_aside_the_invoices_this_month_exempts
    decide_the_exemptions = DecideWhichOverdueInvoicesAreExempt.new(
      collection_of_invoices_that_are_past_due: collection_of_invoices_that_are_past_due
    )
    decide_the_exemptions.perform
    @collection_of_invoices_that_are_still_chargeable =
      decide_the_exemptions.collection_of_invoices_that_are_still_chargeable
  end

  # Each invoice is charged by the process manager that owns one late fee.
  private def charge_every_remaining_overdue_invoice
    collection_of_invoices_that_are_still_chargeable.each do |invoice_that_is_past_due|
      apply_the_late_fee = Billing::ApplyLateFee.new(
        invoice: invoice_that_is_past_due,
        how_many_days_late: invoice_that_is_past_due.how_many_days_late
      )
      apply_the_late_fee.perform
      collection_of_invoices_that_were_charged << invoice_that_is_past_due
    end
  end

  # The middle manager. Namespaced to its parent, used nowhere else, and it calls
  # no manager of its own.
  class DecideWhichOverdueInvoicesAreExempt
    getter collection_of_invoices_that_are_past_due : Array(Invoice)
    getter collection_of_invoices_that_are_still_chargeable : Array(Invoice) = [] of Invoice

    def initialize(@collection_of_invoices_that_are_past_due : Array(Invoice))
    end

    def perform
      keep_every_invoice_whose_customer_is_not_on_a_payment_plan
    end

    private def keep_every_invoice_whose_customer_is_not_on_a_payment_plan
      @collection_of_invoices_that_are_still_chargeable =
        collection_of_invoices_that_are_past_due.reject do |invoice_that_is_past_due|
          invoice_that_is_past_due.customer.is_on_a_payment_plan
        end
    end
  end
end
```

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 process manager is, word for word from the source, "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."
- Every process starts from a `when` statement. Always, because a process is
  *when* something happens.
- `initialize` receives all of the necessary information possible to perform the
  process. Any data organization happens there. Prefer named parameters.
- `perform` is the entry point, takes no arguments, and performs every method the
  business task needs in a single call. Everything it needs already arrived.
- `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.)
- Branching, looping, or inline logic in `perform` means that logic belongs in a
  named step method.
- Public accessors are read-only whenever the object is used for anything other
  than returning a single result.
- Middle managers are namespaced to their process manager, are not reused across
  the code base, and do not use any other managers.
- Class names are short statements or phrases that state the whole process, and are
  namespaced by feature: `Billing::ApplyLateFee`, not `ApplyFee`. Good:
  `PerformCustomerAccountLocking`. Bad: `LockCustomers`.
- 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.
- Non-RESTful routes receive and validate the incoming parameters, use a process
  manager to perform the logic, and render a response. CRUD actions keep the bare
  minimum logic.
- The file is the lower snake_case of the primary class, in a folder named for the
  namespace: `Billing::ApplyLateFee` lives at `billing/apply_late_fee.cr`.

## Why it matters to the agent

A process manager is the largest unit an agent can write without guessing, because
the When-statement is the entire specification. 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 (`/aed:scaffold`), 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 agent does not have to reconstruct what the
business intended from five call sites; the intent is the file's first screen.

The conventions repository publishes two pieces of evidence, both with their limits
stated. 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 (20/20 against 16/20)
and intent probes (20/20 against 18/20), 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. The build-off's own conclusion is the one
worth carrying: 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.

## Before and after

First, the controller with the policy buried inside it. The sentence is in this
file, but no name says so, and no part of it can be read without reading all of
it.

```crystal
# before -- the sentence is nowhere. The thirty is an `if`, the five percent is a
# float, and no name in the file says "late fee policy".
class InvoicesController < ApplicationController
  def apply_late_fee
    invoice = Invoice.find(params["id"])
    days_late = (Time.utc - invoice.due_date).total_days.to_i

    if days_late >= 30
      fee = invoice.amount * 0.05
      invoice.charge(fee)
      CustomerMail.late_fee(invoice).deliver
      respond_with { json({"status" => "charged", "fee" => fee}) }
    else
      respond_with { json({"status" => "ok"}) }
    end
  end
end
```

Then the same behavior as a process manager, plus the controller action that is
left over. The action validates, delegates, and renders, and nothing else.

```crystal
# billing/apply_late_fee.cr
#
# When an invoice goes thirty days late, charge five percent and tell the customer.
class Billing::ApplyLateFee
  # -- What this needs (the "when" clause) --

  # The invoice being charged. It knows its customer, its amount, and its due date.
  getter invoice : Invoice

  # How many days past the due date the invoice is today.
  getter how_many_days_late : Int32

  # -- Company policy --

  # Days past due before any fee applies.
  getter grace_period_in_days : Int32

  # The fee, as a share of the amount owed.
  getter late_fee_rate : Float64

  # -- What the caller may read afterward --

  # The fee actually charged, or zero if the invoice is still inside its grace period.
  getter the_late_fee_that_was_charged : Float64 = 0.0

  # Whether the notice reached the customer.
  getter has_the_customer_been_told : Bool = false

  def initialize(
    @invoice : Invoice,
    @how_many_days_late : Int32,
    @grace_period_in_days : Int32 = 30,
    @late_fee_rate : Float64 = 0.05
  )
  end

  # -- How it runs (the "then" clause) --

  def perform
    stop_unless_the_invoice_is_overdue
    charge_the_late_fee
    tell_the_customer
  end

  # Nothing happens inside the grace period.
  private def stop_unless_the_invoice_is_overdue
    return if how_many_days_late < grace_period_in_days
  end

  # The fee is a share of the amount owed, charged to the same invoice.
  private def charge_the_late_fee
    @the_late_fee_that_was_charged = invoice.amount * late_fee_rate
    invoice.charge(the_late_fee_that_was_charged, kind: :late_fee)
  end

  # The customer gets the notice by email.
  private def tell_the_customer
    CustomerMail.late_fee(invoice).deliver
    @has_the_customer_been_told = true
  end
end

# The non-RESTful action now validates, delegates, and renders -- nothing else.
class InvoicesController < ApplicationController
  def apply_late_fee
    invoice = Invoice.find(params["id"])
    apply_the_late_fee = Billing::ApplyLateFee.new(
      invoice: invoice,
      how_many_days_late: invoice.how_many_days_late
    )
    apply_the_late_fee.perform
    respond_with do
      json({"the_late_fee_that_was_charged" => apply_the_late_fee.the_late_fee_that_was_charged})
    end
  end
end
```

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.

Next: Chapter 5 · Feature stories (where the sentence comes from in the first place,
and how a feature's story yields the nouns and verbs before any process manager is
written).
