Designing a Daily Interest Accrual Engine for Millions of Loans

Designing a Daily Interest Accrual Engine for Millions of Loans

Designing a Daily Interest Accrual Engine for Millions of Loans

A production-grade, interview-ready deep dive into building a system that calculates and applies daily interest across millions of active loans with heterogeneous terms, rates, and compounding rules — while surviving traffic spikes of a million requests per minute, without ever losing a cent.

01

Introduction & History

Every loan in existence — a mortgage, a personal loan, a buy-now-pay-later installment, a credit line, a peer-to-peer loan — has one thing in common: money grows (or the debt grows) every single day it sits unpaid. That growth is called interest accrual. Somewhere, every single day, a piece of software wakes up, looks at millions of loan accounts, and asks a deceptively simple question for each one: “how much interest did this loan earn today?”

That question sounds trivial until you consider the scale and the correctness bar. A bank the size of a mid-tier regional lender might carry 5 to 20 million active loans. A large fintech lender or credit card issuer can carry hundreds of millions of open balances. Every one of those loans can have a different interest rate, a different day-count convention, a different compounding frequency, a different currency, and a different regulatory jurisdiction. And unlike a “like” button on a social app, an interest calculation that’s off by even a fraction of a cent — multiplied across millions of accounts, over years — becomes a regulatory incident, a class-action lawsuit, or a multi-million dollar reconciliation nightmare.

Historically, interest accrual was a nightly, single-threaded COBOL batch job running on a mainframe, processing loans sequentially against a single ledger tape. That approach worked when banks had thousands of loans and an eight-hour overnight window. It does not work when a modern lending platform must onboard millions of new loans through instant digital origination, recalculate accruals intraday when a customer makes a payment or a rate changes, expose real-time “current balance” APIs to millions of concurrent mobile app users, and do all of this while remaining auditable to regulators who can ask “show me exactly how you calculated interest on loan X, on date Y” five years later.

This tutorial designs that system from first principles: the domain concepts that make lending math tricky, the architecture that lets a batch-and-real-time hybrid system scale horizontally, and the engineering discipline (idempotency, event sourcing, precise arithmetic, sharding) required to make the numbers always add up — even under a traffic profile that spikes to a million requests per minute during peak hours like month-end statement generation or a marketing-driven surge in loan applications.

By the end of this tutorial, you’ll be able to whiteboard the full system end to end: draw the component diagram with every box correctly labeled (API Gateway, load balancer, orchestrator, worker fleet, sharded stores, caches), explain precisely why each piece exists and what breaks without it, and defend the design against the kinds of follow-up questions a senior interviewer throws at exactly this class of problem — “what if two events race,” “what if a worker dies mid-batch,” “how do you prove to a regulator this number is correct.” Those follow-ups are woven throughout as dedicated interviewer callouts, so you can treat this as both a design reference and interview preparation in one pass, whether you’re preparing for a staff-level system design round or actually building this system for a real lending platform.

💡
Why this problem is a favorite in system design interviews

It combines four hard sub-problems that interviewers love to probe: (1) large-scale distributed batch processing, (2) financial precision and correctness under concurrency, (3) idempotency and exactly-once semantics in a system where “duplicate” means “we stole or gave away money,” and (4) read-heavy real-time APIs layered on top of a write-heavy ledger. Very few system design problems force you to reason about money-grade correctness AND internet-scale throughput at the same time.

Real-Life Analogy

Think of a bathtub with a slowly dripping faucet. The tub is the loan balance. Every day, a few more drops (interest) fall in, even if nobody touches the tub. A daily accrual engine is a very precise “drop counter” that must count, log, and durably record the drops on tens or hundreds of millions of tubs every single day — without ever double-counting or missing a drop, and while a million people are simultaneously walking up to check exactly how full their tub is.

02

Problem & Motivation

Let’s define the problem precisely before drawing a single box.

2.1 Functional requirements

  • Daily accrual: For every active loan, compute the interest that accrued for “today” based on the loan’s outstanding principal, its annual interest rate, and its day-count convention (e.g., Actual/365, 30/360, Actual/360).
  • Heterogeneous terms: Support fixed-rate, variable-rate, tiered-rate, and promotional-rate (e.g., 0% APR for 6 months) loans, each with its own compounding schedule (daily, monthly, or none until maturity).
  • Mid-cycle events: Correctly re-accrue when a payment posts, a rate changes, a loan is paid off early, or a loan goes delinquent (and possibly stops accruing, or starts accruing penalty interest) — all mid-day, not just at the nightly batch boundary.
  • Real-time balance API: Expose a “what do I owe today” endpoint to mobile/web clients that reflects accrued-but-not-yet-posted interest, with low latency, at massive read concurrency.
  • Immutable audit trail: Every cent of interest ever calculated must be traceable — which formula, which inputs, which code version, which timestamp — for 7+ years, to satisfy regulators (e.g., Truth in Lending Act, Regulation Z in the US, or equivalent regimes elsewhere).
  • Idempotent, exactly-once posting: A retried or replayed accrual job must never double-post interest to a loan’s ledger.

2.2 Non-functional requirements

RequirementTarget
Scale (loan book)50M–500M active loans across all portfolios and tenants
Batch completion windowFull daily accrual run for all loans completes in under 2 hours
Peak read throughputUp to 1,000,000 balance-check / statement API requests per minute during month-end and promotional spikes
Write throughput (events)100K–300K payment/rate-change events per minute during peak
CorrectnessZero tolerance for lost, duplicated, or miscalculated interest entries; penny-accurate reconciliation
Availability99.99% for read APIs; batch pipeline must recover from partial failure without manual reconciliation
AuditabilityFull replayable history of every accrual calculation for regulatory retention (7+ years)
Latency (read API)p99 < 150ms for current-balance lookups
The core tension

Lending platforms must reconcile two opposing forces: batch-oriented, sequential, auditable financial computation (the domain of ledgers and accountants) versus internet-scale, horizontally distributed, highly concurrent systems (the domain of modern backend engineering). The entire architecture in this tutorial exists to resolve that tension — turning a “single conveyor belt” nightly job into thousands of parallel, independently-verifiable, idempotent units of work.

2.3 Why “just scale the database” isn’t a real answer

A natural first instinct is to throw a bigger database at the problem — more CPU, more RAM, a faster disk. This works until it very suddenly doesn’t. A single database instance, no matter how large, has a ceiling on write IOPS and connection concurrency. At 200 million loans needing a write each night within a 2-hour window, the required sustained write rate exceeds what even a very large single instance can sustain reliably, and it creates a single point of failure for the entire lending business’s most critical nightly process. Vertical scaling buys time; it doesn’t remove the ceiling. The architecture in this tutorial is built around horizontal scaling from the outset specifically to avoid hitting that wall later, at the worst possible time — during a growth spurt when the business can least afford an emergency re-architecture.

2.4 Regulatory weight behind the numbers

Interest accrual isn’t just a technical calculation — in most jurisdictions it’s directly governed by consumer protection law (for example, Regulation Z / Truth in Lending Act disclosures in the US, or equivalent regimes elsewhere), meaning the method of calculation, not just the final number, must match what was disclosed to the borrower at origination. This is why the system must persist the day-count convention, rate, and formula version used for every single calculation rather than just the resulting dollar amount — a regulator or auditor can ask to see the exact method five years later, and “we don’t have that level of detail anymore” is not an acceptable answer.

03

Core Domain Concepts (Explained Simply)

Before touching architecture, we need shared vocabulary. Think of these as the “physics” of the lending world — the rules the system must obey no matter how it’s built.

3.1 Principal, rate, and accrual

What: Principal is the amount of money still owed. The interest rate (Annual Percentage Rate, or APR) is the yearly cost of borrowing that money, expressed as a percentage. Accrual is the daily “drip” of interest that builds up on top of the principal.

