Why state makes more sense when you model what accumulates and what changes it.

 

There is a quiet trap in how we model software: we love nouns.

User. Account. Invoice. Payment. Subscription. Queue. Balance. Credit. Inventory. Ticket. Job.

Nouns feel solid. They look good in diagrams. They turn into tables, classes, API resources, and tickets that sound like someone knows what they are doing. But many painful bugs are not really about the noun. They are about what accumulates.

An account balance is not just a thing. Depending on the domain, it may be the accumulated result of charges, credits, payments, refunds, disputes, reversals, fees, adjustments, and manual corrections performed under pressure because a customer was blocked and the dashboard was lying.

Inventory is not just a number. It is purchases, reservations, shipments, returns, losses, corrections, and timing.

A queue is not just a list. Its behavior depends on arrivals, service/completion, retries, dead-letter transitions, backpressure, and the slow discovery that your worker pool was designed for the happy path, which is adorable in retrospect.

If the model only captures the noun, it may look complete while missing the part that actually explains the behavior. Stocks and flows give that missing part a name.

The stock is not the flow

Systems thinking is the study of wholes: interdependence, feedback loops, delays, accumulation, emergence, and leverage points. Donella Meadows is the reference, especially Thinking in Systems. It is one of those books that makes simple ideas harder to ignore. I highly recommend her lesson about A Philosophical Look at System Dynamics.

One of the most useful ideas is the distinction between stocks and flows. A stock is something that accumulates. A flow changes the stock.

Water in a bathtub is a stock. Water flowing in through the faucet increases it. Water flowing out through the drain decreases it. That example is almost aggressively simple, which is why it works. Software has bathtubs everywhere.

An account balance is a stock. Charges, payments, refunds, credits, fees, and adjustments are flows.

Inventory is a stock. Purchases, reservations, shipments, cancellations, returns, and losses are flows.

A queue length is a stock. Incoming jobs increase it. Workers completing jobs decrease it. Retries may increase it again, because distributed systems enjoy comedy.

A customer’s available credit is a stock. Invoices consume it. Payments restore it. Disputes may freeze part of it. Manual overrides may pretend none of this happened, which is a separate problem and probably a Slack thread.

The distinction sounds obvious once stated. But many systems blur it constantly.

They store the current balance but not the authoritative movements that produced it, or they keep those movements scattered across logs, webhooks, service methods, and support tools. They model the subscription but not the transitions that change entitlement over time. They track the queue length but not the enqueue/dequeue events, retry paths, or arrival and service rates. They store inventory_count and then spend the next year discovering all the ways a count can be true and useless at the same time.

Snapshot thinking

A lot of software models are snapshots. They describe what the world looks like now:

class Account:
   id: UUID
   balance: Money
   status: AccountStatus

There is nothing wrong with this. Most systems need snapshots. Users want to know their current balance. Support wants to know whether the account is blocked. Finance wants reports that do not require replaying civilization from first principles every time someone opens a dashboard. The problem begins when the snapshot becomes the explanation.

A balance of €400 tells you where the account is. It does not tell you how it got there, whether it is moving in the right direction, whether a payment is pending, whether a chargeback is about to reverse part of it, whether the number is stale, or whether two flows raced and one of them lost. The current value is not the system. It is a frame from the movie. And sometimes the bug is in the movie.

This is the useful intuition behind event-centered designs. A chess board is the current state, but the game is the sequence of moves that produced it. If you only see the board, you may know where the pieces are. You do not know whether the position came from careful play, a blunder, an illegal move, or someone quietly putting a bishop back on the board when nobody was looking. Software state has the same problem. The snapshot may be necessary, but the movements explain it.

The bug is usually in the movement

This becomes painfully concrete in billing, inventory, and queues. A customer says their balance is wrong. The database says the balance is correct. Everyone is technically right, which is the least helpful kind of right.

Maybe an invoice was generated twice.

Maybe a payment arrived after the invoice was marked overdue.

Maybe cash was refunded but the corresponding receivable, credit memo, or revenue adjustment was not recorded correctly.

Maybe a credit note was applied to the wrong period.

Maybe usage events arrived late, were rated twice, or were corrected after the billing cycle closed.

Maybe the account balance was updated directly instead of through the ledger, which is how systems acquire ghosts.

In all of these cases, the entity may look fine. The Account exists. The Invoice exists. The Payment exists. The bug is in the flows between them: what changed, when it changed, what it changed from, what it changed to, and whether the accumulated result still makes sense.

