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.

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.

Users

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.

Organizations

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.

Subscriptions

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.

Plans

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.

Invoices

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.

Payments

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.

Credits

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

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.

Usage billing

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.

Email notifications

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.

Audit logs

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.

External payment providers

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

    What must never stop being true about this system, stated about the data rather than about any code path?

  2. 2

    What responsibilities exist here, and what is each one's single reason to change?

  3. 3

    Which concepts deserve a type of their own rather than a primitive?

  4. 4

    Where do the module boundaries fall, and what test says they are in the right place?

  5. 5

    Which dependencies are volatile, and what does that force?

  6. 6

    Which state transitions exist, and — the part that matters — which must not?

  7. 7

    Which errors are expected outcomes and which are failures, and how does the difference show up in the code?

  8. 8

    Which interfaces are stable enough to depend on, and which are still moving?

  9. 9

    What needs migration, and what is true during it?

  10. 10

    What is tested at which boundary, and what does each fake stop the suite from detecting?

  11. 11

    Who owns each piece of data, and who is allowed to write it?

  12. 12

    Where does time come from, and which time is the system talking about?

  13. 13

    What are you deliberately not building, and what would make you change your mind?

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

A product manager asks for one word to change in the renewal email. The estimate comes back at two days.
How it presents

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.

A customer is quoted one price at checkout and charged another. It is not a rounding difference.
How it presents

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.

The payment provider replays a webhook after a timeout. Several customers are charged twice, and one invoice is marked settled twice, driving its balance negative.
How it presents

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.

Customers receive invoice emails for invoices that do not exist, and separately, a batch of invoices is issued with no email sent at all.
How it presents

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.

A report shows subscriptions that are simultaneously cancelled and paused. Finance asks which number is real.
How it presents

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.

The company signs a second payment provider for a new market. The estimate for supporting it is a quarter.
How it presents

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.

§249. You inherit the twelve-year-old order and billing monolith. Sales has sold marketplace sellers — third parties listing and being paid out — for the end of the quarter. There are no tests, `OrderManager` is nine thousand lines, and pricing rules appear in at least four places.
How it presents

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.

§249. The first real change: marketplace orders need a different commission calculation, and the code for it is inside `OrderManager.calculateTotals()`, a four-hundred-line method.
How it presents

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.

§249. Seller payouts need new tables and a new order shape. Three other applications — a warehouse tool, a reporting stack and an internal admin console — read and write the `orders` table directly, and two of them are maintained by other teams.
How it presents

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.

Compliance arrives with a requirement that was not in the brief and is not negotiable: every change to any financial record must be recorded with who did it, when, what the value was before and after, and under whose authority. It applies to invoices, subscriptions, credits, discounts, refunds, plan definitions and tax settings.
How it presents

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 company sells into a second country. Every amount in the system — prices, line totals, taxes, credits, refunds, payouts, thresholds, report columns — must now carry a currency, and no arithmetic may combine two of them.
How it presents

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.