Analogy: Imagine filling a bathtub with a slowly dripping faucet. The tub is the loan balance. Every day, a few more drops (interest) fall in, even if nobody touches the tub. The daily accrual job is the “drop counter” — it doesn’t fill the tub in one go, it counts drops one day at a time.

Formula (simple daily interest):

Simple daily interest formulamath
Daily Interest = Outstanding Principal × (Annual Rate / Day Count Basis)

3.2 Day-count conventions

What: A rule for how many days are assumed to be in a month or year when computing interest. This sounds like trivia, but it’s the single most common source of “off by a few cents” bugs in lending software.

ConventionUsed forRule
Actual/365Many consumer loans, credit cardsActual days elapsed ÷ 365
Actual/360Commercial loans, money marketsActual days elapsed ÷ 360 (slightly higher effective rate)
30/360Mortgages, bondsEvery month is treated as having exactly 30 days, year has 360
Actual/ActualGovernment bondsActual days ÷ actual days in the year (365 or 366)

Practical example: A $100,000 loan at 6% APR accrues $16.44/day under Actual/365, but $16.67/day under Actual/360 — a small daily gap that compounds into thousands of dollars of difference over a loan’s lifetime. The system must store the convention as loan-level metadata and never hardcode it.

3.3 Compounding

What: Whether accrued-but-unpaid interest itself starts earning interest. Simple interest never compounds; the principal alone accrues. Compound interest (common in credit cards) adds unpaid interest into the balance that then accrues further interest.

Analogy: Simple interest is like a savings jar where only the original coins earn interest. Compound interest is a jar where yesterday’s earned interest coins get added to the jar and start earning interest too.

3.4 Ledger & immutability

What: A ledger is an append-only, chronological record of every financial event on an account — every accrual, payment, fee, and adjustment. Unlike a typical CRUD table, ledger rows are never updated or deleted; corrections are made by inserting a new offsetting entry.

Why it matters: Immutability is what makes a financial system auditable. If a bug caused an over-accrual, you don’t silently “fix” the old row — you write a reversing entry, exactly like double-entry bookkeeping in accounting has done for 500 years.

💬
What an interviewer may ask

“Why not just update a current_balance column directly instead of maintaining a full ledger?” Good answer: a mutable balance column loses history — you can’t answer “why is the balance what it is” or reconstruct state as of any past date, which regulators and customer support both require. The ledger is the source of truth; any denormalized “current balance” field is a derived, rebuildable cache.

3.5 Amortization vs. simple daily accrual

What: Amortization is a pre-computed schedule that splits each fixed payment into a principal portion and an interest portion over the life of a loan (common in mortgages and auto loans). Daily simple accrual, by contrast, doesn’t rely on a fixed schedule — it computes interest fresh every day off the actual outstanding principal, which is what makes it correctly handle early payments, extra principal payments, and payoffs without needing to regenerate an entire schedule.

Why this system uses daily accrual, not a static amortization table: A static schedule assumes the borrower pays exactly on time, every time. Real portfolios don’t behave that way — partial payments, late payments, and payoffs are the norm at scale, so the amortization schedule becomes a display convenience for the customer, while the daily accrual engine remains the actual source of truth for how much interest is owed.

3.6 Grace periods, delinquency, and charge-off

What: A grace period is a window (e.g., 15 days after a due date) during which a late payment doesn’t yet trigger penalty interest or fees. Delinquency status kicks in once a loan is meaningfully overdue (e.g., 30/60/90 days past due), and often changes the accrual rule itself — some products stop accruing standard interest and start accruing a higher penalty rate; others pause accrual entirely pending a regulatory or accounting requirement. Charge-off is the point where a lender writes the loan off as a loss for accounting purposes; the system typically stops accrual entirely at that point, even though collections activity may continue.

Why it matters for architecture: Accrual isn’t one universal formula — it’s a small state machine per loan (current → grace → delinquent → charged-off), and the Interest Rate and Terms Service must expose not just a rate, but the current accrual policy for that loan’s status, which the worker consults before applying any formula.

3.7 Prepayment and payoff

What: A payoff is when a borrower pays the full remaining balance early. From that moment forward, principal is zero and accrual must stop precisely at the payoff timestamp — not at midnight, not at the next batch run. Some loans also apply a prepayment penalty, itself a special one-time interest-like charge computed by a different formula.

Analogy: Think of the daily accrual job as a faucet that drips into the tub every day. A payoff event isn’t just “empty the tub” — it’s “turn the faucet off at 2:47pm today,” and the system has to know to only count drops up to that exact moment, not the whole day.

3.8 Consistency model: why eventual consistency is acceptable here

What: The CAP theorem says a distributed system facing a network partition must choose between consistency and availability. This system deliberately chooses different points on that spectrum for different data: the ledger write path favors strong consistency within a shard (a single loan’s entries must never be lost or duplicated), while the balance read path favors availability and low latency, accepting a small, bounded eventual-consistency window (typically under one second) between a ledger write and the read-side cache reflecting it.

Why this split is safe: The kind of “wrong answer” a customer-facing balance API can tolerate for a few hundred milliseconds (a slightly stale but never fabricated number) is fundamentally different from what the ledger must never tolerate (a lost or duplicated financial entry). Separating these concerns is precisely why CQRS is used — a single system can’t uniformly optimize for both, but two cooperating models can.

04

Architecture & Components

The system splits cleanly into two operational modes that share the same domain logic but very different traffic shapes:

  • Batch Accrual Pipeline — a nightly (and optionally intraday) distributed job that walks every active loan and posts a daily interest entry.
  • Real-Time Event & Read Path — handles payments, rate changes, payoffs, and serves millions of “what do I owe” reads per minute without ever touching the batch pipeline’s write path directly.

Below is the full end-to-end architecture. Every box names the concrete component responsible for it — this is deliberately drawn the way you’d whiteboard it in an interview.

flowchart TB subgraph CL[“Client Layer”] C1[“Mobile App”] C2[“Web Portal”] C3[“Partner / Servicer API Consumer”] end C1 –> DNS[“DNS + Global Traffic Manager
Route53 / Anycast”] C2 –> DNS C3 –> DNS DNS –> CDN[“CDN + Edge Cache
Static assets + cached GETs”] CDN –> WAF[“WAF + DDoS Protection”] WAF –> LB[“Load Balancer (L7 ALB)
Health checks + routing”] LB –> GW[“API Gateway
AuthN/Z, rate limiting, schema validation”] GW –> SVC1[“Loan Account Service”] GW –> SVC2[“Interest Rate + Terms Service”] GW –> SVC3[“Accrual Orchestrator Service”] GW –> SVC4[“Ledger + Posting Service”] GW –> SVC5[“Payment Ingestion Service”] GW –> SVC6[“Balance Query Service (read-optimized)”] GW –> SVC7[“Notification Service”] SVC3 –> SCHED[“Distributed Scheduler
Leader election coordinator”] SCHED –> QUEUE[“Kafka Cluster
Accrual job topic, partitioned by shard key”] QUEUE –> WORKERPOOL[“Accrual Calculation Worker Pool
Auto-scaled consumer group”] WORKERPOOL –> RATECACHE[“Redis Cluster
Rate, terms + balance snapshot cache”] WORKERPOOL –> LOANDB[“Sharded Loan Master DB
PostgreSQL / MySQL cluster”] WORKERPOOL –> LEDGERSTORE[“Append-only Ledger Store
Event-sourced journal”] WORKERPOOL –> IDEMPSTORE[“Idempotency Key Store
Redis / DynamoDB”] WORKERPOOL –> DLQ[“Dead Letter Queue
Failed accrual events”] LEDGERSTORE –> STREAM[“Change Data Capture stream
Debezium / Kafka Connect”] STREAM –> BALPROJ[“Balance Projection Builder
Materialized view updater”] BALPROJ –> READCACHE[“Read Cache Layer
Redis for current balance”] READCACHE –> SVC6 STREAM –> AUDIT[“Audit + Compliance Store
Immutable WORM storage”] STREAM –> DWH[“Data Warehouse
Analytics + regulatory reporting”] LOANDB –> REPLICAS[“Read Replicas (multi-AZ)”] DLQ –> ALERTSVC[“Alerting + On-call Paging
PagerDuty”] SVC5 –> QUEUE SVC2 –> RATECACHE WORKERPOOL –> METRICS[“Metrics, Logs + Tracing
Prometheus, OpenTelemetry, ELK”]
Diagram 1 — End-to-end architecture of the daily accrual engine.

