Stories · Season 3 — Magpie · Episode 01

The Account That Went Negative

In which a savings account holds minus £37, the rules said that was impossible, and the rules were written in the wrong place.

The story

Magpie was a money app — the friendly kind, with a round-up feature and a little bird that congratulated you for saving. So it was a bad day when a customer screenshotted their savings balance showing −£37.12 and posted it with the caption “my bank invented debt for me.”

Kemi, Magpie’s lead engineer, was baffled, because the rule was obvious: a savings balance can’t go below zero. Everyone knew that. The trouble was that “everyone knew it” wasn’t the same as “the code enforced it.” The check lived in the mobile app’s UI — grey out the button if the withdrawal is too big. It lived in the web form’s validation. It lived in a comment that said // must be >= 0. It lived everywhere except the one place that mattered: the thing that actually changed the balance.

So when a delayed transaction and a round-up landed in the wrong order, the balance was updated directly — balance = balance - amount — with nothing standing in the way. The object that represented an account was just a bag of properties anyone could set to anything. The “account” didn’t protect itself; it trusted every caller to remember the rules. One caller forgot.

“We don’t have an account,” Kemi realized. “We have a row, and a lot of good intentions wrapped around it.”

Why this is hard the traditional way

Most systems model their core concepts as anemic data holders: a class with public setters, or a database row, and all the rules about what’s valid live outside it — in controllers, services, UI, and validators scattered across the codebase. This works right up until two of those places disagree, or a new code path forgets one. The data can be put into a state the business says is impossible, because nothing structurally prevents it.

And the rules multiply. “Balance ≥ 0,” “a closed account can’t transact,” “a transfer needs both a source and a destination.” Each is enforced in several places and guaranteed in none. The invariant you’re sure holds is exactly the one a customer will violate and screenshot.

How Relay changes the ending

Relay models core concepts as aggregates: objects that own their data and expose behavior, not setters. You don’t set the balance — you ask the account to Withdraw(...), and the account itself enforces the rule, in one place, on every path. Invalid states become unrepresentable.

public void Withdraw(Money amount)
{
    Guard.Against.Negative(amount);
    if (amount > _balance)
        throw new DomainException("Insufficient funds.");   // the rule lives HERE
    Apply(new FundsWithdrawn(amount));
}
// There is no public setter for balance. The only way down is through this door.

Now it doesn’t matter how many UIs, APIs, or background jobs touch the account — they all go through the same door, and the door is locked against impossible states. The rule is enforced by the type, not by the discipline of every developer who ever calls it. Magpie’s little bird never has to apologize for inventing debt again.

What it costs you to ignore this

  • Impossible states happen anyway. “That can’t occur” is a promise the data will break the moment one path forgets the rule.
  • Rules rot by duplication. The same invariant in five places drifts out of sync, and the gaps are invisible until a customer finds them.
  • Bugs become trust problems. A wrong balance isn’t just a defect; in money, health, or logistics it’s a reason to leave.
  • Every new feature is a new risk. Without a single enforcement point, the next developer is one forgotten check away from the next screenshot.