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.
- 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.
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.
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
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.
| 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.
Transactionis the command received from a source document. It has two or moreTransactionLinevalues. The established posting strategies use it as an in-memory command; the reservation protocol persists it temporarily while its account effects are incomplete.TransactionLinenames an account, debit or credit, the originaltransactionAmount, and the functional-currencyledgerAmount.JournalEntryis the durable accounting record created for one transaction. It owns an ordered list ofJournalLinevalues.JournalLinerecords the affected account, debit or credit, both amounts, and the account balance after that line posts.Accountholds 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.
The persistence module registers:
AccountNumberas a validating scalar value mapped throughString;Currencyas a scalarCHARvalue;Instantthrough UTCLocalDateTime;Moneyas 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.
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.
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.
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:installDistThen 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.PipelineWorkerApplicationPIPELINE_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.
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.
The project requires Java 25 and consumes the sibling ORM through a Gradle composite build:
sdk env
./gradlew testIntegration 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.shLicensed under the Apache License 2.0. See Third-Party Notices.