Skip to content
Back to all writing

Four Decisions I Made Before Writing Any Features

Four architecture decisions that kept an enterprise expense system auditable: claim boundaries, immutable history, identity, and concurrent writes.

A dark foundation with four blue-lit stations for grouped claims, immutable versions, administrator-controlled identity, and coordinated concurrent writes

I spent the first stretch of an enterprise expense management build not writing features.

The company had an existing system for employees to claim travel and expenses, and it was being replaced. Money goes through it, so it gets audited. That combination — real money, real audits, real employees who will be annoyed if it breaks — is the kind of project that punishes you for building in the wrong order.

The replacement is now built and deployed internally. To keep this account useful without exposing company-specific policy, I use the weekly claim path as the running example and simplify some implementation detail. The part worth writing about isn't the features. It's that four decisions made before the schema was settled are the reason the features went in quickly afterwards.

All four share a property: they are cheap to decide early and brutally expensive to change later. Get them wrong and you don't lose a day; you lose the week where you rewrite everything built on top of them.

First, don't copy the old system's screens

The obvious way to replace an internal system is to open it, look at every screen, and rebuild what you see.

It's a trap. Screens in a system that's been running for years contain two things mixed together: the actual business rules, and a decade of workarounds for problems that no longer exist. A field exists because of a policy change in 2019. Two buttons do nearly the same thing because a team once asked for an extra option and nobody later removed it. A step happens in a strange order because the original developer hit a constraint that isn't there anymore.

Copy the screens and you copy all of it, permanently, without ever knowing which parts were deliberate.

What I did instead was work backwards: for each screen, what rule is this actually enforcing? Some answers were real constraints worth keeping — an expense can't be claimed twice, a claim can't be edited after approval, certain categories need a receipt. Others turned out to be accidents nobody could justify when asked.

That's slower on the first day and much faster by the second week, because you end up building the rules rather than the interface that used to express them.

With those rules written down, four decisions followed.

Decision 1: What counts as "one thing"?

This sounds like a philosophy question. It's the most practical decision in the whole project.

An employee's expenses could be modelled at two different boundaries. Either each individual expense owns its own lifecycle and a week is just a way of listing them, or the weekly claim owns the lifecycle and the individual expenses live inside it.

I made the claim the unit. More precisely, it is the aggregate: the boundary the system validates, submits, reviews and versions as one operation. In the weekly path, that aggregate represents one week's claim. The expenses can still live in separate database rows; they simply don't have independent approval lifecycles.

That means everything important attaches to the claim: the total, the status, the approval, the version history and the audit trail. The individual expenses are components within it — they matter, but they aren't independently approvable objects.

Here's why this matters more than it sounds. A reviewer approves a week, not an expense. If each expense were the unit, then "approve this week" means updating many separate records at once, and you now have to answer awkward questions: what if six succeed and one fails? Is a week with four approved expenses and two pending in some half-approved state? What does the total mean while that's happening?

Every one of those questions disappears when the claim is the approval boundary. Approval is one state transition on the claim, committed atomically with its history. There is no half-approved week because the individual expenses do not carry separate approval states.

Pick this wrong and you don't find out immediately. You find out when a business rule expects one coherent approval but the schema has given each line an independent lifecycle. By then the fix is not a query tweak; it is a schema and API change underneath everything you've already built.

The general version: work out what the business treats as a single unit of decision, and make that the boundary the system validates and changes atomically. If a human approves it as one thing, the data model should preserve it as one coherent thing.

Decision 2: Never overwrite anything someone might have to justify later

When a claim gets submitted or approved, the natural instinct is to keep updating the same record — set the status, stamp the date, move on.

Don't overwrite the facts that were reviewed. Freeze an immutable submitted version, record the decision against that exact version and keep the earlier versions. You can still maintain a current status or pointer for efficient reads; it just must not erase the evidence behind it.

Money systems get audited, and audits ask questions about the past. Not "what does this claim say now" but "what did it say when it was approved, and who approved that exact thing?" If approval overwrote the record, that question has no answer. You can see the current state and you cannot prove what was agreed to.

There's a second half to this that's easier to miss. It isn't only the claim that changes over time — the rules change too. Mileage rates get updated. Category limits change. A policy that applied in March doesn't apply in September.

So policy configuration is stored with the dates it was valid for, rather than as a single current value that gets edited. When you need to know why a claim was calculated the way it was, you look up which rules were in force on that date and get the actual answer, not today's answer applied retrospectively.

Both are much cheaper to design in while the database is still taking shape. Add them afterwards and you may already have months of overwritten records and history you simply cannot reconstruct.

The general version: if a record might need defending months later, preserve immutable versions or append-only events for it. Anything that influenced the decision needs a validity period rather than only a current value.