4.1 Component responsibilities

Gateway

API Gateway

Single entry point for all external traffic. Handles authentication (OAuth2 / JWT), per-client rate limiting, request schema validation, and routes to the correct downstream microservice. Shields internal services from being called directly by clients.

Edge

Load Balancer

L7 load balancer (e.g., an Application Load Balancer) in front of the API Gateway fleet and again in front of each stateless microservice tier, distributing traffic by least-connections or round-robin, with active health checks removing unhealthy nodes.

Coordination

Distributed Scheduler

A leader-elected coordinator (built on something like a distributed lock via ZooKeeper / etcd, or a managed scheduler) that triggers the daily accrual run exactly once, fans it out into per-shard jobs, and tracks completion.

Compute

Accrual Worker Pool

Stateless, horizontally auto-scaled consumers that pull loan batches from Kafka, run the interest calculation, and write ledger entries. This is the CPU-bound heart of the system and scales linearly with partition count.

Storage

Ledger / Posting Service

Owns the append-only ledger table(s). Guarantees idempotent writes using a deterministic idempotency key (loan_id + accrual_date + calculation_version).

Read model

Balance Projection Builder

Consumes the ledger’s change stream (via CDC) and maintains a fast, denormalized “current balance” read model so the read API never has to sum ledger rows on the fly.

💬
What an interviewer may ask

“Why separate the write-heavy ledger from the read-heavy balance API instead of querying the ledger directly for balance checks?” Answer: this is CQRS (Command Query Responsibility Segregation). Summing millions of ledger rows per request at a million-requests-per-minute read load would collapse the database. Instead, a projection builder pre-computes the current balance into a fast key-value cache, updated asynchronously via CDC, trading a few hundred milliseconds of eventual consistency for orders-of-magnitude better read scalability.

4.2 Why the Kafka topic is partitioned by shard key, not by loan ID directly

A natural instinct is to partition the accrual topic by individual loan_id for maximum parallelism. In practice, the system partitions by the same shard key used for the database (e.g., loan_id % 4096, then grouped into a smaller number of Kafka partitions) so that a single consumer processes a contiguous, database-co-located batch of loans per message rather than one tiny message per loan. This dramatically reduces message volume (thousands of shard-trigger messages instead of hundreds of millions of per-loan messages), lets each worker do efficient batched database reads and writes against loans it already owns a connection pool for, and keeps the mapping between “which worker touches which data” stable and predictable — valuable both for debugging and for reasoning about concurrency.

4.3 Why a separate idempotency store instead of relying solely on the database’s unique constraint

The database’s unique constraint on (loan_id, idempotency_key) is the ultimate source of truth and the real safety net — it’s what actually prevents a duplicate row from ever being committed. But hitting the database to discover “was this already processed” for every single loan, every single day, adds unnecessary load to the most contended resource in the system. The Redis-backed idempotency store acts as a fast-path check: if a key is already marked complete, the worker skips the expensive database round trip entirely. If the fast-path check is wrong (e.g., the cache entry expired early or was never written due to a crash), the database constraint still catches the duplicate at insert time — so correctness never depends on the cache being right, only on it being a helpful optimization.

05

Internal Working

Let’s zoom into how a single day’s accrual actually runs across the fleet.

5.1 Job fan-out and sharding

At the scheduled trigger time (say, 00:05 UTC), the Distributed Scheduler doesn’t process 200 million loans in one job. It fans the work out: the loan population is partitioned into shards (e.g., by loan_id % 4096), and one lightweight “accrual trigger” message is published per shard to Kafka. Each message says, in effect, “go accrue interest for shard 137, for business date 2026-08-04.” This turns one giant sequential job into thousands of small, independent, parallelizable units of work — the same principle as MapReduce.

5.2 Worker execution

Each worker in the auto-scaled consumer pool picks up a shard message, queries the Loan Master DB (or a warmed cache) for all active loans in that shard, and for each loan:

1

Fetch loan state

Fetches the loan’s current principal, rate, day-count convention, and status.

2

Idempotency check

Checks the Idempotency Key Store to confirm this loan hasn’t already been accrued for this business date (protects against retries / replays).

3

Precise calculation

Computes the day’s interest using BigDecimal arithmetic (never floating point) with the loan’s specific formula.

4

Write ledger entry

Writes an immutable ledger entry: {loan_id, business_date, principal_used, rate_used, interest_amount, formula_version, computed_at}.

5

Mark complete

Marks the idempotency key as complete so any redelivery of the same trigger message becomes a safe no-op.

5.3 Idempotency: the non-negotiable guarantee

Kafka consumers can redeliver messages (at-least-once delivery is the norm). A worker can crash mid-batch and be restarted. Without idempotency, a redelivered message would double-post interest — effectively fabricating money. The fix: every accrual write uses a deterministic idempotency key, and the ledger insert is guarded by a unique constraint (or a conditional write) on that key, so a duplicate attempt is a safe no-op rather than a duplicate row.

AccrualPoster.java — idempotent accrual postingjava
public class AccrualPoster {

    private final LedgerRepository ledgerRepo;
    private final IdempotencyKeyStore idempotencyStore;

    public AccrualResult postDailyAccrual(LoanSnapshot loan, LocalDate businessDate) {
        String idemKey = buildIdempotencyKey(loan.getLoanId(), businessDate);

        // Fast-path check against Redis/DynamoDB before touching the DB
        if (idempotencyStore.exists(idemKey)) {
            return AccrualResult.alreadyProcessed(idemKey);
        }

        BigDecimal dailyInterest = InterestCalculator.calculateDaily(
                loan.getOutstandingPrincipal(),
                loan.getAnnualRate(),
                loan.getDayCountConvention(),
                businessDate
        );

        LedgerEntry entry = LedgerEntry.builder()
                .loanId(loan.getLoanId())
                .businessDate(businessDate)
                .principalUsed(loan.getOutstandingPrincipal())
                .rateUsed(loan.getAnnualRate())
                .interestAmount(dailyInterest)
                .idempotencyKey(idemKey)
                .formulaVersion("v3-actual365")
                .build();

        try {
            // Unique constraint on (loan_id, business_date, formula_version)
            // makes this insert safe to retry
            ledgerRepo.insertIfAbsent(entry);
            idempotencyStore.markComplete(idemKey, Duration.ofDays(3));
            return AccrualResult.success(entry);
        } catch (DuplicateKeyException dup) {
            // Another worker/retry already committed this exact entry
            return AccrualResult.alreadyProcessed(idemKey);
        }
    }

    private String buildIdempotencyKey(String loanId, LocalDate date) {
        return loanId + ":" + date + ":daily-accrual";
    }
}
InterestCalculator.java — precise BigDecimal mathjava
public class InterestCalculator {

    private static final int SCALE = 10;
    private static final RoundingMode ROUNDING = RoundingMode.HALF_EVEN;

