Building Financial-Grade Double-Entry Bookkeeping with Spring Boot 3: From Domain Model to End-of-Day Reconciliation
This article walks through implementing a financial-grade double-entry bookkeeping system using Spring Boot 3, MyBatis, and MySQL, covering domain modeling with immutable entries and derived balances, a posting engine with idempotency and debit-credit validation, balance calculation via snapshots and incremental deltas, end-of-day trial balance verification, cross-system reconciliation, and production concerns like precision handling, sharding strategies, and hot-account mitigation.
Domain Model Design
Five Core Models
Double-entry bookkeeping can be summarized as: vouchers are the atomic unit of recording, entries are the decomposition of a voucher into individual accounts, and balances are the aggregated result of entries.
Accounting subjects form the classification skeleton, determining account nature and balance direction; accounts are concrete accounting objects under subjects (e.g., a specific user, merchant, or internal account); vouchers carry a single recording act and serve as the natural idempotency key; entries are the debit/credit records of a voucher on a specific account — the single source of truth; balance snapshots materialize point-in-time balances to accelerate queries and calibration.
Relationships: subject one-to-many accounts, voucher one-to-many entries, account one-to-many entries, account one-to-many balance snapshots. Subject tables are usually configuration data; the DDL below lists only the four tables tightly coupled to the posting pipeline.
Table Structures
CREATE TABLE `account` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`account_no` VARCHAR(32) NOT NULL COMMENT '账户号',
`subject_code` VARCHAR(32) NOT NULL COMMENT '会计科目编码',
`account_type` VARCHAR(16) NOT NULL COMMENT 'ASSET/LIABILITY/EQUITY/INCOME/EXPENSE',
`direction` VARCHAR(8) NOT NULL COMMENT '余额方向 DEBIT/CREDIT',
`currency` CHAR(3) NOT NULL DEFAULT 'CNY',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 0冻结',
`version` INT NOT NULL DEFAULT 0,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_account_no` (`account_no`)
) ENGINE=InnoDB;
CREATE TABLE `voucher` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`voucher_no` VARCHAR(40) NOT NULL,
`biz_type` VARCHAR(32) NOT NULL COMMENT '业务类型:RECHARGE/PAY/REFUND...',
`biz_no` VARCHAR(64) NOT NULL COMMENT '业务单号,幂等键之一',
`voucher_date` DATE NOT NULL COMMENT '记账日期',
`period` CHAR(7) NOT NULL COMMENT '账期 yyyy-MM',
`debit_total` DECIMAL(20,4) NOT NULL,
`credit_total` DECIMAL(20,4) NOT NULL,
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '1已记账 2已冲正',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_voucher_no` (`voucher_no`),
UNIQUE KEY `uk_biz` (`biz_type`, `biz_no`), -- idempotency unique index
KEY `idx_period` (`period`)
) ENGINE=InnoDB;
CREATE TABLE `entry` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`voucher_no` VARCHAR(40) NOT NULL,
`account_no` VARCHAR(32) NOT NULL,
`direction` VARCHAR(8) NOT NULL COMMENT 'DEBIT/CREDIT',
`amount` DECIMAL(20,4) NOT NULL COMMENT '恒为正数',
`currency` CHAR(3) NOT NULL,
`period` CHAR(7) NOT NULL,
`summary` VARCHAR(128) NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
KEY `idx_account_id` (`account_no`, `id`), -- incremental pull by account
KEY `idx_voucher` (`voucher_no`),
KEY `idx_period` (`period`)
) ENGINE=InnoDB;
CREATE TABLE `account_balance` (
`account_no` VARCHAR(32) NOT NULL,
`currency` CHAR(3) NOT NULL,
`debit_total` DECIMAL(20,4) NOT NULL DEFAULT 0,
`credit_total` DECIMAL(20,4) NOT NULL DEFAULT 0,
`version` INT NOT NULL DEFAULT 0,
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`account_no`, `currency`)
) ENGINE=InnoDB;
CREATE TABLE `balance_snapshot` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`account_no` VARCHAR(32) NOT NULL,
`currency` CHAR(3) NOT NULL,
`snapshot_date` DATE NOT NULL,
`balance` DECIMAL(20,4) NOT NULL,
`last_entry_id` BIGINT NOT NULL COMMENT '该余额已覆盖的最大分录ID',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_acc_ccy_date` (`account_no`, `currency`, `snapshot_date`)
) ENGINE=InnoDB;Entries Are Facts, Balances Are Derived
This is the most critical rule in the design: entry is fact — once written, never updated or deleted; account_balance is derived data, existing only for performance, and can be recomputed at any time via SUM(entry); balance_snapshot is a materialized cache of derived data to avoid full aggregation.
Engineering disciplines that follow:
Business code must never directly UPDATE account_balance. Balances are only incrementally updated by the posting engine in the same transaction that writes entries.
A wrong balance is not a disaster — run a calibration job to recompute; a wrong entry is a disaster — can only be corrected via a reversing voucher.
Therefore the write path of entry must be as narrow and simple as possible, ideally a single entry point.
Posting Engine Implementation
Business Only Describes Debit/Credit Direction
The posting engine should not understand business semantics like "recharge", "payment", "refund". It only understands debit and credit. The caller translates the accounting entry; the engine validates and persists.
public record PostingLine(String accountNo, Direction direction, BigDecimal amount, String summary) {}
public record PostingRequest(
String bizType,
String bizNo,
LocalDate voucherDate,
String summary,
List<PostingLine> lines) {}Engine Main Flow
@Service
@RequiredArgsConstructor
public class PostingEngine {
private final VoucherMapper voucherMapper;
private final EntryMapper entryMapper;
private final AccountBalanceMapper balanceMapper;
@Transactional(rollbackFor = Exception.class)
public String post(PostingRequest req) {
// 1. Basic validation: non-null, at least two lines, positive amounts, valid direction
validate(req);
// 2. Debit-credit balance check — engine's last line of defense
BigDecimal debitSum = sum(req, Direction.DEBIT);
BigDecimal creditSum = sum(req, Direction.CREDIT);
if (debitSum.compareTo(creditSum) != 0) {
throw new UnbalancedVoucherException(debitSum, creditSum);
}
// 3. Idempotency: unique index is the ultimate guard; catching duplicate key is more reliable than check-then-insert
Voucher exist = voucherMapper.selectByBiz(req.bizType(), req.bizNo());
if (exist != null) {
return exist.getVoucherNo();
}
// 4. Persist voucher + entries
String voucherNo = NoGenerator.next("V");
try {
voucherMapper.insert(Voucher.of(voucherNo, req, debitSum));
} catch (DuplicateKeyException e) {
// Concurrent duplicate submission, return existing voucher
return voucherMapper.selectByBiz(req.bizType(), req.bizNo()).getVoucherNo();
}
entryMapper.batchInsert(buildEntries(voucherNo, req));
// 5. Update balances, sorted by account_no to avoid deadlocks
applyBalance(req.lines());
return voucherNo;
}
}Transaction Boundaries
The transaction boundary only covers "voucher + entry + balance". A practical rule: no external I/O inside the transaction — no RPC, no MQ publish, no HTTP calls. External calls stretch lock holding from milliseconds to hundreds of milliseconds, killing throughput, and in distributed scenarios can cause "local committed, remote timed out" inconsistencies.
Correct approach: after local transaction commits, use a transaction log table with async delivery to trigger external actions. The local message table pattern serves this purpose.
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onVoucherPosted(VoucherPostedEvent e) {
messageProducer.send(e); // send only after commit
}Concurrency Control
Balance updates are the classic contention hotspot. Clarify a common mistake: if you only accumulate turnover amounts, atomic increment SQL is sufficient — no read-then-write, no optimistic lock version needed.
UPDATE account_balance
SET debit_total = debit_total + #{debit},
credit_total = credit_total + #{credit},
version = version + 1
WHERE account_no = #{accountNo} AND currency = 'CNY'This SQL itself never loses updates. version is mainly reserved for auditing and future conditional updates. True pessimistic or optimistic locking is needed only when you must make business decisions based on current balance, e.g., "verify sufficient balance before debit" or "no overdraft". Pessimistic locking uses SELECT ... FOR UPDATE — strong consistency but hot accounts queue; optimistic locking uses WHERE version = ? — higher throughput on conflict retry, but retry count must be bounded. In practice I default to atomic increment; only accounts requiring balance-condition checks get locking, and hot accounts are further split.
When updating multiple accounts, lock order must be fixed, otherwise transfer A→B and B→A easily deadlock. Sorting by account_no is the simplest method.
private void applyBalance(List<PostingLine> lines) {
Map<String, List<PostingLine>> byAccount = lines.stream()
.collect(Collectors.groupingBy(PostingLine::accountNo));
byAccount.entrySet().stream()
.sorted(Map.Entry.comparingByKey()) // fixed lock/update order
.forEach(e -> {
BigDecimal debit = e.getValue().stream()
.filter(l -> l.direction() == Direction.DEBIT)
.map(PostingLine::amount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal credit = e.getValue().stream()
.filter(l -> l.direction() == Direction.CREDIT)
.map(PostingLine::amount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
balanceMapper.incrementBalance(e.getKey(), debit, credit);
});
}High-contention accounts can also be serialized at the application layer: hash account_no modulo a fixed thread pool queue, guaranteeing sequential execution for the same account. This moves DB lock contention to the application layer, usually more effective than simply increasing connection pool size.
Three Granularities of Idempotency Keys
Idempotency must be layered.
Business idempotency relies on (biz_type, biz_no) unique index — the fundamental layer, ensuring the same business serial number posts only once. Request idempotency sits at the API layer, using "client_id + request_serial" to deduplicate, preventing RPC retries. Message idempotency sits at the MQ consumer, using message ID to deduplicate, preventing redelivery.
Only the first layer is strong consistency and unavoidable because it lives on the DB unique index. The other two are optimizations to reduce invalid requests hitting the posting engine.
A pitfall: business serial numbers may be reused across different business types. Therefore the idempotency key must include biz_type, not just biz_no. If not defined early, splitting business lines later becomes painful.
Balance Calculation Strategies
Three Common Approaches
Pure aggregation is the most direct: every balance query runs SELECT SUM(...). Works when entries are few; degrades linearly beyond a million rows, basically unacceptable.
Redundant balance field is the mainstream approach: update balance on every posting. Good read performance, but introduces lock contention, and must be paired with a calibration mechanism, otherwise an error may go undetected for a long time.
Snapshot plus incremental entries is a compromise: periodically generate snapshots; reads use snapshot balance plus incremental entries. It balances read performance and correctness, especially suitable for many accounts with long entry histories. In practice I usually combine both: redundant balance serves online queries, snapshots serve reconciliation and recomputation.
Snapshot + Incremental Implementation
Core idea: snapshot records "balance up to last_entry_id ", queries only aggregate the small delta after that point.
Must respect balance direction. Debit-balance accounts (assets, expenses) increase on debit, decrease on credit; credit-balance accounts (liabilities, equity, income) increase on credit, decrease on debit. Cannot universally apply "debit minus credit", otherwise liability-type account balances compute backwards.
public BigDecimal currentBalance(String accountNo, String currency) {
Account account = accountMapper.selectByNo(accountNo, currency);
if (account == null) {
throw new AccountNotFoundException(accountNo);
}
BalanceSnapshot snap = snapshotMapper.findLatest(accountNo, currency);
long fromId = snap == null ? 0L : snap.getLastEntryId();
List<Entry> deltas = entryMapper.selectAfter(accountNo, currency, fromId);
BigDecimal delta = deltas.stream()
.map(e -> signedDelta(account.getDirection(), e))
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal base = snap == null ? BigDecimal.ZERO : snap.getBalance();
return base.add(delta);
}
private BigDecimal signedDelta(Direction accountDirection, Entry e) {
boolean increase = accountDirection == e.getDirection();
return increase ? e.getAmount() : e.getAmount().negate();
}Snapshot generation can run at day-end batch, trigger every N entries, or adjust dynamically by account activity. When generating, first take MAX(id), then aggregate up to that ID, ensuring last_entry_id and balance match.
Balance Calibration Job
Balance is derived data, so recomputation capability is mandatory. Calibration job runs daily at midnight, compares per account. For small datasets, full recomputation is simplest:
SELECT e.account_no,
e.currency,
SUM(CASE WHEN e.direction = 'DEBIT' THEN e.amount ELSE 0 END) AS debit_total,
SUM(CASE WHEN e.direction = 'CREDIT' THEN e.amount ELSE 0 END) AS credit_total
FROM entry e
GROUP BY e.account_no, e.currency;For large datasets, segment by snapshot: compute forward from the latest snapshot, avoid full table scan. Compare computed results with account_balance; differences exceeding threshold trigger alerts and write to balance_diff error table for manual confirmation.
Do not auto-repair balances. A difference indicates a problem somewhere; auto-smoothing only hides it, making next reconciliation harder. Calibration job discovers; repair requires human confirmation.
End-of-Day Processing & Reconciliation
Day-end batch is the settlement moment, typically four steps: period switch, trial balance, cross-system reconciliation, error handling.
Trial Balance
Trial balance is double-entry's self-check mechanism, three levels.
Voucher level: every voucher debit equals credit. Validated at posting time; day-end re-checks as a safety net.
SELECT voucher_no
FROM voucher
WHERE period = #{period}
AND debit_total <> credit_total;Grand total level: total debit turnover equals total credit turnover for the entire book.
SELECT SUM(debit_total) - SUM(credit_total) AS diff
FROM voucher
WHERE period = #{period};Balance level: account balances aggregated by subject satisfy the accounting identity. Expense accounts are debit-direction, income accounts are credit-direction; after aggregation verify Assets + Expenses = Liabilities + Equity + Income.
SELECT a.account_type,
SUM(CASE WHEN a.direction = 'DEBIT'
THEN b.debit_total - b.credit_total
ELSE b.credit_total - b.debit_total END) AS balance
FROM account_balance b
JOIN account a ON a.account_no = b.account_no AND a.currency = b.currency
GROUP BY a.account_type;Any level fails, day-end job immediately halts and alerts, blocking entry into next period. This gate cannot be skipped; skipping once may cost a week of investigation later.
Cross-System Reconciliation
Internal self-consistency does not guarantee external consistency. Reconciliation essence: two independent ledgers record separately, then compare.
Typical flow: pull channel reconciliation file, match against local vouchers by "business serial + amount" bilaterally, classify into four categories: both match, we have they don't, they have we don't, amount mismatch. Differences written to recon_diff table, routed to handling flows by error type.
Reconciliation job must be idempotent and re-runnable. Use "period + channel" as idempotency key; on re-run, either clean current period results then recompute, or upsert directly. Never make it dirty on each run.
Suspense Account for Differences
When differences found, never just adjust balance to zero. Instead express the difference via a voucher. Standard practice: set up a transit subject, e.g., "Pending Property Gains/Losses" or "Other Receivables — Under Investigation", post the difference first, creating a real entry:
借:其他应收款—待查 100.00
贷:银行存款 100.00This puts the difference into the ledger, trial balance still holds, and leaves a clear todo item. After root cause identified, a reversing entry moves it to the correct subject, keeping the full chain auditable.
Reversal & Red/Blue Ink
Reversal is the standard financial correction action; two engineering implementations, do not mix.
Reverse entry method: amounts stay positive, debit/credit directions swap. Original "Debit A 100 / Credit B 100", reversal writes "Debit B 100 / Credit A 100". Benefit: all amount stay positive, aggregation logic simple — just add/subtract by direction.
Negative amount method: keep original direction, amount becomes negative. Suits same-direction reduction scenarios, e.g., expense reduction, revenue reduction, where flipping direction would pollute that direction's cumulative turnover. Requires an is_red flag; aggregation uses CASE WHEN is_red = 1 THEN -amount ELSE amount END.
Reversal voucher itself must balance, and link to original via ref_voucher_no, forming a reversal chain. Original voucher status becomes "reversed" but is never deleted.
Performance & Scaling
Sharding
Beyond 100M rows single table fails. Sharding key choice depends on query patterns. entry sharded by account_no — account flow queries hit single shard (highest frequency); but voucher-dimension queries broadcast. voucher sharded by voucher_no — voucher detail queries hit single shard. Cost: cross-shard transactions — a transfer involving two accounts on different shards cannot be covered by a local transaction.
Options: same database multi-table (shard tables not databases) to avoid distributed transactions, upgrade to TCC or local message table + eventual consistency only when forced. Or shard by business entity — colocate related accounts on same shard, letting most transactions stay single-shard. In practice, entity-based sharding yields highest ROI: sharding key should not be account_no, but book-set, merchant, or entity ID, because most business flows occur between accounts within the same entity.
Hot Accounts
E-commerce flash sales, platform fee income master account, payment channel in-transit funds account — typical hotspots. Single account thousands of postings/second, row locks become bottleneck.
Three common splitting techniques. Sub-account pool: split one logical account into N physical sub-accounts (e.g., FeeIncome-01 to -10), distribute postings by round-robin or hash, day-end aggregate to master, queries read aggregated value. Async posting: business side synchronously writes "posting request" and returns immediately; background batch consumes and merges into ledger, costing second-level latency, requiring business to accept "posting in progress" state. Batch merge: combine multiple entries for the same account within a time window into one large entry for balance update; fact layer retains details, snapshot layer records only merged result.
Async Posting & Eventual Consistency
Key design: "persist request first, process later".
@Transactional
public String submit(PostingRequest req) {
// Local transaction: insert posting request table with idempotency key
PostingTask task = taskMapper.insertOrGet(req);
// After commit, publish MQ via AFTER_COMMIT listener
eventPublisher.publishEvent(new TaskCreatedEvent(task.getId()));
return task.getId();
}Query side must expose "posting in progress" state; cannot assume balance changes visible immediately after submit. For balance-sensitive operations like withdrawal pre-check, either use synchronous posting or read "confirmed balance + in-transit changes". This product interaction must be clarified upfront, otherwise users think money is lost.
Production Pitfalls
Amount Precision
Never use double or float for money. 0.1 + 0.2 = 0.30000000000000004 — this error is an accident in accounting systems.
Standard: always use new BigDecimal("100.00") string constructor, forbid new BigDecimal(0.1). Unify scale = 4 or business-defined 2; call setScale before add/subtract; multiplication/division must explicitly specify RoundingMode. Compare with compareTo() == 0; equals() returns false due to scale differences ( 1.0 vs 1.00). Encapsulate a Money value object binding currency and amount, preventing cross-currency addition. Database uses DECIMAL(20,4), mapping to Java BigDecimal; verify JDBC driver and type handler don't silently convert DECIMAL to double. For pure CNY scenarios, a more robust alternative is long storing fen (smallest currency unit), completely bypassing decimal issues.
Timezone & Accounting Period
Server, database, JVM all unified to Asia/Shanghai; container startup explicitly sets -Duser.timezone=Asia/Shanghai, don't rely on host default.
Don't hardcode calendar month for accounting period; store explicitly as period CHAR(7). Day-cut timestamp defined by business, usually 23:59:59, but banks or payment institutions may use 22:00 or next-day 00:00 — must be configurable, not assumed. After period close, posting prohibited; supplementary entries must go through adjustment vouchers with approver recorded, otherwise historical reports keep changing and audit fails. Cross-midnight transaction attribution needs explicit rules: by transaction occurrence time or by posting time; when they differ, document in product spec, don't rely on oral tradition.
Deadlock Investigation in Concurrent Posting
Deadlocks are common in posting scenarios. Typical causes: inconsistent lock order — transfer A→B and B→A wait on each other; unique index conflicts causing gap locks — high-concurrency voucher inserts may hit gap locks on uk_biz; large transactions holding locks too long — transaction does RPC or large batch updates.
Investigate using MySQL 8.0 performance_schema.data_locks and data_lock_waits:
SELECT * FROM performance_schema.data_locks;
SELECT * FROM performance_schema.data_lock_waits;Traditional method also works:
SHOW ENGINE INNODB STATUS/G -- view LATEST DETECTED DEADLOCKRecommend embedding lock-wait timeout monitoring in posting engine. Set innodb_lock_wait_timeout to 3-5 seconds, not default 50s; fast failure is far better than long hangs. Hanging connections drag down the entire request chain.
Landing Sequence
The design boils down to one sentence: use immutable entries to record facts, use recomputable balances for performance, use debit-credit balance to guarantee self-consistency. Debit-credit balance enforced by engine entry validation plus day-end three-level trial balance safety net; idempotency enforced by (biz_type, biz_no) unique index at DB layer, application layer only optimizes; traceability guaranteed by "entries never modified + reversal via reverse voucher + balances recomputable" three-part safeguard.
If I prioritize landing order: Phase 1 — single DB single table, get voucher/entry/balance trio working, build day-end trial balance and balance calibration jobs. Don't rush sharding; first get accounts correct. Phase 2 — onboard second business line, validate model generality, complete suspense-account and reversal flows. Phase 3 — tackle hot accounts with splitting and async, shard by entity. Phase 4 — only then consider multi-currency, multi-book, multi-legal-entity; that's when FX conversion and consolidated reporting become real problems.
Further evolution directions are interesting. Event sourcing treats entries as sole event stream, balances become pure projections, arbitrary point-in-time replay naturally supported. Accounting middle-platform extracts posting capability into independent service, business lines integrate via standard accounting entry APIs. Real-time reconciliation uses stream computing to compress T+1 to minutes, exposing discrepancies at occurrence rather than next day.
However technology evolves, the most fundamental rule remains: every cent of movement must have a debit and a credit, and be traceable.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Xiaolin Talks Programming
Focuses on sharing original technical insights. Senior architect at a top tech company with years of experience in technical architecture and management, and extensive interview experience. Offers one-on-one technical coaching, guiding you from beginner to architecture design to technical management. Follow for free learning resources. Free one-on-one interview coaching to help you land offers quickly.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
