Capstone: Evolvable Billing, and a Legacy One
A brief, the requirements that make it harder than the nouns suggest, questions whose answers constrain each other, the things that go wrong afterwards — and then the change requests nobody put in the brief.
Design the billing platform for a SaaS product sold to organisations. It has users inside organisations, subscriptions to plans, invoices, payments, credits, discounts, usage-based charges, emails, an audit trail and at least one external payment provider. None of it is algorithmically difficult. All of it changes: pricing changes because sales negotiated something, tax changes because a government did, the provider changes because a market demanded it, and finance changes what an invoice must show because an auditor asked. Design it so that the requirement arriving next quarter — which you have not been told — is a change to one place rather than to nine. Then be honest about which changes your design does not make cheap, because a design that claims to make all of them cheap has not been examined.
The requirements
None of this is algorithmically hard. All of it changes, and each one has a wrinkle that decides most of the design.
A user is not one thing. There is an identity that authenticates, a person with a name and an email that is personal data subject to erasure, and a membership that grants authority inside an organisation — and a single user can belong to several organisations with different rights in each. Collapse them into one record and "delete this person" becomes impossible without destroying an audit trail, while "who approved this refund" becomes ambiguous the moment someone changes organisation.
The organisation is the thing everything else is scoped by, which makes it the consistency boundary whether or not anyone chose it as one. Every query must be filtered by it and one missed filter is a cross-tenant data leak. It is also where the awkward requirements land: organisations merge, split, get renamed, transfer ownership, and occasionally want two subscriptions billed to different departments — none of which a design that treats the organisation as a foreign key can express.
The subscription is the lifecycle everything else hangs off, and it accumulates states faster than anything in the system: trialing, active, past due, paused, pending approval, scheduled for cancellation, cancelled, expired. The states are not the difficulty — the transitions are, and specifically the ones that must not exist. A subscription that can go from past due to paused lets a customer freeze a debt, and nothing about the happy path will ever reveal that.
A plan is a published offer, and existing subscribers keep the terms they were sold. So a plan cannot be edited — changing a price would silently reprice everyone on it — which means plans are versioned, a subscription references the version it bought, and every screen showing "the Pro plan" has to decide which version it means. The request that exposes a design here is not a new plan; it is sales agreeing a custom price for one customer, which is a plan with exactly one subscriber.
Once issued, an invoice is a record of what was claimed on a date, and it must be reproducible identically years later. That makes it immutable, which makes every correction a new document rather than an edit, and it makes every line self-contained: a line that references a plan will render with next year's price and look entirely plausible while being wrong. Everything about invoices is easy until the first one has been sent.
A payment is the one operation where a timeout is genuinely ambiguous — the charge may have succeeded. Retrying without an idempotency key charges twice; not retrying leaves an invoice unpaid that the customer has paid. And confirmation is not always synchronous: a bank transfer settles in days and can be reversed weeks after it succeeded, so "paid" is a state with a history rather than a boolean.
Credit is money that exists inside the system rather than at a provider, which means the system is now a ledger and has the obligations of one. The invariant is unforgiving: the sum of credits applied to an invoice plus payments received must equal what was settled, and credit must never be applied twice from two concurrent operations. Credits also expire, which makes their balance a function of time rather than a stored number.
Discounts compose, and composition is where the rules live: percentage off before or after a fixed amount, before or after tax, applied to the whole invoice or to one line, stacking or exclusive, first period only or forever. The order of application changes the total, so the order is a business rule that someone must own — and if it is implicit in the sequence of if statements that grew over three years, nobody can answer what a given customer was charged or why.
Metering is a high-volume pipeline with different reliability properties from everything else here: events arrive duplicated, out of order, and sometimes after the billing period they belong to has closed. The design question is not how to count but where the boundary is — billing must consume a closed, agreed aggregate rather than query raw events, or the invoice total changes depending on when you ask, and a late event silently makes an issued invoice wrong.
Sending is an effect that cannot be rolled back, and it is almost always written inside the transaction that caused it. Then the transaction fails and a customer has an email about an invoice that does not exist, or the send fails and the transaction commits and nobody was told. It is also the most volatile requirement in the platform — copy, timing, locale, opt-outs and channels change constantly — so anything that hard-codes a message into billing logic gives billing a reason to change that has nothing to do with money.
This is the requirement that cuts across everything by construction: every mutation, in every module, must produce a record of who did what to which entity and when. No boundary contains it, which means a well-separated design touches more places to add it than a single tangled service would — the one case where separation genuinely costs more, and worth meeting on purpose rather than being surprised by. The trap on top is retention: an audit record naming a person collides with that person's right to be erased.
A provider is a dependency that changes on someone else's schedule, has its own model of a customer, a product and a subscription that does not match ours, and is authoritative about facts we also store. Let its vocabulary into the domain and every provider concept becomes ours forever; keep it out and you must maintain a translation whose failure mode is silent — the day the provider adds a status value the adapter does not know, it maps to a default and the system is confidently wrong.
The questions
Answer them in this order. Deciding which concepts get their own types decides what a boundary can be drawn around, which decides what a test can be written against — answer them out of order and the later answers are forced by earlier accidents.
- 1
What must never stop being true about this system, stated about the data rather than about any code path?
- 2
What responsibilities exist here, and what is each one's single reason to change?
- 3
Which concepts deserve a type of their own rather than a primitive?
- 4
Where do the module boundaries fall, and what test says they are in the right place?
- 5
Which dependencies are volatile, and what does that force?
- 6
Which state transitions exist, and — the part that matters — which must not?
- 7
Which errors are expected outcomes and which are failures, and how does the difference show up in the code?
- 8
Which interfaces are stable enough to depend on, and which are still moving?
- 9
What needs migration, and what is true during it?
- 10
What is tested at which boundary, and what does each fake stop the suite from detecting?
- 11
Who owns each piece of data, and who is allowed to write it?
- 12
Where does time come from, and which time is the system talking about?
- 13
What are you deliberately not building, and what would make you change your mind?
- 14
How will you know this design is working, six months in?
Injected failures
Each of these arrives while the product is live. Every one has a response that is plausible, is what most teams reach for, and makes the next change more expensive — read the trap even when you got the cause.
Changing a sentence requires editing BillingService, a class that also calculates prices, reads and writes six tables, calls the payment provider, renders invoices, updates subscriptions and writes audit records. Its test suite takes eleven minutes and touches a database and a provider sandbox, so the two days are mostly waiting for it and proving nothing else moved.
The number on the pricing page, the number in the checkout preview and the number on the invoice come from three different pieces of code. Two of them apply the annual discount before tax and one applies it after. Every one of them was correct when written.
Support sees duplicate charges. The application logs show one request per charge, so at first it looks like the provider double-charged and a support ticket is opened with them.
Two incidents that look unrelated and are reported weeks apart. Support cannot find the invoice referenced in the first, and in the second the invoices are correct and nobody was told.
The subscription table has is_active, is_trialing, is_paused, is_cancelled and cancel_at_period_end. Thirty-one of the thirty-two combinations are representable and about six are meaningful. The rows in question have both is_paused and is_cancelled set, and different reports apply different precedence.
A search for the provider's SDK finds it imported in nineteen files, including the subscription model, three reporting queries, the admin console and a scheduled job. Provider status strings are stored in our own columns and compared against string literals throughout.
Every estimate for the feature is met with "we cannot safely change that code". The team's proposal is to build the marketplace as a new, clean service and migrate the existing business onto it afterwards, and the slide says three months.
OrderManager cannot be constructed without a live database connection, a payment provider key and a static configuration singleton initialised at boot. There is no way to call the one method in question without all of it, so there is no way to write a test that fails before the change and passes after.
Any schema change breaks something outside this repository, and nobody has a complete list of what reads what. A proposed column rename is abandoned after a staging deploy takes the warehouse tool down. Meanwhile the same pricing rule is implemented independently in the admin console, which is how a marketplace order was recently discounted twice.
The estimate comes back much larger from the team that spent two years giving each rule its own module than it would have from the version of this system where everything happened inside one BillingService. Somebody says this out loud in the planning meeting and the room goes quiet, because it sounds like an argument against everything the team has been doing.
The change lands in almost every module. Pricing, tax, invoicing, credits, payments, reporting and the admin screens all hold amounts, and each of them has its own opinion about what an amount is: an integer of minor units in one place, a decimal in another, a float in an export that nobody wants to talk about.
The change requests
The real exam. For each one: what it really demands, the design that absorbs it locally, and the design it tears up. Two of them are genuinely cross-cutting — no boundary contains them, and that is stated rather than hidden.
renewsAt = lastCharged + 1 month, or where reports group by calendar month because that is what a period was. The interval assumption is not in one place; it is in every date calculation written by someone who knew all subscriptions were monthly, and those are not greppable — an off-by-one-month expression looks exactly like correct code.plan_id plus a quantity, with the price looked up at render time. That design regenerates last year's invoice with this year's price, and the failure is silent — the PDF looks perfectly plausible. Retrofitting the snapshot means backfilling historical values that may no longer be recoverable, which is why this is worth deciding before the first invoice is issued rather than after ten thousand of them.pending_plan_id to the subscription: the second approval requirement arrives (pause, cancellation, seat changes) and each adds another pending column, none of which compose.if (charge()) { invoice.paid = true }. The tear-up is not just the call site; it is every downstream assumption that paid means settled — access granted on payment, revenue recognised on payment, dunning cancelled on payment — each of which now needs to distinguish "we asked" from "the money arrived". A retry on top of a non-idempotent confirmation handler turns one duplicate webhook into a double-credited invoice.