Project Ideas

Production Java Project: Building a Double-Entry Ledger in Java 21

DD
Ankur Ishwar
12 min read Updated Sep 7, 2026
Production Java Project Building a Double-Entry Ledger in Java 21

The Trap of College Java Projects

Walk into any campus recruitment drive in India. Almost every candidate lists the exact same Java project on their resume: a Hospital Management System or a Library Management System built with Core Java, Swing GUI, and MySQL. When the interviewer asks what happens when two users issue a transfer at the exact same millisecond, the candidate draws a blank.

Mass recruiters hire students with these projects because they pay 3.5 LPA to maintain ancient Java 8 codebases. But if your goal is to land a product engineering role paying 12 to 24 LPA at a fintech startup, payment gateway, or high-scale backend team, you need modern engineering proof.

Modern Java in 2026 is built on Java 21 LTS with Virtual Threads (Project Loom), immutable records, pattern matching, and Spring Boot 3. In this tutorial, we will build a production-grade Double-Entry Accounting Ledger Engine. This project handles concurrent financial transactions with ACID guarantees, preventing balance corruption and double-spending.

The Core Principle: Double-Entry Bookkeeping

In financial systems, you never simply update a single account balance column like UPDATE accounts SET balance = balance - 100. If the server crashes halfway through, money vanishes into thin air or duplicates.

Double-entry accounting requires that every financial movement consists of at least two balanced journal entries: a debit and a credit. The fundamental invariant must always hold true:

Total Debits == Total Credits

To transfer ₹5,000 from Rahul to Priya, the system records:

  • Entry 1: Debit Rahul's Account by ₹5,000
  • Entry 2: Credit Priya's Account by ₹5,000

Account balances are derived sums of their historical journal entries, or maintained with strict pessimistic locks during updates.

Step 1: Project Setup with Java 21 and Spring Boot 3

Initialize a Spring Boot 3.3 project using Java 21 and Maven. We enable Virtual Threads in application.properties so the embedded Tomcat server handles thousands of concurrent HTTP connections on lightweight virtual threads rather than OS threads.

# src/main/resources/application.properties
spring.threads.virtual.enabled=true
spring.datasource.url=jdbc:postgresql://localhost:5432/ledger_db
spring.datasource.username=postgres
spring.datasource.password=postgres
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.open-in-view=false

Step 2: Defining Immutable Domain Models with Java Records

Java 21 records provide clean, immutable data carriers. Here are our domain models for the transaction transfer request and ledger entry:

// src/main/java/in/dropoutdeveloper/ledger/dto/TransferRequest.java
package in.dropoutdeveloper.ledger.dto;

import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
import java.util.UUID;

public record TransferRequest(
    @NotNull UUID sourceAccountId,
    @NotNull UUID destinationAccountId,
    @NotNull @DecimalMin(value = "0.01", message = "Transfer amount must be positive") BigDecimal amount,
    @NotNull String idempotencyKey,
    String description
) {}

Step 3: Account Entity with Concurrency Lock

To eliminate race conditions under heavy load, we declare a JPA repository method using LockModeType.PESSIMISTIC_WRITE. This executes a SQL SELECT ... FOR UPDATE on the account rows, serializing concurrent transfer attempts on the same balance.

// src/main/java/in/dropoutdeveloper/ledger/model/Account.java
package in.dropoutdeveloper.ledger.model;

import jakarta.persistence.*;
import java.math.BigDecimal;
import java.util.UUID;

@Entity
@Table(name = "accounts")
public class Account {
    @Id
    private UUID id;

    @Column(nullable = false)
    private String ownerName;

    @Column(nullable = false, precision = 19, scale = 4)
    private BigDecimal balance;

    protected Account() {}

    public Account(UUID id, String ownerName, BigDecimal initialBalance) {
        this.id = id;
        this.ownerName = ownerName;
        this.balance = initialBalance;
    }

    public UUID getId() { return id; }
    public BigDecimal getBalance() { return balance; }

    public void debit(BigDecimal amount) {
        if (this.balance.compareTo(amount) < 0) {
            throw new IllegalStateException("Insufficient funds in account: " + id);
        }
        this.balance = this.balance.subtract(amount);
    }

    public void credit(BigDecimal amount) {
        this.balance = this.balance.add(amount);
    }
}

Now, declare the repository with lock guarantees:

// src/main/java/in/dropoutdeveloper/ledger/repository/AccountRepository.java
package in.dropoutdeveloper.ledger.repository;

import in.dropoutdeveloper.ledger.model.Account;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Optional;
import java.util.UUID;

public interface AccountRepository extends JpaRepository<Account, UUID> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT a FROM Account a WHERE a.id = :id")
    Optional<Account> findByIdForUpdate(@Param("id") UUID id);
}

Step 4: Preventing Deadlocks and Enforcing Idempotency

When Account A transfers to Account B at the same time Account B transfers to Account A, a naive locking implementation produces a database deadlock. Account A locks row A and waits for row B, while Account B locks row B and waits for row A.

The Fix: Consistent Lock Ordering. Always lock accounts in deterministic order (for example, by sorting UUIDs alphabetically). This guarantees that both transactions request locks in the identical sequence, eliminating deadlocks entirely.