    public static BigDecimal calculateDaily(BigDecimal principal,
                                              BigDecimal annualRatePercent,
                                              DayCountConvention convention,
                                              LocalDate businessDate) {
        BigDecimal rateDecimal = annualRatePercent.divide(BigDecimal.valueOf(100), SCALE, ROUNDING);
        BigDecimal dayCountBasis = convention.basisFor(businessDate); // 360, 365, or 366

        BigDecimal dailyRate = rateDecimal.divide(dayCountBasis, SCALE, ROUNDING);
        BigDecimal dailyInterest = principal.multiply(dailyRate);

        // Round to the currency's minor unit only at the final posting step,
        // never during intermediate compounding math - avoids drift
        return dailyInterest.setScale(2, ROUNDING);
    }
}
Common pitfall: floating point money

Never use double or float for money. IEEE-754 floating point cannot represent values like 0.1 exactly, and across millions of loans and years of daily accruals, those tiny representation errors compound into real, auditable discrepancies. Always use fixed-point decimal types (BigDecimal in Java, decimal in .NET) with an explicit rounding mode agreed upon with finance / compliance teams — usually banker’s rounding (HALF_EVEN) to avoid systematic bias.

5.4 Concurrency control: batch accrual vs. concurrent payment posting

A subtle but critical race condition: what happens if a payment posts for a loan at the exact moment the nightly batch is calculating that loan’s accrual? Two writers touching the same loan’s principal at once is exactly the kind of bug that causes real financial loss if handled naively.

The system resolves this with optimistic concurrency control at the loan level. Each loan’s mutable “current state” row (principal, last-accrued date) carries a version number. Both the batch worker and the real-time payment handler read the version, compute their update, and write back conditionally on that version being unchanged (UPDATE ... WHERE loan_id = ? AND version = ?). If the conditional update affects zero rows, the writer knows it lost the race, re-reads the fresh state, and retries its calculation against the new principal. This avoids taking a heavyweight distributed lock per loan while still guaranteeing no lost updates.

LoanStateUpdater.java — optimistic concurrency controljava
public class LoanStateUpdater {

    private final LoanStateRepository repo;
    private static final int MAX_RETRIES = 5;

    public void applyAccrualWithOptimisticLock(String loanId, BigDecimal accruedInterest) {
        int attempt = 0;
        while (attempt < MAX_RETRIES) {
            LoanState current = repo.fetchCurrentState(loanId); // includes version
            LoanState updated = current.withAccruedInterestApplied(accruedInterest);

            int rowsAffected = repo.updateIfVersionMatches(
                    loanId,
                    updated,
                    current.getVersion()
            );

            if (rowsAffected == 1) {
                return; // success
            }

            attempt++;
            sleepWithJitter(attempt); // small backoff before re-reading and retrying
        }
        throw new OptimisticLockExhaustedException(loanId, MAX_RETRIES);
    }

