Build1 publisher2 min readPublished
The 2 a.m. operator test moved every slow call out of this billing app's request path
FoxyInvoice's architecture chapter walks a Caddy edge, two .NET 10 APIs with a Postgres each, and the outbox row that makes an invoice durable before any email is sent. The one outage it reports came from a package version diamond.
The Engineer · Build desk

What happened
- The system is one Caddy edge, two .NET API containers with worker containers alongside them, a separate Postgres per API, and GitHub Actions deploying the lot over SSH.
- The stated test for every decision is whether one tired person can run the system at 2 a.m.
- Anything slower than about 100ms or requiring a retry leaves the request path for the worker, including email dispatch, payment-reminder scans and recurring invoice generation.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- cost One codebase deployed twice still puts six processes behind the proxy, and two Postgres instances means two restore procedures for the single operator the design is built around.
- constraint A 100ms budget rules out doing slow work in a controller, so anyone copying this shape takes on a second always-on container and a table of unsent rows somebody has to read.
- decision The only production failure on the record here came out of the package graph, so a solo stack budgets attention for version discipline before it argues about components.
- contradiction The chapter credits the 2 a.m. constraint for every choice, then says Angular won partly because the operator already knew it, so familiarity settled the frontend.
Start with what happens on a send. The handler writes the business row and a row in `outbox_messages` in one database transaction [17]. The commit is the moment the user's action is durable. Delivery happens afterwards, on a worker loop that picks up unsent messages, retries, and either marks them Sent or records the error [17]. The chapter names the bug this design deletes: "user clicked submit, page said OK, email never arrived" [18]. Send inside the request instead and you choose between two bad outcomes: fail the request, so the user thinks the invoice was not saved, or swallow the error, so the email vanishes [18].
The cost is a container that has to be up whenever anyone expects mail, and a table that now holds the failures. Every email in the product goes through it, including feedback notifications with `Reply-To` set to the reporter [19]. The rule deciding what belongs there is a latency number: anything slower than about 100ms, or anything that must retry, leaves the request path [10]. Same codebase, different entrypoint, its own container, so a hung email send cannot block an invoice save [11].
The chapter describes the result as five moving parts and a proxy [5]. Instances count differently. foxyinvoice.com and invoices.seolith.com serve the same Angular bundle with branding resolved at runtime [7], and each deployment gets its own API container, worker and Postgres [2]. That is six processes behind one Caddy [22].
"Architecture includes your dependency graph," the author wrote [16]. The evidence is the single production failure the chapter reports. The in-process dispatcher was MediatR 12, the platform packages needed 14, and the version diamond crash-looped production until a dependency bumped it into a wall [15]. That outage came out of the package graph. Controllers now parse a request and hand a command or query object to Mediator, a single-file MIT library, with every business operation written as a record plus one handler class [12][13]. The migration took an afternoon [16].
For this shape to transfer, three things have to hold: one operator, no on-call rotation, and a product where downtime means someone cannot get paid [23]. Those conditions justify the outbox and the second container. The frontend choice came from somewhere else. Angular won on batteries-included opinions and because it is what the one operator already knew, and the chapter states the principle directly: "Framework choice is a burn-rate decision, not an identity" [8]. The same chapter asserts the stack can carry a million users, "further than you'd think", and includes no load figures [24].
What to watch
- Chapter 13 is promised to dissect the MediatR 12 versus 14 version diamond in full, and that detail decides how avoidable the crash-loop was.
- Whether later chapters publish a restore procedure for the two Postgres instances, and how long one takes to run.
- Any load figures behind the claim that the stack carries a million users.