// src/main/java/in/dropoutdeveloper/ledger/service/LedgerService.java
package in.dropoutdeveloper.ledger.service;

import in.dropoutdeveloper.ledger.dto.TransferRequest;
import in.dropoutdeveloper.ledger.model.Account;
import in.dropoutdeveloper.ledger.repository.AccountRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import java.util.UUID;

@Service
public class LedgerService {

    private final AccountRepository accountRepository;
    private final IdempotencyService idempotencyService;

    public LedgerService(AccountRepository accountRepository, IdempotencyService idempotencyService) {
        this.accountRepository = accountRepository;
        this.idempotencyService = idempotencyService;
    }

    @Transactional(isolation = Isolation.READ_COMMITTED)
    public void executeTransfer(TransferRequest request) {
        // Idempotency check: Reject duplicate API submissions
        if (!idempotencyService.claimKey(request.idempotencyKey())) {
            throw new IllegalArgumentException("Duplicate transfer request key: " + request.idempotencyKey());
        }

        UUID firstId = request.sourceAccountId();
        UUID secondId = request.destinationAccountId();

        // Consistent lock order prevents circular wait deadlocks
        if (firstId.compareTo(secondId) > 0) {
            UUID temp = firstId;
            firstId = secondId;
            secondId = temp;
        }

        Account first = accountRepository.findByIdForUpdate(firstId)
            .orElseThrow(() -> new IllegalArgumentException("Account not found: " + firstId));
        Account second = accountRepository.findByIdForUpdate(secondId)
            .orElseThrow(() -> new IllegalArgumentException("Account not found: " + secondId));

        Account source = request.sourceAccountId().equals(first.getId()) ? first : second;
        Account dest = request.destinationAccountId().equals(second.getId()) ? second : first;

        source.debit(request.amount());
        dest.credit(request.amount());

        accountRepository.save(source);
        accountRepository.save(dest);
    }
}

Step 5: Testing Concurrency with JUnit 5 and Virtual Threads

Here is an automated integration test that fires 100 concurrent transfers simultaneously across virtual threads. It proves that no balances become negative and that final ledger totals remain perfectly balanced.

// src/test/java/in/dropoutdeveloper/ledger/LedgerConcurrencyTest.java
package in.dropoutdeveloper.ledger;

import in.dropoutdeveloper.ledger.dto.TransferRequest;
import in.dropoutdeveloper.ledger.model.Account;
import in.dropoutdeveloper.ledger.repository.AccountRepository;
import in.dropoutdeveloper.ledger.service.LedgerService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.math.BigDecimal;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;

import static org.junit.jupiter.api.Assertions.assertEquals;

@SpringBootTest
public class LedgerConcurrencyTest {

    @Autowired
    private LedgerService ledgerService;

    @Autowired
    private AccountRepository accountRepository;

    @Test
    public void testConcurrentTransfersDoNotCorruptBalances() throws InterruptedException {
        UUID acc1 = UUID.randomUUID();
        UUID acc2 = UUID.randomUUID();

        accountRepository.save(new Account(acc1, "Aman", new BigDecimal("10000.00")));
        accountRepository.save(new Account(acc2, "Sneha", new BigDecimal("10000.00")));

        int totalTransfers = 50;
        CountDownLatch latch = new CountDownLatch(totalTransfers);

        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < totalTransfers; i++) {
                final int index = i;
                executor.submit(() -> {
                    try {
                        ledgerService.executeTransfer(new TransferRequest(
                            acc1,
                            acc2,
                            new BigDecimal("10.00"),
                            "key-" + UUID.randomUUID(),
                            "Concurrent transfer #" + index
                        ));
                    } finally {
                        latch.countDown();
                    }
                });
            }
        }

        latch.await();

        Account finalAcc1 = accountRepository.findById(acc1).orElseThrow();
        Account finalAcc2 = accountRepository.findById(acc2).orElseThrow();

        // 10,000 - (50 * 10) = 9,500
        assertEquals(new BigDecimal("9500.0000"), finalAcc1.getBalance());
        // 10,000 + (50 * 10) = 10,500
        assertEquals(new BigDecimal("10500.0000"), finalAcc2.getBalance());
    }
}

How to Frame This on Your Resume

Compare how typical candidates present Java vs how you will present it:

Weak Resume Bullet Engineered Resume Bullet
Built a Banking Application using Java, Spring Boot, and MySQL with CRUD operations. Engineered an ACID-compliant double-entry ledger in Java 21 and Spring Boot 3 using pessimistic database locks and ordered resource acquisition, eliminating deadlocks under 500 concurrent transfers executed across Project Loom Virtual Threads.

Next Steps

To take this ledger further, implement distributed tracing using OpenTelemetry, store immutable audit trails in Amazon S3, and introduce event streaming with Kafka.

For more real-world project blueprints, check out our guides on resume-worthy coding projects, building production C# event pipelines, overcoming the challenges self-taught programmers face, and our path for becoming a developer without a computer science degree.

Found this useful?
View all articles

Keep Reading

Related Articles

Learn with Dropout Developer

Build real software with AI

Step-by-step learning paths, vibe coding tutorials, and certified developer programs designed for the modern engineer.