Skip to content

Repository files navigation

Minimal Java ORM — accounting contention demo

This Java 25/PostgreSQL investigation is the flagship validation project for Minimal Java ORM. It posts the same deterministic transaction stream using pessimistic locks, optimistic revisions, and durable asynchronous intake, then exercises a horizontally scalable reservation and journal pipeline.

It is a focused engineering experiment, not a complete accounting product. The interesting parts are the boundaries: plain domain models, real multi-row invariants, small JDBC adapters, explicit transaction strategies, durable work queues, and benchmark results that are kept with their source revisions.

What this demonstrates

  • a domain module with no ORM, JDBC, web, monitoring, or dependency-injection dependency;
  • value-object and ordered-child mappings for AccountNumber, Money, and journal lines without persistence annotations on the models;
  • one posting operation reused behind pessimistic, optimistic, and queued persistence strategies;
  • a generic PostgreSQL-backed queue with non-blocking claims and independently scalable worker stages;
  • correctness checks that reconcile accepted work with durable journal state after every benchmark trial;
  • fixed stability and attribution rules that retain excluded or inconclusive measurements instead of selecting only favorable runs.

Results at a glance

The current source-pinned evidence was collected from clean accounting and ORM revisions. On one local host, the common workload scheduled 60 transactions per second for each strategy:

Strategy Median accepted/s Median request p95 Optimistic conflicts
Pessimistic 60.01 11.47 ms 0
Optimistic 60.02 10.65 ms 19
Eventual 60.00 3.13 ms 0

Eventual request latency ends after durable queue intake; synchronous latency ends after journal commit, so those latency values have different boundaries. All 16,205 accepted transactions across the nine trials produced exactly one journal entry.

The full reservation-to-booking pipeline sustained a median 40.08 completed transactions per second in four eligible one-process-per-stage trials at a scheduled rate of 40/s. A scaling comparison measured 47.80 completed/s for 1:1:1:1:2 processes and 61.35/s for 2:1:1:1:2, but its predefined attribution test was not satisfied. The higher median is therefore retained as an observation, not presented as proof of a reservation-stage bottleneck. All 17 pipeline runs, including excluded measurements, drained exactly to 87,413 journal entries and completed postings with no temporary or queued state left.

Architecture

flowchart LR
    request[Transaction] --> api[AccountingService]
    api --> pessimistic[Pessimistic transaction decorator]
    api --> optimistic[Optimistic retry decorator]
    api --> intake[Durable queued intake]
    pessimistic --> posting[PostingService]
    optimistic --> posting
    intake --> inbox[(QueueMessage)]
    inbox --> worker[Eventual worker]
    worker --> posting
    posting --> repositories[Repository interfaces]
    repositories --> postgres[(PostgreSQL)]

    request -. reservation experiment .-> reservation[ReservationService]
    reservation --> stages[Durable stage queues]
    stages --> journal[JournalService]
    journal --> booking[BookingService]
    booking --> repositories
Loading

Workflow decisions and aggregate state changes stay in domain.application. The persistence module owns JDBC repositories, locking and retry decorators, queue claims, codecs, and transaction boundaries, but delegates transitions to the domain services. The dependency-free monitoring module owns the counters and completion-latency histograms used by workers and benchmarks; app wires the runtime and measurement processes together.

Code tour

Concern Starting point
Posting rules and journal construction PostingService
Reservation transitions ReservationService
Journal and account-booking stages JournalService, BookingService
ORM value, entity, and ordered-list mappings AccountingMappings
Transaction and concurrency decorators SynchronousAccountingService, OptimisticAccountingService
Generic queue claim QueueMessageClaim
Joined statement projection LedgerQuery, LedgerEntryRow
Reproducible benchmark evidence benchmark/evidence

The detailed model and protocols follow below. The investigation sequence, including failed assumptions and corrections, is preserved in JOURNAL.md.

Model

  • Transaction is the command received from a source document. It has two or more TransactionLine values. The established posting strategies use it as an in-memory command; the reservation protocol persists it temporarily while its account effects are incomplete.
  • TransactionLine names an account, debit or credit, the original transactionAmount, and the functional-currency ledgerAmount.
  • JournalEntry is the durable accounting record created for one transaction. It owns an ordered list of JournalLine values.
  • JournalLine records the affected account, debit or credit, both amounts, and the account balance after that line posts.
  • Account holds its number, display name, current balance, pending debit and credit reservations, optional inclusive minimum and maximum balances, and revision.

There is no separate account-line model. JournalLine joins JournalEntry and Account, so account activity is queried from the journal.

The main checks are:

  • a transaction has at least two lines;
  • all ledger amounts use one functional currency;
  • total ledger debits equal total ledger credits;
  • all lines for an account are combined before checking its final balance;
  • every final balance is within the account's inclusive limits.

Original amounts may use several currencies in one transaction. They are retained for audit and reporting, but the journal balances in the ledger currency.

ORM features exercised