Decision 3: Accounts are created by an administrator, not by signing up

Many authentication libraries and their starter examples are shaped around public products. Anyone can register. Anyone can request a password reset. There is a signup page, an email verification flow and a set of defaults built around strangers arriving on the internet and creating accounts.

None of that describes a company expense system. Employees don't sign up for it. An administrator provisions access because they work there. When they leave, that access is deactivated, but their identity and history remain so old approvals still make sense.

So I used the library for what it's genuinely good at — password hashing, session handling, the security-sensitive mechanics you should not write yourself — and kept everything else in the application. Who this person is. Whether they're an employee. What they're allowed to see. Who can approve their claims. Whether their account should exist at all.

That boundary matters because if you let the library own identity, you inherit its assumptions. Mount the stock routes without narrowing them and public registration or self-service recovery can become part of the product by accident. The library's idea of a user gradually becomes your idea of a user, which is a problem the first time the business needs something the library never contemplated — like an employee whose approvals must transfer to someone else when they leave.

Accounts are created deliberately, activated through a time-limited link sent to the employee's work email, and the domain model owns the meaning of all of it.

The general version: frameworks give you mechanics, not your business's model of a person. Decide which is which on day one, because that line only gets harder to draw later.

Decision 4: Decide what happens when things go wrong at the same time

These are three related problems, each cheaper to design in while the schema and command boundaries are still moving, and each painful to retrofit.

Someone double-clicks submit. The browser sends two requests. Without protection you can create duplicate versions or repeat downstream side effects, and now a human has to work out which result is real. The fix is an idempotency key tied to the employee and the request: repeating the same submission returns the same result, while reusing the key for different content is rejected. One outcome, regardless of how many times the button was pressed.

That is the local version of the timeout ambiguity I explored in A Timeout Doesn't Tell You Whether the Write Happened.

Two reviewers open the same claim. Both look at it and both try to decide it. Without a concurrency check, the second action can be recorded against a state that no longer exists — duplicating the first decision or contradicting it. The fix is that each decision names the version it was based on. If the claim or review task changed since you loaded it, your action is rejected and you're told why instead of quietly winning.

A submission has to do several things at once. Submitting a claim writes the immutable claim version, an audit entry recording who did it and when, and an outbox entry that queues the review notification. Those database writes must all succeed or all fail together. If the claim saves and the audit entry doesn't, you have a record with no explanation of how it got there — precisely the thing an audit is looking for. So they are written in a single transaction that the application owns explicitly. The email itself is sent afterwards; an external mail service is never held inside the database transaction.

None of these are exotic. All three can be added later only by revisiting the relevant write paths in the system.

The general version: decide how duplicate requests, simultaneous edits and multi-part writes behave before launch, not after the first incident.

The test that decides whether the core path works

Automated tests were passing. That is not the same as the system working, and I don't treat it as the same claim.

The real check happens in a disposable environment: wipe everything there. Empty database, nothing seeded, nothing warm. Then have a person do the whole job in a browser.

As an administrator, create an employee account. Have that employee receive the activation email, set their password, and log in. Create a weekly claim. Add expenses to it. Submit it. Watch it land in a review state with its version recorded and its history intact. Then reload the page and confirm it's all still there — because reading it back from the database proves the data exists, and reloading proves the product works.

If that journey completes from a cold start, the core path works end to end. It still does not prove every policy edge case or failure mode, but it closes the gap between tested code and a usable product. If the journey fails, the green test suite cannot make the product ready.

I'd argue this is the single most useful habit on this list. Automated tests confirm the code does what its tests describe. Only a person completing the actual task confirms a person can complete the actual task, and those are genuinely different statements.

When this is the wrong approach

All of the above costs time up front, and it isn't always worth paying.

If you're building something where records don't need defending later, where nobody audits the history, where a duplicate submission is a shrug rather than a finance problem — this is over-engineering. A straightforward create-read-update-delete application may not need immutable versions or effective-dated configuration, and adding them can buy complexity for no practical benefit.

The trade is worth it when three things are true: the data represents money or obligations, someone will ask questions about the past, and more than one person can touch the same record. Expenses hit all three. Plenty of internal tools hit none.

The pattern

Every decision here has the same shape. Each one is a choice about the structure of the system rather than its features. Each one is nearly free while the schema is still being written. And each one, if you get it wrong, is fixed only by changing the foundation underneath everything you've built on top.

That is why front-loading them isn't slower. The time you spend deciding what counts as one business aggregate, what can never be overwritten, who owns identity and how concurrent writes behave is time you would otherwise spend rewriting — except later, under pressure, with real data in the system and people already using it.

Features are fast when the decisions underneath them have stopped moving.