The same thing happens outside finance. An inventory count can be wrong because a reservation expired without releasing stock. A queue can look healthy because the visible depth is low while retries are piling up somewhere else. The stock is the symptom. The flows are often the explanation.

This is why ledgers are so common in serious financial systems. Not because accountants enjoy making developers sad, although I cannot rule that out. A ledger is a way of treating movements as first-class records. Instead of only storing “the balance is 400,” you store the entries that explain the balance.

For a simple customer amount-due view, that might look like:

+1000 invoice issued / receivable created
-600 payment applied
+50 late fee assessed
-50 fee waived

In a stricter accounting system, those movements may be represented as double-entry postings across multiple accounts, not just pluses and minuses on one customer balance. The important design idea is the same: the current balance is explained by recorded movements, not overwritten as an unexplained fact. Now the balance is not just a field. It is a derived claim. That changes the kind of questions the system can answer.

Derived values are promises

For an accumulated stock, a derived value is a promise that the relevant flows add up. balance = 400 is not merely data. It says: if you look at the relevant movements, apply the rules correctly, respect ordering, handle reversals, ignore voided entries, choose the right pending-or-settled view, and avoid counting the same event twice, the result is 400.

That is a lot of meaning hiding in one number.

Sometimes you should store the number anyway. Recomputing everything on every request may be too slow. Some views need stable historical snapshots. Some reports need to preserve what was known at the time, not what later corrections revealed. Performance is real. Auditors are real. Time, regrettably, is real.

But when you store a derived value, you inherit a responsibility: you need to know which source of truth updates it, which flows can change it, which flows can reverse it, how freshness is represented, and what happens when the stored value disagrees with the movements underneath.

That disagreement is not an edge case. It is one of the central facts of stateful systems.

A cached balance and a ledger can diverge. An inventory count and a shipment log can diverge. A queue depth metric and the actual work backlog can diverge because the metric lags, samples one shard, excludes retrying jobs, or disagrees with the broker’s definition of “ready.” A user’s displayed entitlement and the billing system’s entitlement can diverge.

When that happens, the useful question is not only:

Which value is correct?

It is also:

Which flow failed to update the stock, and why did we not notice?

That is a systems question.

Delays make honest models look wrong

Stocks and flows also force you to care about time.

Not abstract time. Annoying time.

The kind where payment events arrive late. Usage records arrive after the invoice closes. A refund is requested today, processed tomorrow, settled next week, and reconciled whenever the provider and the bank finish exchanging little bureaucratic poems. A background job updates the aggregate every five minutes, except when the queue is backed up, which of course is when everyone is looking.

Delays are not implementation details. They change what the system can honestly say.

If a dashboard shows “current balance,” does that mean current as of the ledger? The provider webhook? The reconciliation run? The aggregation job? The user’s refresh? These are different claims.

Every displayed stock has an implicit “as of” timestamp. Sometimes it has more than one: business/effective time, event arrival time, processing time, settlement time, reconciliation time, and display refresh time. A payment can have an authorization time, capture time, provider event time, webhook arrival time, settlement time, and reconciliation time. Treating all of those as “the payment happened” is how temporal bugs hide.

A lot of confusion comes from presenting delayed stocks as if they were immediate facts. The number may not be wrong. It may be late. That is a different kind of problem.

A wrong number needs correction. A late number needs context, freshness, maybe a pending state, maybe a different promise in the UI. If you treat latency as incorrectness, you may build the wrong fix. If you treat incorrectness as latency, you may ship a very calm lie.

Flows need boundaries

Once you start seeing flows, another question appears:

What is allowed to change the stock?

This matters more than it sounds. If every part of the system can update balance, then the balance is not really a model. It is a shared editable opinion.

Maybe invoices increase accounts receivable. Payments decrease it. Refunds may decrease cash, restore credit, or reopen an amount owed depending on the view. Credits reduce it. Adjustments change it. Manual corrections adjust it, sometimes through a named adjustment flow and sometimes through a dangerous direct write. Imports backfill it. Migrations “temporarily” patch it. Tests create impossible states because the factory made it easy.

Each of these may be legitimate. The problem is not that many flows exist. The problem is when they are not named, bounded, or ordered.

A stock should have a known, controlled set of flows that can change it, even if those flows are implemented by multiple services or processes. Not necessarily one code path. Not necessarily one service. But a clear model of what kind of movement is happening.

The boundary should also define invariants: whether the stock may go negative, whether movements must be idempotent, whether duplicate events are rejected or collapsed, whether ordering matters, and which transitions are impossible.

