Double-entry ledger engine in Spring Boot with balance validation and immutable audit trail.
A backend service that models real double-entry bookkeeping: every transaction is split into balanced debit/credit entries, balances are always derived from entry history (never stored as mutable state), and nothing is ever deleted, only appended.
This service is the source of truth for account balances in a small distributed system. It is deliberately general-purpose: it has no knowledge of payments, users making purchases, or any particular business flow. It only knows how to accept balanced transactions and record them permanently. See Interconnected Services for how it fits alongside the Payment Processor.
- Every
Transactioncreates two or moreEntryrecords (debits and credits) across accounts. - The system enforces
sum(debits) == sum(credits)for every transaction, or it's rejected. - Balances are computed on the fly from entry history, never stored as a mutable field.
- All writes are append-only. Corrections happen via reversal, not deletion or update.
- Java 17
- Spring Boot 4.1.0
- Spring Data JPA: persistence layer
- PostgreSQL: primary datastore
- Spring Security: authentication/authorization
- Lombok: boilerplate reduction
- Spring Validation: request-level input validation
- Spring Actuator: health/metrics endpoints
- springdoc-openapi: Swagger UI / OpenAPI documentation
- Maven: build tool
The codebase follows a domain-driven design (DDD) structure, organized by bounded context rather than technical layer:
com.king.ledgerengine
├── domain
│ ├── account # Account entity, repository, service, controller
│ ├── entry # Entry entity, repository (balance computation)
│ ├── transaction # Transaction entity, repository, service, controller
│ └── user # User entity, repository, service, controller
├── config # Spring framework configuration (security, beans)
├── shared # Cross-cutting concerns (exceptions, common DTOs)
└── LedgerEngineApplication
| Entity | Description |
|---|---|
Account |
A ledger bucket. Has a name, type (ASSET, LIABILITY, EQUITY, REVENUE, EXPENSE), and currency. Owned by a User. |
Transaction |
A business event. Has a description and status (PENDING, POSTED, REVERSED). Groups one or more Entry records. |
Entry |
A single debit or credit line against an account, tied to a transaction. Immutable once created. |
User |
The identity that owns accounts and authenticates against the API. |
Balance is intentionally not a stored, mutable field. It's a derived value:
balance(account) = SUM(credits) - SUM(debits)
Storing it separately would risk drift between the "official" entry history and a cached number, exactly the class of bug double-entry accounting is designed to prevent. Balance is computed via an indexed SUM() query over entries.account_id at read time.
| Method | Endpoint | Description |
|---|---|---|
POST |
/auth/register |
Register a new user |
POST |
/auth/login |
Sign an existing user in |
GET |
/users/me |
Get an authenticated user |
POST |
/accounts |
Create a new account |
GET |
/accounts |
Get all accounts by a owned by the authenticated user |
GET |
/accounts/{id} |
Get account by ID |
GET |
/accounts/{id}/balance |
Get current computed balance |
GET |
/accounts/{id}/entries |
Get full entry history for an account |
POST |
/transactions |
Create a transaction with balanced entries (requires Idempotency-Key header) |
POST |
/transactions/deposit |
Add funds to the authenticated user's account from the System Deposit Account |
POST |
/transactions/{id}/reverse |
Reverse a posted transaction via offsetting entries |
- Balanced-transaction validation: a transaction is rejected at the service layer unless
sum(debits) == sum(credits). - Idempotency keys: every
POST /transactionsrequest requires anIdempotency-Keyheader; retried requests with the same key return the original result instead of double-processing. - Atomicity: transaction + entry creation is wrapped in a single
@Transactionalboundary; if any part fails, nothing commits. - Append-only entries: entries have no
UPDATE/DELETEpath. Corrections are made viaPOST /transactions/{id}/reverse, which creates new offsetting entries rather than touching the original transaction. - Database-level constraints: foreign keys,
NOT NULL, and unique constraints (e.g. onidempotency_key) act as a second line of defense beyond application-level checks.
This project is designed to be one component of a small distributed system, not a standalone application in production use. It intentionally accepts transactions from any authenticated caller and has no awareness of where a transaction request originates.
┌─────────────────────┐ RabbitMQ ┌─────────────────────┐
│ Payment Processor │ ───── PaymentCaptured ──▶│ (event consumed │
│ (separate service) │ event │ by listener) │
└─────────────────────┘ └──────────┬──────────┘
│
HTTP POST │ /transactions
▼
┌─────────────────────┐
│ Ledger Engine │
│ (this service) │
└─────────────────────┘
What this means in practice:
- The Payment Processor handles payment authorization, capture, and refund logic entirely on its own. Once a payment is captured, it publishes an event and calls this service's
POST /transactionsendpoint to record the settled movement of funds. - This service has no dependency on the Payment Processor existing. It does not know what a "payment" is, only that it received a request for a balanced transaction between two accounts.
- The two services do not share a database. All communication happens over HTTP, the same as any external caller would use this API.
- This separation means either service can be deployed, scaled, or replaced independently. A future third service (a manual journal entry tool, a batch import job, a different payment provider) could post transactions here using the exact same API, with zero changes to this codebase.
To run this service as part of the full distributed setup:
- Start Postgres for this service (and a separate Postgres database for the Payment Processor, they should not share a database)
- Run this Ledger Engine on its own port, e.g.
server.port=8081 - Run the Payment Processor on a different port, e.g.
server.port=8080, withledger.engine.base-url=http://localhost:8081set in its configuration - Capture a payment in the Payment Processor and confirm the resulting transaction appears here via
GET /accounts/{id}/entries
See the Payment Processor's README for the full end-to-end event flow.
- Java 17+
- Maven (or use the included
./mvnwwrapper) - PostgreSQL running locally
Set your database connection in src/main/resources/application.properties (or via .env, loaded manually at startup):
Properties: src/main/resources/application.properties
server.port=8081
spring.datasource.url=jdbc:postgresql://localhost:5432/ledger_engine
spring.datasource.username=your_username
spring.datasource.password=your_password
spring.jpa.hibernate.ddl-auto=updateENV: .env
POSTGRES_DB_URL=jdbc:postgresql://localhost:5433/ledger_db
POSTGRES_DB=ledger_db
POSTGRES_USER=your_username
POSTGRES_PASSWORD=your_password
JWT_SECRET=your_jwt_secret./mvnw spring-boot:runThe API will be available at http://localhost:8081 (adjust if running standalone rather than alongside the Payment Processor), and Swagger UI at http://localhost:8081/swagger-ui/index.html.
curl -X POST http://localhost:8081/transactions \
-H "Content-Type: application/json" \
-H "Idempotency-Key: unique-key-123" \
-H "X-User-Id: <user-id>" \
-d '{
"description": "Customer deposit",
"entries": [
{ "accountId": "<cash-account-id>", "amount": 100.00, "type": "DEBIT" },
{ "accountId": "<liability-account-id>", "amount": 100.00, "type": "CREDIT" }
]
}'- Core domain model (Account, Transaction, Entry)
- Balanced-transaction validation
- Balance computation via aggregate query
- Idempotency key enforcement
- Transaction reversal (offsetting entries)
- Integration with Payment Processor via HTTP API
- Optimistic locking on Account
- Audit log table
- System-wide reconciliation endpoint
- Multi-currency support with exchange rate snapshots
- Scheduled/recurring transactions
- Event publishing on transaction posting
- API rate limiting
- Amounts are stored as
BigDecimal, not floating-point, to avoid rounding errors inherent todouble/floatin financial calculations. - Entity relationships use
@ManyToOnefromEntryto bothTransactionandAccount: many entries belong to one transaction, many entries belong to one account. - Package-by-domain, not package-by-layer: each bounded context (
account,transaction,entry,user) owns its own entity, repository, service, and controller, rather than grouping all controllers together, all services together, etc. - This service is intentionally source-agnostic. Keeping it decoupled from any single upstream caller (like the Payment Processor) is what makes it reusable as a general-purpose ledger rather than payment-specific infrastructure.