Accept a second payment provider, because the current one does not support the markets we are expanding into.
What it really demands
A dependency the whole codebase assumed was singular becomes plural, and — the harder half — the two providers do not agree on what a payment is. One confirms synchronously and one confirms by webhook minutes later; one refunds partially, one does not; their identifiers, error codes and retry semantics are entirely different. The request is not "add an integration", it is "define what a payment means to us, independently of anyone's SDK".
The design that absorbs it locally
A payment port defined by what billing needs — charge this amount against this method, tell me the outcome, refund this charge — with each provider behind its own adapter that translates vocabulary in both directions. The domain never sees a provider object. Adding the second provider is a new adapter, a routing rule for which provider serves which market, and no change to pricing, invoicing or the subscription lifecycle.
The design it tears up
Any design where the provider SDK is called from the code that decides things. If provider objects and status strings have spread into the subscription, the invoice and the reporting queries, the second provider is not an integration but a rewrite of everything that learned to speak the first one's language — and the two vocabularies must then be reconciled in every one of those places rather than in one adapter.
Some plans should be billed by usage — API calls, seats, gigabytes — rather than a flat monthly fee.
What it really demands
Price stops being a property of the plan and becomes a function of measured events over a period. That drags in three things the request does not mention: a metering pipeline that is a different system with different reliability properties, a decision about what to do with usage events that arrive after the invoice is issued, and the fact that the amount is now unknown until the period closes — which every part of the system that displays or forecasts a price currently assumes it is not.
The design that absorbs it locally
A pricing module that already answers "what does this subscription cost for this period" as a computation rather than a lookup, so a usage-based plan is another implementation of the same question. Metering stays a separate module with its own storage and its own idempotency, and hands billing an aggregate for a closed period — one directional dependency, no shared tables.
The design it tears up
Any design where the price is read from a column on the plan. The assumption that a price is a stored value rather than a computed one is usually invisible until this request, and it is baked into the invoice preview, the checkout, the dunning email, the revenue report and the admin screens. Each of those must now learn that some prices are not knowable yet, which is a change to the meaning of a field rather than to its value.
Offer annual plans at a discount, and let customers switch between monthly and annual.
What it really demands
The billing period stops being a constant. Everything that said "next month" — the renewal job, the revenue report, the proration, the trial-to-paid conversion, the dunning schedule — was quietly encoding a one-month assumption, and the switch case is worse than the new plans: moving to annual mid-month means crediting the unused part of a paid month and charging a year from a date that is not the anniversary of anything.
The design that absorbs it locally
Modelling the billing period as an explicit interval with a start, an end and a term, and expressing renewal, proration and reporting as arithmetic over intervals. A twelve-month term is then a different value in an existing model rather than a new branch. The switch becomes: close the current interval early, credit its unused remainder as a stated amount, open the new one.
The design it tears up
Any design where the period is implied by 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.
Finance needs to issue credit notes when we overcharge, instead of editing the invoice.
What it really demands
An invoice becomes immutable at issue, and every correction becomes a new document that references it. This is not a preference — once an invoice has been sent to a customer and reported to an accountant, changing it makes two versions of a legal record exist. The demand is that the system stop treating invoices as rows to update and start treating them as facts to append to.
The design that absorbs it locally
Invoices that are already written once and never edited, with the amount owed derived from the sequence of documents against a subscription rather than stored on the invoice as a mutable balance. A credit note is then a new document type and a new line in the same derivation; the balance query gains a term.
The design it tears up
Any design where correcting a bill means an UPDATE. Beyond the accounting problem, everything downstream that captured the invoice total — the revenue report, the exported ledger, the emailed PDF, the payment provider's record of what was charged — now disagrees with the database, and there is no record of which version each of them saw. The tear-up is not the credit note feature; it is retrofitting immutability to a table that has been edited for years.
Calculate and apply the correct tax for every customer, in every jurisdiction we sell into.
What it really demands
Tax is not a percentage. It depends on where the seller is, where the buyer is, what evidence of that location we hold, whether the buyer is a business with a valid registration number, what kind of product it is, and what the rules were on the date of supply — which change without warning and are not retroactive. In practice this means adopting an external tax engine, and therefore inheriting a dependency that is authoritative, expensive per call, occasionally unavailable, and has its own model of a customer and a product.
The design that absorbs it locally
A tax module that owns the concept and an anti-corruption layer between it and the vendor, so the vendor's categories are translated into ours at one boundary. The tax decision for an invoice is recorded on the invoice as a fact — rate, jurisdiction, the evidence used, the engine's decision id — so a re-computation years later is not required and a rate change tomorrow does not silently reprice yesterday.
The design it tears up
This one breaks more in a separated design than in a tangled one, and it is worth being honest about why. "Every amount now has a tax treatment" touches pricing, invoicing, credits, reporting, the checkout preview and the provider integration — every module that handles money — because it is a property of amounts rather than a piece of knowledge with one home. A single tangled service adds it in one place. Separation spreads the edit; what it buys back is that the *rules* have one address, so the next rate change is one edit instead of nine. It is a change the boundaries did not anticipate, and no amount of good structure makes an unanticipated cross-cutting requirement local.
Generate a PDF invoice that customers can download, and email it when the invoice is issued.
What it really demands
The document must be reproducible identically for as long as it is legally required to exist — typically years. Regenerating it from live data will not do that: the plan will have been renamed, the price changed, the company address moved, the tax rate updated, and the customer's name corrected. What is really being asked for is that an invoice carry the values it was rendered from, frozen, rather than references to things that keep changing.
The design that absorbs it locally
Invoice lines that are already self-contained values — description, quantity, unit amount, currency, tax treatment, all captured at issue — rather than foreign keys to plans and products. The renderer becomes a pure function from a stored invoice to a document, testable without a database and safe to change without touching billing, because it consumes a snapshot rather than the current world.
The design it tears up
Any design where the invoice line is a pointer: 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.
Enterprise customers want plan changes to require approval by a named administrator before they take effect.
What it really demands
A subscription change stops being an operation and becomes a small workflow with its own state — proposed, approved, rejected, expired — an actor who is not the person requesting it, and a window during which the intended change exists but has not happened. It also means a proposal can be approved after the world it was proposed against has moved: the plan may have been retired, the price changed, or the subscription cancelled.
The design that absorbs it locally
A lifecycle that is already explicit, so a pending change is a new state with guarded transitions rather than a flag, and a rule that a proposal is validated again at approval rather than trusted from when it was raised. Approval policy lives in its own module keyed by organisation, so only enterprise organisations pay for it and the subscription does not learn about approval chains.
The design it tears up
Any design where "change the plan" is a single method that mutates and returns. The change now has to be representable *before* it happens, which means the intent must be storable — and a codebase that only ever expressed changes as function calls has nowhere to put one. Worse is the design that adds 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.
Support payment methods that confirm later — bank transfers and direct debits that settle in days, not seconds.
What it really demands
Every synchronous return value in the payment path was a lie that a card provider let us get away with. "Charge, get a result, mark the invoice paid" becomes "charge, get an acknowledgement, wait, receive an outcome that may be success, failure, or a reversal weeks after success". The subscription lifecycle needs a state for money that is in flight, and the confirmation arrives on a path we do not control and cannot assume runs once.
The design that absorbs it locally
A payment port whose result is already a stated outcome rather than a boolean, plus a payments module that owns an explicit lifecycle — initiated, pending, settled, failed, reversed — and applies confirmations by idempotency key. The invoice asks the payments module whether it is paid rather than storing a flag someone else must remember to set.
The design it tears up
Any design where 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.
Sell in local currencies rather than billing everyone in one.
What it really demands
An amount stops being a number and becomes a pair of amount-and-currency, and arithmetic on amounts stops being total: adding two amounts is only defined when the currencies match. The second half is that reporting needs one currency, so a conversion rate becomes part of the record — and which rate, from which date, is a business rule rather than a lookup, because the rate on the invoice date, the payment date and the report date all differ.
The design that absorbs it locally
A Money value object introduced before it was needed, which is the clearest case in this domain of a boundary anticipating a change nobody asked for yet: currency handling has one address, so the request lands there plus the modules that genuinely make currency decisions — pricing and payments. Reports store the converted amount and the rate used, so history does not move when rates do.
The design it tears up
Any design where amounts are bare integers or floats. The change then touches every module that holds a number, and — the expensive part — there is no way to find them, because a currency-less amount looks exactly like a correct amount. The bugs are silent additions of unlike currencies that produce a plausible total, and they surface as reconciliation differences months later.
When a customer asks to be deleted, delete them. Legal has committed to a thirty-day window.
What it really demands
Two obligations that contradict each other. Financial records must be retained and must not be altered; personal data must be erased on request. The resolvable version is that they are not the same data — an invoice needs an amount, a date, a jurisdiction and a stable customer reference, but not a name, an email or an address. So the request really demands that identity and financial record were separated in the first place, and that every copy of personal data has a known owner and a reachable delete path: the search index, the analytics warehouse, the CRM, the email provider, the support tool, the backups, and every log line where someone helpfully logged the whole customer object.
The design that absorbs it locally
A design where personal data lives in one module behind an interface, referenced everywhere else by an opaque customer id, so erasure is a delete in one place plus a documented list of exports. Nothing is truly absorbed cheaply here, but the difference between a half-day of coordination and a quarter-long project is whether anyone ever decided where personal data was allowed to live.
The design it tears up
Everything that copied a name or an email for convenience — denormalised onto invoices "so the PDF is easy", into event payloads, into the analytics pipeline, into logs with a retention policy nobody owns. Like the tax change, this is cross-cutting by construction: no boundary contains it, because it is a statement about all of them, and a well-separated system has more places holding a reference than a tangled one has. What structure buys is not fewer places — it is that the places are enumerable. The design that cannot answer "where does personal data live" answers the regulator with a grep.

Where to practise the pieces