For money, that often means ledger entries.

For inventory, reservations and movements.

For queues, enqueue, dequeue, retry, timeout, and dead-letter transitions.

For credits, grants, consumption, expiration, restoration, and manual adjustment.

The useful design move is to stop asking only “what fields does this entity have?” and start asking:

- What can increase this?
- What can decrease this?
- What can freeze it?
- What can move it from one bucket to another?
- What can correct it after the fact?
- What evidence do we keep when that happens?

That is usually where the model starts becoming honest.

Match the machinery to the consequence

There is an obvious trap here: deciding that every system needs a perfect event-sourced ledger for everything.

Please do not do that.

A shopping cart quantity does not always need the same machinery as a customer cash balance. A retry counter does not need a double-entry ledger. A cached unread-notification count can probably be rebuilt when it gets weird. The world contains many levels of seriousness, and your architecture should notice.

Also, keeping events is not the same as event sourcing. You can have an audit log without rebuilding state from it. You can have a ledger for financial movements while still using ordinary relational tables for current projections. You can record enough evidence to reconcile a stock without making every entity an event-sourced aggregate.

Event-driven architectures put actions and changes closer to the center of the design, which can be a very good instinct. But an event-driven system does not automatically mean every state can be reproduced from events alone. That stronger claim belongs to event sourcing, and it only works if the events are complete, ordered enough for the question being asked, durable, and semantically stable.

The point is not to make every change permanent, auditable, reversible, and queryable until the heat death of the universe.

The point is to match the flow model to the consequence of being wrong.

If a stock affects money, customer access, compliance, inventory, contractual entitlement, or human trust, the flows probably deserve more explicit treatment.

If a stock is cheap to recompute and wrong values are merely annoying, maybe a cache plus a repair job is fine.

If a flow changes business meaning, it probably deserves a name.

If a flow only changes display convenience, maybe it does not.

The right question is not how pure the model is. It is what happens when the model is wrong.

Agents need the movement model too

AI coding agents are especially vulnerable to snapshot thinking.

They see the class. They see the fields. They see the API shape. They may not see the operational history that explains why the fields are dangerous.

If the codebase exposes Account.balance as a writable field, an agent may treat it as a value to update. If the real rule is “balances are derived from ledger entries except during migration repair windows approved by finance,” that rule had better exist somewhere the agent can see. Otherwise the agent will do the local, plausible thing.

Documentation helps, but boundaries help more. If balances must be derived from ledger entries, expose APIs that create entries and make direct writes difficult, detectable, or impossible.

And the local, plausible thing is often exactly how the flow model gets broken.

This is not unique to AI. Humans do it too. But agents make it easier to apply the wrong local pattern quickly and consistently. They are very good at continuing the shape of the code in front of them. If that shape hides the flows, the agent is likely to preserve the same abstraction leak: updating fields directly, copying existing unsafe patterns, or adding another special case outside the movement model.

For agent-assisted work, the useful context is not only entity definitions. It is also movement definitions.

Useful context includes questions like:

- What creates balance?
- What consumes credit?
- What transitions inventory from available to reserved to shipped?
- What events are authoritative?
- What derived values must never be written directly?
- What reconciliation process decides when the system and the outside world disagree?

If those answers are not in the context, the agent is not missing trivia. It is missing the system’s physics: what can be created, consumed, reserved, reversed, expired, or reconciled.

The design question

Stocks and flows give software design a practical question:

What is accumulating here, and what changes it?

The less comfortable version:

Which numbers in this system look like facts but are really the result of flows we have not modeled?

Ask it about balances. Ask it about inventory. Ask it about queues. Ask it about credits. Ask it about quotas, rate limits, usage counters, retry counts, entitlements, and “remaining seats” in a plan. Then ask where the movements are recorded.

Are they first-class facts? Are they events? Are they logs? Are they mutable fields? Are they hidden inside service methods? Are they reconstructed from provider webhooks? Are they in a spreadsheet that has quietly become part of the production system?

You do not need the same answer everywhere. But you should know the answer.

Model the movement

A static model can tell you what exists. It cannot always tell you what is accumulating, what is draining, what is delayed, or why the current state is what it is. That is the systems-thinking lesson: the entity is rarely the whole story. The stock matters, but so do the flows that created it. If your model captures the noun but not the movement, it may describe the system’s shape while missing its behavior. And in stateful systems, behavior is where the expensive surprises live.