The persistence module registers:

  • AccountNumber as a validating scalar value mapped through String;
  • Currency as a scalar CHAR value;
  • Instant through UTC LocalDateTime;
  • Money as a two-column record value.

For example, Money balance becomes balance_minor and balance_currency. JournalEntry.lines is a Hibernate-style ordered list: the ORM writes its zero-based index to line_number; JournalLine declares neither a surrogate ID nor its parent ID.

GeneralLedger is a PostgreSQL view joining journal entries, lines, and account master data. Arbitrary SQL is mapped through the canonical constructor of LedgerEntryRow, including its AccountNumber and three Money components.

Posting strategies

All strategies implement:

public interface AccountingService {
    void post(Transaction transaction);
}

Both synchronous strategies run the same domain PostingService.

pessimistic supplies an account repository that selects every affected account in ID order with FOR NO KEY UPDATE. A persistence decorator runs the service and its repository writes in one transaction.

optimistic supplies an account repository that reads ordinary rows and updates them using revisions. The same decorator rolls back stale writes, rebuilds the transaction-bound repositories, reloads current balances, and retries with bounded jitter. This is intentionally the simple high-contention implementation.

eventual uses the domain QueuedPostingService to check facts contained in the request and add it through TransactionInbox. The JDBC inbox adapter serializes the complete transaction as JSON and inserts one generic QueueMessage row before returning. The queue stores message metadata and an opaque TEXT payload; it has no accounting columns, status, partition, or retry state. Its nullable group_key is generic delivery metadata: messages without a group remain independent, while messages with the same group are processed one at a time.

Each worker first scans a small page ordered by priority and ID without taking locks. It then tries the candidates one at a time with a materialized single-row claim:

WITH candidate AS MATERIALIZED (
    SELECT id, message_id, message_type, group_key, priority,
           schema_version, enqueued_at, payload
    FROM QueueMessage
    WHERE id = ?
    FOR UPDATE SKIP LOCKED
)
SELECT *
FROM candidate
WHERE group_key IS NULL
   OR pg_try_advisory_xact_lock(hashtextextended(group_key, 0))

This fixes both choices before the non-blocking advisory-lock function runs: the database can lock only that queue row and can acquire at most that row's group lock. If the row or group is busy, the worker tries the next candidate and then the next page.

Inside one database transaction, the worker decodes the payload, locks affected accounts in ID order with FOR NO KEY UPDATE, checks account existence, currency, and balance limits, then inserts a final JournalEntry and its lines. If an accounting check rejects the request, it inserts a PostingRejection containing the original envelope, payload, and reason. Either terminal result is followed by deletion of the queue row in the same transaction. Database or unexpected failures roll the transaction back and leave the message available.

The transaction-scoped advisory lock is non-blocking and is released by commit or rollback. A worker that encounters an active group continues to another candidate. Hash collisions can unnecessarily serialize unrelated groups, but cannot allow two messages from the same group to run together. Ordinary eventual-posting messages have no group.

ACCOUNTING_WORKERS controls the number of concurrent virtual-thread workers and defaults to one. Workers share the same service, claim different queue rows, and still serialize changes to the same accounts through ordered account locks. A value of zero is a supported intake-only mode: requests are accepted into the queue but no journal processing runs in that application instance. Idle workers back off to avoid continuously polling an empty queue.

The journal has one persistence operation: enter a complete entry. Its ORM mapping is insert-only, so updates and deletes are rejected. It contains no pending state and never doubles as queue machinery. A request ID already in the queue is rejected as a duplicate. Replaying an ID after its terminal journal or rejection row exists is accepted into the generic queue, then recognized and removed by the worker without applying it again.

Optimistic reservation experiment

The domain ReservationService reduces a transaction to one net AccountEffect per affected account and owns every reservation and release state transition. Persistence stores the temporary transaction and one account command per effect, then independently runnable adapters load one account and apply the service decision through its revision. Result messages are decoded by persistence and passed back to the service. Every successful effect leaves it RESERVED; a permanent rejection releases any successful reservations before the temporary state is deleted.

Reservation results use a higher generic queue priority than account commands. QueueMessage(priority, id) is indexed, allowing a result to reach its coordinator without waiting behind an entire fixed command backlog. The queue remains independent of accounting fields. Reservation results for one temporary transaction share a stage-specific group key, so only one reservation worker can update that transaction at a time. Account commands remain ungrouped and independently claimable.

Completing reservation enqueues one deterministic enter-journal command. The processing adapter loads the transaction and accounts; domain JournalService verifies reservation state and creates the immutable JournalEntry. Persistence inserts it and replaces the command with a journal-entered result in the same database transaction. A request ID that already has a journal entry is reused rather than inserted again. This step neither updates accounts nor deletes the temporary transaction; booked balances and pending reservations therefore remain unchanged at the journal-entered boundary.