    private void sleepWithJitter(int attempt) {
        try {
            long baseMs = (long) Math.pow(2, attempt) * 10;
            long jitter = ThreadLocalRandom.current().nextLong(0, baseMs);
            Thread.sleep(baseMs + jitter);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

5.5 Batch fetching for throughput

Fetching one loan at a time from the database per worker iteration would drown the database in round trips at this scale. Instead, each worker pulls loans in bulk pages (e.g., 500–1000 rows per query) within its assigned shard, processes the page in memory, and writes ledger entries in a single batched insert rather than one insert per loan — reducing both network round trips and transaction overhead by two to three orders of magnitude compared to row-by-row processing.

ShardAccrualProcessor.java — batched, paginated processingjava
public class ShardAccrualProcessor {

    private static final int PAGE_SIZE = 750;

    public void processShard(int shardId, LocalDate businessDate) {
        String cursor = null;
        List<LedgerEntry> batchToInsert = new ArrayList<>(PAGE_SIZE);

        do {
            Page<LoanSnapshot> page = loanRepo.fetchActiveLoansPage(shardId, cursor, PAGE_SIZE);
            for (LoanSnapshot loan : page.getItems()) {
                BigDecimal interest = InterestCalculator.calculateDaily(
                        loan.getOutstandingPrincipal(),
                        loan.getAnnualRate(),
                        loan.getDayCountConvention(),
                        businessDate
                );
                batchToInsert.add(buildLedgerEntry(loan, businessDate, interest));
            }
            ledgerRepo.bulkInsertIfAbsent(batchToInsert); // single round trip per page
            batchToInsert.clear();
            cursor = page.getNextCursor();
        } while (cursor != null);
    }
}
06

Data Flow & Lifecycle

The sequence diagram below traces one loan’s daily accrual from trigger to a client seeing the updated balance.

sequenceDiagram participant SCHED as Scheduler participant KAFKA as Kafka Accrual Topic participant WORKER as Accrual Worker participant CACHE as Redis Rate Cache participant DB as Sharded Loan DB participant LEDGER as Ledger Store participant CDC as Change Data Capture participant PROJ as Balance Projection participant CLIENT as Mobile Client SCHED->>KAFKA: Publish shard trigger for business date KAFKA->>WORKER: Deliver shard batch of loan ids WORKER->>CACHE: Fetch rate and terms for loans CACHE–>>WORKER: Cache hit or miss alt cache miss WORKER->>DB: Load loan terms from source of truth DB–>>WORKER: Return loan record WORKER->>CACHE: Populate cache with TTL end WORKER->>WORKER: Calculate daily interest using BigDecimal WORKER->>LEDGER: Insert idempotent ledger entry LEDGER–>>WORKER: Acknowledge commit LEDGER->>CDC: Emit change event CDC->>PROJ: Update materialized balance view PROJ->>CACHE: Refresh current balance cache entry CLIENT->>CACHE: GET current balance CACHE–>>CLIENT: Return updated balance under 150ms
Diagram 2 — End-to-end sequence for a single loan’s daily accrual.

6.1 Mid-cycle events

Payments, payoffs, and rate changes don’t wait for the nightly batch. They flow through a separate real-time event path: a payment event lands in a Payment Ingestion Service, which publishes an event to the same Kafka ecosystem (a different topic), consumed by the Ledger Service to post a payment entry and trigger an immediate partial re-accrual for that single loan if the business rules require it (e.g., “interest stops accruing on paid-off principal from the payment timestamp forward”).

07

Databases, Caching & Sharding

7.1 Sharding strategy

With 200M+ loans, a single database instance is out of the question. The Loan Master DB and Ledger Store are both horizontally sharded by loan_id hash, typically into a few thousand logical shards mapped onto a smaller number of physical database clusters (e.g., 4096 logical shards across 64 physical Postgres clusters, each holding 64 logical shards). This gives room to re-balance physical placement later without changing the hashing scheme clients depend on.

flowchart LR RQ[“Accrual Request Router
Consistent hash on loan_id”] –> SH1[“Shard Group 1
Loans hash 0-999″] RQ –> SH2[“Shard Group 2
Loans hash 1000-1999″] RQ –> SH3[“Shard Group 3
Loans hash 2000-2999″] RQ –> SHN[“Shard Group N
Remaining hash range”] SH1 –> P1[“Primary + 2 read replicas”] SH2 –> P2[“Primary + 2 read replicas”] SH3 –> P3[“Primary + 2 read replicas”] SHN –> PN[“Primary + 2 read replicas”]
Diagram 3 — Sharded database topology with per-shard read replicas.

7.2 Caching layers

CacheContentsTTL / invalidation
Rate/Terms Cache (Redis)Loan interest rate, day-count convention, compounding ruleInvalidated on rate-change event; short TTL (minutes) as safety net
Current Balance Cache (Redis)Denormalized latest balance per loan, updated by CDC projectionUpdated on every ledger write via CDC stream, sub-second lag
Idempotency Key Store (Redis / DynamoDB)Marker rows proving an accrual was already posted for loan+dateTTL of a few days, long enough to cover retry windows

7.3 Ledger storage model

The ledger uses an event-sourced, append-only table partitioned by business date (for efficient archival of old partitions to cold storage) and indexed by loan_id for fast per-loan history reads. Every row is immutable; corrections are new rows referencing the original entry’s ID.

ledger_entries — append-only, partitioned by business datesql
CREATE TABLE ledger_entries (
    entry_id            BIGINT GENERATED ALWAYS AS IDENTITY,
    loan_id              BIGINT NOT NULL,
    business_date        DATE NOT NULL,
    entry_type           VARCHAR(20) NOT NULL,  -- ACCRUAL, PAYMENT, ADJUSTMENT, REVERSAL
    principal_used        NUMERIC(18,2),
    rate_used             NUMERIC(9,6),
    amount                NUMERIC(18,2) NOT NULL,
    formula_version       VARCHAR(20) NOT NULL,
    idempotency_key       VARCHAR(100) NOT NULL,
    reverses_entry_id      BIGINT,
    created_at            TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (business_date, entry_id),
    UNIQUE (loan_id, idempotency_key)
) PARTITION BY RANGE (business_date);
💬
What an interviewer may ask

“How would you re-shard this system if one shard group becomes a hotspot?” Answer: use consistent hashing with virtual nodes rather than static modulo sharding, so re-balancing moves a small fraction of loans rather than requiring a full re-partition. Alternatively, adopt directory-based sharding with a lookup service mapping loan_id ranges to physical clusters, which allows live migration of individual ranges with zero downtime.

08

APIs & Microservices

8.1 Key endpoints

Loan platform — public REST surfacehttp
GET  /v1/loans/{loanId}/balance          -> real-time current balance (cache-backed)
GET  /v1/loans/{loanId}/ledger?from&to   -> paginated ledger history for audits
POST /v1/loans/{loanId}/payments          -> ingest a payment event
POST /v1/loans/{loanId}/rate-change       -> apply a new rate effective a given date
GET  /v1/accrual-runs/{date}/status       -> batch run health/progress for ops dashboards

8.2 Microservice boundaries

Each service owns its own data and communicates via well-defined APIs and asynchronous events — never by reaching into another service’s database. The Accrual Orchestrator only knows shard boundaries and job status; it delegates actual calculation to workers. The Balance Query Service never reads the ledger directly at request time; it only reads the projection cache, keeping read latency flat regardless of ledger size.

📡
Design choice: event-driven over synchronous chaining

Payment posting doesn’t synchronously call the Accrual Service to recalculate. It publishes a domain event (PaymentPosted) that the Accrual Service subscribes to. This decouples services, lets each scale independently, and — crucially — makes the pipeline replayable: if the Accrual Service was down, replaying the event log catches it back up without any lost business logic.

8.3 Example request / response

GET /v1/loans/LN-88213/balance — cached balance responsehttp
GET /v1/loans/LN-88213/balance
Authorization: Bearer <jwt>

200 OK
{
  "loanId": "LN-88213",
  "asOf": "2026-08-04T09:12:03Z",
  "outstandingPrincipal": "12450.32",
  "accruedInterestUnposted": "4.18",
  "currentPayoffAmount": "12454.50",
  "dayCountConvention": "ACTUAL_365",
  "annualRate": "7.250",
  "status": "CURRENT",
  "lastAccrualDate": "2026-08-03",
  "cacheAge": "312ms"
}

Note the cacheAge field — a deliberate transparency choice. Since the balance API is backed by an eventually-consistent projection, the response tells the caller exactly how fresh the number is, rather than silently pretending it’s a live, strongly-consistent read. This matters for both customer trust and for internal services that may need to decide whether to force a synchronous re-read for a high-stakes operation like a payoff quote.

8.4 Multi-tenancy and portfolio isolation

A lending platform rarely serves a single homogeneous loan book — it typically serves multiple portfolios (different loan products, different originating partners, sometimes different regulatory jurisdictions), each of which can define its own day-count convention, compounding rule, and accrual policy. The Interest Rate and Terms Service models this as a portfolio configuration layer sitting above individual loan records: every loan references a portfolio_id, and the accrual worker resolves “which formula and which day-count convention applies” by looking up the portfolio’s policy, not by hardcoding rules per loan. This keeps onboarding a new lending product or partner a configuration change rather than a code change, while still letting each shard mix loans from many portfolios without any cross-contamination of rules.

09

Advantages, Disadvantages & Trade-offs

DecisionAdvantageTrade-off
Batch + event-driven hybridEfficient bulk processing at night, responsive to real-time events during the dayTwo code paths must stay consistent; more operational complexity than a single model
CQRS (separate read projection)Read APIs scale independently of ledger writes; sub-150ms reads at massive scaleEventual consistency window (typically sub-second) between a ledger write and the cache reflecting it
Sharded databasesHorizontal scalability far beyond a single instance’s limitsCross-shard queries (e.g., portfolio-wide reports) become harder and need the data warehouse
Append-only immutable ledgerFull auditability, easy replay / reconciliationStorage grows without bound; requires partition archival strategy
Idempotency keys everywhereSafe retries, no double-postingExtra storage and a lookup on every write path
Optimistic concurrency (vs. distributed locks)High throughput, no lock-contention bottleneck across unrelated loansRequires retry logic on write conflicts; slightly more complex client code than “just lock it”
Kafka as the batch fan-out mechanismDurable, replayable, naturally partitioned for parallelismOperational overhead of running and tuning a Kafka cluster at scale
Portfolio-level policy configurationOnboarding new loan products is a config change, not a code changeConfiguration itself becomes a governed artifact requiring its own review and testing discipline

Stepping back, nearly every trade-off above follows the same shape: the system gives up a small amount of simplicity or a small, bounded consistency window in exchange for horizontal scalability and operational resilience. That’s a deliberate, recurring theme rather than a coincidence — it’s the same trade-off financial infrastructure has made for decades in different technological clothing, from paper ledgers with reversing entries, to mainframe batch tapes, to today’s event-sourced, sharded, cloud-native pipelines.

10

Performance & Scalability at a Million Requests per Minute

A million requests per minute is roughly 16,700 requests per second sustained, with realistic bursts well above that during month-end statement generation or a marketing campaign. Here’s how each tier absorbs that load.

10.1 Edge and gateway tier

The CDN absorbs and caches cacheable GET responses (e.g., static rate disclosures, marketing content) so they never reach the origin. The WAF and Load Balancer tier scales horizontally behind an auto-scaling group, and the API Gateway enforces per-client and global rate limits using a token-bucket algorithm backed by a distributed counter (Redis), so one noisy client can’t starve others.

10.2 Read path scalability

Because balance reads hit a Redis-backed projection cache rather than the sharded transactional database, the read path scales by adding Redis replicas / shards, which is far cheaper and faster than scaling relational database read replicas for the same throughput. A well-provisioned Redis cluster can serve well over a million reads per second per cluster, giving enormous headroom above the target load.

10.3 Write / batch path scalability

The batch accrual pipeline scales by increasing Kafka partition count and worker pool size — each partition is consumed independently, so doubling partitions and workers roughly doubles throughput (up to the database’s write capacity limits). Database write throughput is protected by sharding: 4096 logical shards mean each physical cluster only needs to sustain a fraction of total write volume.

10.4 Back-of-envelope capacity math

Capacity envelope for 200M loans, 2-hour windowmath
Loan book:            200,000,000 active loans
Batch window target:  2 hours = 7,200 seconds
Required throughput:  200,000,000 / 7,200 ≈ 27,778 loan-accruals/sec

If each worker processes 500 loans/sec (DB write + calc):
  Workers needed ≈ 27,778 / 500 ≈ 56 workers minimum
  (Provision 2-3x headroom -> ~150 workers, auto-scaled)

Peak read load:       1,000,000 req/min ≈ 16,667 req/sec
  Redis cluster easily absorbs this with sub-ms reads;
  bottleneck shifts to network/API Gateway throughput,
  addressed via horizontal Gateway auto-scaling.
💬
What an interviewer may ask

“What’s your scaling bottleneck first — compute, database, or network?” A strong answer: the sharded transactional database is almost always the true bottleneck for the write / batch path, because compute (workers) and cache (Redis) scale near-linearly and cheaply, while relational databases have per-node write ceilings. This is exactly why sharding is introduced early rather than bolted on later.

10.5 Cache hit ratio and its effect on database load

The Rate/Terms cache exists specifically to keep database load flat during the batch window. Consider the math: with 200 million loans and a 98% cache hit ratio on rate/terms lookups (reasonable, since a loan’s rate rarely changes day to day), only 4 million lookups per run fall through to the database rather than 200 million — a 50x reduction in read pressure on the Loan Master DB during the exact window it’s also absorbing heavy ledger writes. This is why cache warming (pre-populating the cache ahead of the nightly run, based on the previous day’s active loan set) is a standard operational step before the batch trigger fires.

10.6 Horizontal scaling levers, summarized

TierScaling leverPractical ceiling
API Gateway / Load BalancerAdd nodes behind auto-scaling groupEffectively unbounded; network egress becomes the constraint far before compute
Stateless microservicesKubernetes HPA on CPU / request latencyBounded by downstream dependency capacity (DB, cache), not by the service itself
Kafka accrual topicIncrease partition countPartition count sets the max parallelism of consumers; re-partitioning requires planning
Accrual worker poolAdd consumer instances up to partition countOne consumer per partition is the practical max useful parallelism per consumer group
Sharded databaseAdd more physical shard clusters, rebalanceEach shard’s own instance-level IOPS / CPU ceiling; sharding raises the aggregate ceiling
Redis cache clusterAdd cache nodes, use client-side hashingVery high per-node throughput; rarely the bottleneck if sized correctly
200M
Active loans supported
1M/min
Peak read throughput
<150ms
p99 balance latency
2h
Nightly batch window

10.7 Handling traffic spikes beyond a million requests per minute

Predictable spikes (month-end statements, a scheduled marketing push) are handled with scheduled pre-scaling — the API Gateway and Balance Query Service fleets scale up ahead of the known event rather than reactively. Unpredictable spikes rely on fast auto-scaling triggers (scaling on request queue depth rather than just CPU, since CPU metrics lag behind an actual traffic surge) plus graceful degradation: if load exceeds provisioned capacity, the system prioritizes serving cached-but-slightly-stale balances over failing requests outright, and sheds lower-priority traffic (e.g., non-critical reporting endpoints) before shedding customer-facing balance checks.

11

High Availability & Reliability

11.1 Failure recovery flow

flowchart TD START[“Accrual job starts for shard”] –> TRY[“Attempt calculation + ledger write”] TRY –> OK{“Write succeeded?”} OK –>|Yes| COMMIT[“Commit ledger entry”] OK –>|No| RETRY{“Retry count < 3?”} RETRY –>|Yes| BACKOFF[“Exponential backoff with jitter”] BACKOFF –> TRY RETRY –>|No| DLQ[“Route to Dead Letter Queue”] DLQ –> ALERT[“Trigger on-call page”] COMMIT –> IDEMP[“Mark idempotency key complete”] IDEMP –> CDC[“Emit change event downstream”]
Diagram 4 — Retry, backoff, and DLQ flow inside the worker.

11.2 Multi-region and multi-AZ design

Each database shard’s primary lives in one availability zone with synchronous replicas in at least two others, enabling automatic failover within seconds. Kafka brokers and consumer groups span multiple AZs so a single AZ outage doesn’t halt the pipeline. For disaster recovery, ledger data is continuously replicated to a secondary region; a full region failover is a documented, drilled runbook rather than an aspiration.

11.3 Reconciliation as a safety net

Beyond preventing failures, the system runs a nightly reconciliation job comparing the sum of ledger entries against expected totals derived independently (e.g., from the loan master’s principal / rate history), flagging any mismatch above a cent threshold for human review before it reaches a customer statement.

Circuit breakers on downstream calls

Worker calls to the Rate Cache and Loan DB are wrapped in circuit breakers. If the database starts timing out under load, the breaker trips, workers back off and retry later rather than compounding the outage with a retry storm — protecting the database from a thundering herd during recovery.

11.4 Backup, RPO and RTO

Financial ledger data carries the strictest backup requirements in the platform. Continuous write-ahead-log (WAL) streaming to a secondary region keeps the Recovery Point Objective (RPO) — the maximum acceptable data loss — under a few seconds. Automated failover mechanisms target a Recovery Time Objective (RTO) — the maximum acceptable downtime — of a few minutes for a single-shard failure and under thirty minutes for a full regional failover, both figures validated through scheduled, unannounced failover drills rather than assumed from documentation.

11.5 Graceful degradation ladder

Rather than a binary “up or down,” the platform defines a ladder of degraded states it can fall back through under stress, always preserving core financial correctness even if convenience features drop first:

1

Full service

Real-time balances, all APIs, batch on schedule.

2

Stale-but-labeled reads

Balance API serves the last known-good cached value with a “last updated at” timestamp if the projection pipeline lags.

3

Write-path only

If the read cache tier is impaired, the ledger continues accepting and correctly processing accruals and payments; only the customer-facing read experience degrades.

4

Batch-critical only

Under severe resource constraints, non-critical services (reporting, analytics exports) are paused first so the core daily accrual run still completes within its window.

12

Security

  • AuthN / AuthZ: OAuth2 / OIDC at the API Gateway; service-to-service calls use mutual TLS and short-lived tokens (SPIFFE / SPIFFE-like identities).
  • Encryption: TLS in transit everywhere; AES-256 at rest for the ledger and loan master databases; field-level encryption for PII (SSNs, account numbers).
  • Least privilege: Accrual workers have write access only to the ledger tables they need; no service has blanket database admin rights.
  • Immutable audit logging: All writes to financial tables are logged to a WORM (write-once-read-many) store, satisfying regulatory tamper-evidence requirements.
  • Rate limiting and abuse prevention: Per-client quotas at the gateway prevent a single compromised or buggy integration from overwhelming the platform.
  • PCI / PII scoping: Payment card data, if handled, is isolated to a narrowly scoped, separately audited subsystem rather than spread across the general architecture.
💬
What an interviewer may ask

“How do you prevent an internal engineer from tampering with a customer’s accrued interest?” Answer: no human has direct write access to production ledger tables; all writes go through the Ledger Service’s API, which enforces the idempotent-insert-only model. Any manual correction must go through a documented adjustment workflow that itself writes an auditable reversal entry, never an in-place UPDATE.

12.1 Segregation of duties

Beyond technical access controls, the platform enforces segregation of duties at the process level: the engineer who can deploy a change to the interest calculation formula is never the same person who can approve that formula’s golden-file test results, and any manual ledger adjustment requires a second approver before it posts — mirroring the “maker-checker” controls that traditional banking back-offices have used for decades, now encoded into the deployment and adjustment workflows themselves rather than left as a manual policy.

12.2 Data minimization in logs and traces

Distributed traces and structured logs are invaluable for debugging, but a stack trace or log line that accidentally includes a full account number or a customer’s SSN becomes a compliance liability the moment it’s written to a log aggregator with broader access than the production database itself. Logging middleware at the service boundary automatically redacts known sensitive fields before anything is emitted, and code review checklists explicitly call out “does this log statement include PII” as a required check for any change touching the loan or payment services.

12.3 Secrets and credential management

Database credentials, API keys, and signing keys are never embedded in code or configuration files checked into source control; they’re pulled at runtime from a managed secrets store (such as a cloud KMS-backed vault), rotated on a defined schedule, and scoped so that a compromised credential for one microservice cannot be used to access another service’s data store.

13

Monitoring, Logging & Metrics

SignalToolingWhy it matters
Batch run completion %Custom dashboard fed by Orchestrator status eventsOps needs to know at 1am if the nightly run is on track to finish before markets open
Per-shard latency & error ratePrometheus + GrafanaPinpoints a slow or failing shard before it delays the whole run
Distributed tracesOpenTelemetry across Gateway → Services → DBDiagnoses where a slow balance-check request spends its time
DLQ depthKafka consumer lag metrics, alerting on thresholdSignals loans that failed accrual and need investigation before statements go out
Reconciliation mismatchesNightly batch report + PagerDuty alert on non-zero mismatchLast line of defense against silent financial errors
Structured logsELK / OpenSearch, correlation ID per requestEnables tracing one loan’s entire journey across services during an audit

13.1 The batch run dashboard: what ops actually watches

During the nightly window, the single most important screen in the operations center is a live dashboard answering three questions at a glance: how many shards have completed, how many are in progress or stuck, and how many loans have landed in the dead letter queue. This is deliberately a business-facing dashboard, not just an infrastructure one — it’s built from Orchestrator status events rather than raw infrastructure metrics, so a non-engineer on the finance operations team can look at it at 2am and know whether statements will go out on time.

13.2 Alerting philosophy

Alerts are tiered by financial blast radius, not just technical severity. A single loan failing accrual and landing in the DLQ is a warning-level alert reviewed the next business day. A shard-wide failure that threatens the batch completion window is a page-immediately, wake-someone-up alert. A reconciliation mismatch of any non-zero amount is treated with the same urgency as a production outage, because unlike most software bugs, a financial miscalculation actively gets worse (compounds) the longer it goes unnoticed.

14

Deployment & Cloud Considerations

Stateless services (API Gateway, microservices, workers) run as containerized workloads on Kubernetes, scaled by Horizontal Pod Autoscalers keyed on Kafka consumer lag and CPU. The Accrual Worker Pool in particular scales aggressively before the nightly batch window (a scheduled pre-warm) and scales back down afterward to control cost. Blue-green or canary deployments are mandatory for the Ledger and Accrual services given the financial blast radius of a bad release — new formula versions are shipped behind a version flag (formula_version) so a regression is caught on a small canary shard before touching the full book. Infrastructure is defined as code (Terraform) so shard topology, Kafka topic configuration, and database cluster sizing are reproducible and reviewable.

14.1 Cost optimization

The Accrual Worker Pool is the single biggest driver of variable compute cost, since it’s provisioned for a burst (the nightly run) rather than steady-state load. Scheduled scale-down after the batch window, combined with spot / preemptible instances for the worker fleet (safe here because work is idempotent and replayable, so a preempted worker simply gets its partition reassigned), typically cuts compute cost for the batch tier substantially compared to running the peak fleet size around the clock. On the storage side, tiering ledger partitions from hot (fast SSD-backed database storage, recent ~18 months) to cold (object storage, older data retained for regulatory purposes) keeps the expensive, high-IOPS storage tier proportional to active data rather than the platform’s entire multi-year history.

14.2 Environment parity

Staging environments run against a synthetic loan population sized to a meaningful fraction of production (not just a handful of test loans), because many of the bugs that matter in a system like this — sharding hot spots, cache stampedes, batch-window overruns — only manifest at realistic scale. A staging environment with 50 loans will never catch a bug that only appears when 4,096 shards are running concurrently against contended database connections.

15

Design Patterns & Anti-patterns

Pattern

✓ CQRS

Separating the ledger (write model) from the balance projection (read model) is what makes both writes and reads independently scalable.

Pattern

✓ Event Sourcing

The ledger itself is an event log; current state is always derivable by replaying events, which is invaluable for audits and bug investigation.

Pattern

✓ Idempotent Consumer

Every worker treats message delivery as at-least-once and uses idempotency keys to make processing effectively-once.

Pattern

✓ Sharding / Partitioning

Splits an otherwise unmanageable single dataset into independently scalable, independently recoverable units.

✗ Mutable balance column as source of truth

  • Storing only a running balance and updating it in place destroys history and makes reconciliation and audits nearly impossible.

✗ Synchronous cross-service chains

  • Having Payment Service directly call Accrual Service synchronously creates tight coupling and cascading failures; events decouple this safely.

✗ Floating-point money math

  • Using double / float for currency introduces rounding drift that compounds into real accounting discrepancies at scale.

✗ One giant nightly job

  • A single-threaded, non-shardable batch job doesn’t scale past a modest loan book and has no partial-failure recovery story.
16

Best Practices & Common Mistakes

  • Version your formulas. Store a formula_version on every ledger entry so historical entries remain explainable even after the calculation logic evolves.
  • Never round in intermediate steps. Only round to currency minor units at the final posting point; round consistently with an agreed rounding mode (typically banker’s rounding).
  • Treat business date, not wall-clock date, as authoritative. Loans operate on a “business day” calendar (accounting for weekends, holidays, and timezone of the servicing entity), which is often not the same as the server’s local date.
  • Design for replay from day one. Being able to safely re-run any past day’s accrual (e.g., after a bug fix) is what turns an incident into a non-event.
  • Common mistake: forgetting leap years in Actual/365 vs Actual/Actual conventions, silently under- or over-charging interest by a day’s worth annually.
  • Common mistake: not handling loan status transitions (delinquency, charge-off, bankruptcy) as first-class accrual rules, leading to loans that keep accruing interest when regulation requires them to stop.
  • Common mistake: under-provisioning the idempotency store’s TTL, so a very delayed retry no longer finds its key and double-posts.
  • Best practice: make every downstream effect of an accrual (notifications, projections, analytics exports) derive from the ledger’s change stream rather than being triggered directly by the worker — this way, replaying history for a bug fix automatically replays every downstream effect correctly too, instead of requiring separate manual backfills.
  • Best practice: build the reconciliation job before the system goes live with real customer money, not after the first incident — it should be considered a core deliverable of the project, not an afterthought bolted on post-launch.
  • Common mistake: conflating “the batch job ran” with “the batch job succeeded.” A job that completes without errors but silently skipped a shard due to a coordination bug can look healthy on a naive dashboard while quietly missing millions of loans — always verify completion against an expected count, not just an absence of exceptions.
  • Best practice: keep the accrual calculation function pure (no side effects, deterministic given its inputs) so it can be unit tested exhaustively and safely run in shadow mode without any risk of accidentally mutating state.
17

Testing & Data Validation Strategy

Financial calculation logic demands a testing discipline beyond typical CRUD services, because a subtle bug doesn’t just crash a request — it silently mis-states money across millions of accounts before anyone notices.

17.1 Golden file / reference testing

Interest formulas are tested against a large table of pre-verified expected outputs (“golden files”), independently computed by finance / actuarial teams using spreadsheet tools, covering every day-count convention, leap years, month-boundary edge cases, and compounding rule the platform supports. Any change to InterestCalculator must reproduce every golden value exactly before it can ship — this is the single highest-value test suite in the codebase.

17.2 Property-based and fuzz testing

Beyond fixed examples, property-based tests assert invariants that must hold for any valid input — for example, “daily interest is never negative for a positive principal and positive rate,” or “the sum of all daily accruals over exactly one year approximately equals principal × rate, within rounding tolerance.” These catch edge cases human-written examples miss.

17.3 Shadow runs before formula changes go live

Before a new formula_version is promoted from canary to the full loan book, it runs in “shadow mode” — computing results alongside the live formula without posting them — so any divergence is caught and investigated against real production data before it can affect a single customer’s balance.

17.4 Reconciliation testing in CI/CD

The nightly reconciliation job itself (comparing ledger totals against independently-derived expected totals) is exercised in staging against synthetic loan populations sized to mirror production, as part of the deployment pipeline — catching regressions in the reconciliation logic itself, not just the accrual logic.

18

Real-World / Industry Examples

Card issuers

Large card issuers

Run daily periodic-rate accrual across hundreds of millions of accounts using exactly this batch-plus-event hybrid model, with heavy investment in reconciliation tooling because credit card interest calculations are among the most heavily regulated and litigated areas of consumer finance. Regulatory scrutiny (such as Truth in Lending Act disclosures in the US) means the audit trail design described in this tutorial isn’t optional polish — it’s a compliance requirement with real legal consequences for getting it wrong.

Fintech

Fintech lenders (personal loans, BNPL)

Fintech lenders that scaled rapidly typically evolved from a single-database “compute balance on read” model to a sharded, event-sourced ledger precisely when their loan book crossed into the tens of millions — the same architectural inflection point this tutorial designs for directly. Many of these platforms report that the hardest migration wasn’t the sharding itself, but re-deriving historical balances correctly when moving from a mutable-balance model to an immutable ledger, which is exactly why building the ledger-first from day one avoids an enormously expensive re-platforming project later.

Core banking

Core banking platforms

Historically ran mainframe COBOL batch jobs for exactly this workload; modern re-platforming efforts almost universally replace that single sequential job with a Kafka-partitioned, horizontally-scaled worker fleet like the one described here, while keeping the same immutable, double-entry ledger philosophy that mainframe banking got right decades ago. Interestingly, the core accounting discipline (append-only, double-entry, reversal-not-mutation) hasn’t changed in centuries — what’s changed is that it now runs across a fleet of horizontally-scaled commodity servers instead of a single mainframe.

Marketplace lending

Marketplace and installment lenders

BNPL-style products face an additional wrinkle this architecture handles cleanly: a single purchase can spawn a brand-new short-term loan instantly at checkout, meaning the Loan Account Service must support extremely high-throughput loan creation, not just accrual on existing loans — the same sharded, event-driven backbone absorbs both workloads without a separate system.

19

Frequently Asked Questions

Q1Why not just recompute the full loan history on every balance request?

It’s correct but doesn’t scale — summing years of daily ledger entries per request at a million-requests-per-minute load would overwhelm any database. The projection cache trades a small, bounded eventual-consistency window for massive read scalability.

Q2What happens if the nightly batch doesn’t finish before the 2-hour window?

Incomplete shards are tracked by the Orchestrator; the run auto-continues past the target window rather than truncating, while alerting on-call. The window is a target SLA for capacity planning, not a hard cutoff that drops loans.

Q3How do you handle a rate change that takes effect mid-day?

The rate-change event carries an effective timestamp; the day’s accrual is split into two ledger entries — interest at the old rate up to the effective moment, and at the new rate afterward — preserving full auditability of exactly which rate applied when.

Q4Why Kafka specifically, and not a simpler job queue?

Kafka’s partitioned log gives natural sharding for parallel consumption, durable replay for recovery and reconciliation, and high sustained throughput — all directly needed here, versus a simpler queue that would need bolt-on replay and partitioning support.

Q5How is regulatory retention of 7+ years handled without the database growing unbounded?

Ledger table partitions older than an active window (e.g., 18 months) are archived to cheaper, immutable object storage (e.g., WORM-configured cold storage) while remaining queryable for audits, keeping the hot transactional database lean.

Q6How would you test that the interest math is correct before it ever touches production?

Golden-file reference testing against independently-verified expected values, property-based tests for universal invariants, and shadow-mode runs of any new formula version alongside the live one before cutover — all three layers described in the Testing section, because unit tests alone don’t catch domain-specific edge cases like leap years or convention mismatches.

Q7What if two events for the same loan arrive out of order — a rate change and an accrual trigger?

Every event carries a business-effective timestamp, not just an arrival timestamp. The Ledger Service orders processing by effective time within a loan, and the accrual calculation always re-reads the loan’s current authoritative state (from the database, not a possibly-stale cache) at the moment of posting, so out-of-order delivery doesn’t produce out-of-order financial effects.

Q8Could this system use a single global lock to simplify concurrency instead of optimistic concurrency control?

A global lock would serialize all writes across the entire loan book, destroying the horizontal scalability the whole design exists to achieve. Per-loan optimistic concurrency control keeps contention scoped to the rare case where two writers touch the same loan at the same moment — a tiny fraction of total traffic — rather than serializing unrelated loans against each other.

Q9How do you handle a loan that changes day-count convention or portfolio partway through its life, for example after a loan modification?

The portfolio and day-count convention are treated as time-versioned attributes of the loan, not fixed-forever values. A modification event writes a new effective-dated policy record, and every accrual calculation resolves “which convention applied on this specific business date” from that history rather than always reading the loan’s current setting — the same effective-dating principle used for rate changes, applied consistently to any policy attribute that can change over a loan’s life.

Q10Is this architecture over-engineered for a smaller lender with only a few hundred thousand loans?

Fair challenge, and the honest answer is: partly, yes, if taken wholesale on day one. A smaller lender can start with a single well-indexed database, a simpler scheduled job instead of a full Kafka-based fan-out, and a single-region deployment — while still keeping the core correctness principles (immutable ledger, idempotent writes, BigDecimal arithmetic, versioned formulas) from the very first line of code. Those principles cost almost nothing at small scale and save an enormous, painful re-architecture later; the sharding, Kafka fan-out, and CQRS projection layer are the pieces that should be introduced incrementally as the loan book and traffic actually demand them, not built speculatively in advance of real need.

20

Summary & Key Takeaways

A daily interest accrual engine for millions of loans is fundamentally a distributed batch-processing problem wrapped around a financial-grade correctness core. The architecture succeeds by turning what began, decades ago, as a single mainframe conveyor belt into thousands of small, parallel, independently-verifiable units of work — while preserving the same append-only, double-entry ledger discipline that classical accounting settled on centuries before any of this ran on a computer.

📌
Key takeaways
  • Shard the loan book so a single “impossible” sequential job becomes thousands of small, parallel, independently-recoverable units.
  • Treat the ledger as immutable, event-sourced, and append-only — never a mutable balance column.
  • Make every write idempotent, so at-least-once delivery (the reality of any distributed queue) can never become double-posting.
  • Separate the read path (CQRS + cache) from the write path, so a million requests per minute of balance checks never touches the transactional ledger database.
  • Use fixed-point decimal arithmetic with explicit, versioned rounding rules — financial correctness has zero tolerance for floating-point drift.
  • Build reconciliation, replay, and audit trails from day one, not bolted on after the first regulatory incident.

Master this design and you’ve internalized patterns — CQRS, event sourcing, idempotent consumers, and sharding — that apply far beyond lending, to any system where correctness and internet-scale throughput must coexist. The same building blocks show up in payment processing, inventory management, and any domain where “the number must be exactly right” and “the number must be available to millions of users instantly” are both non-negotiable requirements at the same time.

📡
One mental model to carry with you

If there’s one idea to carry out of this tutorial, it’s this: scale and correctness aren’t opposing forces to be traded off against each other — they’re two problems that get solved by two different, cooperating parts of the same architecture, the write-optimized immutable ledger and the read-optimized eventually-consistent projection, each doing the one job it’s actually good at.