A loan draws €10,000, accrues €80 interest, receives €500, incurs a €20 fee, receives €1,000 and then reverses that €1,000. What, exactly, is the account state at 10 March 18:00?
The answer must not depend on the latest mutable snapshot, a spreadsheet adjustment or an opaque balance column. It should emerge from a complete, ordered and validated history plus explicit transition rules.
The event stream explains how; state answers what
The ordered sequence of economically meaningful changes and corrections.
The result of reducing those events through a versioned business transition function.
The aggregate is the consistency boundary. It may be a loan facility, revolving facility or account depending on which events must update one economic balance atomically. Customer-level aggregation is not a default: a boundary that is too small splits one state; one that is too broad creates contention and unrelated complexity.
type LoanState = {
facilityId: string;
principalMinor: bigint;
accruedInterestMinor: bigint;
feeBalanceMinor: bigint;
unappliedCashMinor: bigint;
totalOutstandingMinor: bigint;
arrearsMinor: bigint;
daysPastDue: number;
availableLimitMinor?: bigint;
version: number;
lastEventTime?: Date;
};| Class | Examples | Rule |
|---|---|---|
| Primitive / direct | Principal, accrued interest, fees, unapplied cash | Changed by explicit event effects |
| Derived | Total outstanding, availability, utilisation, DPD | Calculated from governed inputs |
| Cached derived | Persisted total or projection for query speed | Rebuildable and checked against source components |
Avoid duplicated state unless performance justifies it. If a total is cached, central calculation and invariants must prevent it drifting from its components.
The reducer is a pure, versioned state transition
function reduceLoanState(
state: LoanState,
event: FinancialEvent
): LoanState {
switch (event.eventType) {
case "DRAWDOWN":
return recalculate({
...state,
principalMinor: state.principalMinor + event.amountMinor,
version: state.version + 1
});
case "INTEREST_ACCRUED":
return recalculate({
...state,
accruedInterestMinor:
state.accruedInterestMinor + event.amountMinor,
version: state.version + 1
});
case "FEE_CHARGED":
return recalculate({
...state,
feeBalanceMinor: state.feeBalanceMinor + event.amountMinor,
version: state.version + 1
});
default:
return state;
}
}function recalculate(state: LoanState): LoanState {
return {
...state,
totalOutstandingMinor:
state.principalMinor +
state.accruedInterestMinor +
state.feeBalanceMinor
};
}The reducer validates applicability, applies the economic effect and returns new state. It does not send messages, write databases or call APIs. Central recalculation prevents each handler from inventing its own balance formula and makes local, CI and recovery replay deterministic.
A payment is an input to allocation—not one subtraction
type PaymentAllocation = {
paymentEventId: string;
amountMinor: bigint;
feesAppliedMinor: bigint;
interestAppliedMinor: bigint;
principalAppliedMinor: bigint;
unappliedMinor: bigint;
allocationRuleVersion: string;
};A €500 payment might settle fees, then interest, then principal—or follow a different contractual hierarchy. This article does not prescribe one. It requires an explicit result that answers “where did the money go?” for servicing, accounting, delinquency, reconciliation and disputes.
Unapplied cash is legitimate state when receipt exists but allocation cannot yet complete. Do not force a fictitious balance mutation. Where appropriate, distinguish PAYMENT_SETTLED from PAYMENT_APPLIED.
Allocation rules are versioned business logic. Reproducing production state uses the version that actually ran; changing policy later does not silently rewrite history. A separately authorised restatement may calculate Staterecalculated while preserving Stateactual.
Financial components reconstruct explicitly
These are illustrative compositions, not universal product rules. Their value is that every total can be traced to components, events and allocation outputs. A write-off should therefore be an explicit economic/accounting event—not balance = 0—because contractual balance, accounting write-off and recoverable amount may diverge. Recovery can occur after write-off.
Interest may be explicit INTEREST_ACCRUED events or computed from rate, day-count and period state. Events improve audit lineage; computation can reduce volume. Either approach must retain enough versioned inputs to reproduce the number.
Outstanding balance is not the whole exposure
type RevolvingFacilityState = {
drawnMinor: bigint;
limitMinor: bigint;
blockedLimitMinor: bigint;
pendingDrawMinor: bigint;
availableMinor: bigint;
utilisation: number;
};Revolving exposure can depend on undrawn commitments, pending drawdowns, guarantees and contingencies as well as outstanding balance. Availability must not be calculated from one stale field. Prefer an explicit desired-state event such as LIMIT_SET 5000 over an ambiguous incremental change where the business semantics allow it.
A limit may be known today and effective tomorrow. Point-in-time Exposure(T) therefore applies effective time and the known/restated mode described in Event Time vs Processing Time vs Posting Time.
DPD is derived from obligations, allocation and time
type Instalment = {
scheduleVersion: string;
dueDate: string;
amountDueMinor: bigint;
appliedMinor: bigint;
adjustmentMinor: bigint;
};DPD may then measure from that date using approved calendar and business-day rules. This is a conceptual pattern, not a universal delinquency policy. A payment applied to the newest instalment may leave the oldest arrears unchanged; payment amount alone cannot prove cure.
A payment reversal can move an account from current back to delinquent. The reducer must propagate the correction through balance, allocation, arrears, DPD and downstream behavioural state while retaining the original and reversal events.
Monetary state and lifecycle state are separate
The reducer applies event effects; a state machine validates lifecycle transitions. CLOSED → ACTIVE may be invalid without an explicit reopen event. Yet zero balance does not necessarily close an account, and write-off can leave accounting or recovery artefacts. One enum cannot replace monetary state.
Where strict sequencing is required, apply only when expected version equals current version. Persist schemaVersion, reducerVersion, allocationVersion and scheduleLogicVersion where material to reproduction.
A snapshot accelerates history; it does not replace it
type StateSnapshot = {
aggregateId: string;
version: number;
reducerVersion: string;
state: LoanState;
stateHash: string;
createdAt: Date;
};Frequent snapshots recover faster but cost storage and management; sparse snapshots replay longer. Each snapshot must correspond exactly to aggregate version k. Manual mutation without event lineage makes history irreproducible.
Late events and reducer upgrades create explicit replay choices
- Late event
- Determine effective position
- Locate prior safe snapshot
- Replay
- Produce restated state
- Recompute dependants
A late event effective before the latest snapshot can invalidate that snapshot. The state engine finds a safe boundary and rebuilds affected projections while retaining Sknown(T) and Srestated(T). Restatement supports a counterfactual decision but never overwrites Decisionactual.
| Need | Logic | Result |
|---|---|---|
| Production forward fix | New reducer for future events | Original history remains reproducible |
| Historical restatement | Replay old streams with corrected reducer | Separate corrected state and impact evidence |
| Incident comparison | Retain original and corrected logic | Quantify state and decision difference |
A restructure similarly changes schedule, rate, principal or maturity through explicit effective-dated events. Never rewrite the previous schedule silently; DPD reconstruction needs the schedule version applicable at T.
Deterministic state does not require replacing the core ledger
A practical bank architecture can anchor to controlled core snapshots, apply canonical events between checkpoints and reconcile periodically. The state layer can serve risk, decisioning and collections without recreating the general ledger. Non-bank lenders can use the same pattern across fragmented PSP, servicing, collections and risk platforms.
| Difference | Meaning | Response |
|---|---|---|
| Timing | Expected cut-off or posting difference | Age and clear at agreed checkpoint |
| Allocation | Rules or hierarchy differ | Compare versioned allocation traces |
| Missing event | Integrity gap | Trace ingestion and replay |
| Duplicate event | Integrity gap | Verify idempotency and reverse impact if needed |
| Source correction | Authoritative fact changed | Restate from controlled boundary |
Use exact monetary reconciliation where required; rounding tolerance for one analytical metric must not become a blanket financial tolerance.
Correctness is a set of properties, not one expected balance
Apply only invariants that are economically valid for the product. Property-based tests can generate valid drawdown, payment, fee, reversal and limit-change sequences and assert conservation after every event without adding a new dependency to production.
| Test | Proof |
|---|---|
| Replay checkpoints | Replay(E₁:ₖ) equals expected state k |
| Duplicate injection | Idempotent preprocessing leaves final state unchanged |
| Ordering | Causal swaps are reordered, rejected or quarantined |
| Reversal | Monetary effect returns correctly; history remains |
| Late event | Known and restated states remain queryable |
| Snapshot parity | Full replay equals snapshot plus tail |
A golden account stream makes the arithmetic inspectable
Assume an illustrative allocation order of fees, interest, then principal. The €500 payment on 5 February clears €80 interest and reduces principal by €420. The later €1,000 payment reduces principal, then its full reversal restores principal.
| Version | Event | Principal | Interest | Fees | Outstanding |
|---|---|---|---|---|---|
| 1 | Drawdown €10,000 | €10,000 | €0 | €0 | €10,000 |
| 2 | Interest €80 | €10,000 | €80 | €0 | €10,080 |
| 3 | Payment €500 | €9,580 | €0 | €0 | €9,580 |
| 4 | Fee €20 | €9,580 | €0 | €20 | €9,600 |
| 5 | Payment €1,000 | €8,600 | €0 | €0 | €8,600 |
| 6 | Reverse payment €1,000 | €9,580 | €0 | €20 | €9,600 |
The reversal restores the exact allocation it reverses: €980 principal and €20 fee, not a fresh allocation under today's balances. Now assume €600 was contractually due on 5 February and only €500 remained effectively applied after the reversal sequence. Remaining arrears are €100; DPD at 10 March follows the approved calendar from the oldest relevant unpaid due date. A payment exists, but cure is false.
Projection state is rebuildable; correctness stays above throughput
The event store preserves lineage; the projection store serves efficient current queries. If a projection corrupts, create a clean projection, replay history, verify hashes and invariants, then switch consumers. Snapshotting, state caches, partitioning and aggregate-local processing improve throughput only after correctness is stable.
High-event accounts can become hot partitions. Adjust aggregate boundaries or incremental projections carefully, but do not fragment one economic consistency boundary merely to improve throughput. A fast incorrect state engine is useless.
These consumers should use one governed state service rather than independently reinventing balance, exposure and delinquency calculations.
The state API declares time mode and preserves explanation
interface AccountStateService {
getCurrentState(accountId: string): Promise<LoanState>;
getKnownState(accountId: string, asOf: Date): Promise<LoanState>;
getRestatedState(accountId: string, asOf: Date): Promise<LoanState>;
getExposure(
facilityId: string,
asOf: Date,
stateMode: "current" | "known" | "restated"
): Promise<ExposureState>;
}{
"principalMinor": 958000,
"derivedFrom": ["draw_01", "payment_07", "payment_09_reversal"],
"allocationRuleVersion": "allocation_3.1",
"aggregateVersion": 6
}Not every request needs full trace, but the service must answer why outstanding equals €9,600 through components, applied events and allocations. State without lineage is not sufficiently explainable for a financial decision platform.
A Financial State Integrity Agent can reconstruct evidence without altering authority
A future controlled agent can replay account streams, compare derived and authoritative states, detect invariant or snapshot failures, identify allocation mismatches, trace balances to source events, explain reversals and late changes, reconstruct DPD inputs and quantify decision impact.
Credit Risk
Finance
Component reconciliation, allocation evidence and write-off lineage.
Decision Automation
Explainable current and point-in-time state for automated decisions.
Continue with Idempotency in Payment and Credit Event Processing, Event Time vs Processing Time vs Posting Time, The Payment Is Not the Balance, The Hidden Infrastructure Debt of Modern Lending and Why Batch Risk Is Becoming a Business Risk. Late corrections, chargebacks and a dedicated DPD engine are future Engineering directions, not fabricated routes.
- Define aggregate
- Define primitive state
- Define event effects
- Separate allocation
- Derive secondary state
- Define invariants
- Snapshot
- Replay
- Reconcile
- Expose state API