The booking-handoff adapter consumes that result and verifies its persisted identities. Domain BookingService changes the transaction and its effects from RESERVED to BOOKING. In the same database transaction persistence replaces the result with one deterministic, ungrouped book-account command per effect. It still does not update an Account. Replaying a result cannot duplicate commands, and a transaction with no effects completes by deleting its temporary state without publishing account work.

The account-booking adapter claims one book-account command and loads only its target account. BookingService moves the reserved amount from pending into the booked balance, then persistence optimistically updates that account. The same database transaction replaces the command with a grouped account-booked result. A conflict retries the whole step; a rollback leaves the balance, pending amount, command, and result unchanged. A replay while the deterministic result is present consumes the duplicate command without booking the account again. The worker never loads or updates the temporary transaction.

The booking-completion adapter consumes account-booked results grouped by a separate booking-completion key and verifies their persisted identities. BookingService changes only that effect from BOOKING to BOOKED and reports when every effect is complete. Persistence consumes the result and either updates or deletes the temporary transaction in the same database transaction. Replays are consumed without another update. This final adapter never updates an account or journal entry. The single journal command and result need no group: each phase handoff replaces one message atomically, and the durable unique keys make replay idempotent.

The immutable journal entry remains the durable request-ID guard after temporary state is deleted. Reservation intake checks it before creating new work, so replaying a completed request cannot reserve or book the accounts again.

Running the pipeline workers

Reservation processing retains its dedicated worker because it reports reservation-command and transaction-result metrics separately. Every later stage uses PipelineWorkerApplication, with one required PIPELINE_STAGE:

Stage Consumes Produces
journal enter-journal journal-entered
booking-handoff journal-entered book-account
account-booking book-account account-booked
booking-completion account-booked completed temporary state

Build the runnable distribution once:

./gradlew :app:installDist

Then run each stage in its own process, for example:

RESERVATION_WORKERS=4 \
java -cp "app/build/install/app/lib/*" \
  dev.minimalorm.accounting.ReservationWorkerApplication

PIPELINE_STAGE=journal PIPELINE_WORKERS=2 \
java -cp "app/build/install/app/lib/*" \
  dev.minimalorm.accounting.PipelineWorkerApplication

PIPELINE_STAGE=booking-handoff PIPELINE_WORKERS=2 \
java -cp "app/build/install/app/lib/*" \
  dev.minimalorm.accounting.PipelineWorkerApplication

PIPELINE_STAGE=account-booking PIPELINE_WORKERS=8 \
java -cp "app/build/install/app/lib/*" \
  dev.minimalorm.accounting.PipelineWorkerApplication

PIPELINE_STAGE=booking-completion PIPELINE_WORKERS=2 \
java -cp "app/build/install/app/lib/*" \
  dev.minimalorm.accounting.PipelineWorkerApplication

PIPELINE_WORKERS is the number of virtual worker threads in that JVM and defaults to one. Run more JVMs to scale a stage horizontally. Set PIPELINE_METRICS_PATH to publish an atomically replaced JSON snapshot; its processed count records committed queue-message transactions. Completion workers also report end-to-end latency from durable intake to final temporary state cleanup. Configure the database connection with the existing ACCOUNTING_DB_* variables.

The full-pipeline harness retains one-second queue and worker observations in JSONL, rejects non-converged measurement windows, and supports balanced baseline/candidate execution. These controls are benchmark-only; the worker runtime has no Prometheus, Micrometer, or other production monitoring dependency.

The reservation path is not exposed as a fourth AccountingService strategy. Its dedicated benchmark runs coordinator and reservation-worker JVMs separately, stops before journal-command processing, and measures horizontal reservation scale without changing the three-strategy comparison. It attributes committed work, retries, and optimistic conflicts separately to account commands and transaction-result coordination, and captures PostgreSQL transaction, WAL, and sampled wait-event deltas over the throughput window.

Expected scale

A representative benchmark run uses:

Table or view Expected rows
Account 1,000
JournalEntry up to 1,000,000
JournalLine roughly 2,000,000-4,000,000
current QueueMessage backlog workload-dependent and temporary
temporary AccountingTransaction current reservation workload only
temporary AccountEffect 2-3 per reservation benchmark transaction
PostingRejection one per terminal accounting rejection
GeneralLedger view no stored rows

JournalLine(account_id, journal_entry_id) supports statement reads. This scale keeps journal query cost independent of queue depth. A queue message uses one row regardless of the number of transaction lines; its payload size grows with the serialized request.

Build and test

The project requires Java 25 and consumes the sibling ORM through a Gradle composite build:

sdk env
./gradlew test

Integration tests use Testcontainers PostgreSQL and skip automatically when Docker is unavailable.

Run the contention comparison described in benchmark/README.md:

./scripts/run-benchmark.sh all safe
./scripts/run-benchmark.sh all boundary
./scripts/run-strategy-comparison.sh
./scripts/run-reservation-benchmark.sh
./scripts/run-pipeline-benchmark.sh

License

Licensed under the Apache License 2.0. See Third-Party Notices.

About

Accounting contention demo for minimal-java-orm

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages