Prevent Logical Errors and Improve Code Clarity With Strongly Typed IDs in Spring Boot

Kevin Muchene
Strongly typed img

In one of my recent Spring-based projects, I encountered subtle but problematic logical data inconsistencies when working with entities and their associated IDs. After debugging, I realized the issue originated from passing the wrong ID type—specifically, using a userId where a loanId was expected. Because both userId and loanId were of the same type (Long), the compiler couldn’t catch the mistake, and caused data confusion and incorrect entity retrieval.

In this article, I’ll walk through the scenario, show how this can lead to issues, and then propose a solution that leverages strongly typed value objects for IDs. This approach prevents subtle logic errors at compile time.

The Initial Setup

Consider two entities: User and Loan. Each has a primary key of type Long, and a standard one-to-many relationship exists between User and Loan. Let’s assume we’re using Spring Data JPA and a relational database (like MySQL or PostgreSQL) for simplicity.


@Entity
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @OneToMany(mappedBy = "user", cascade = CascadeType.ALL)
    private List<Loan> loans;

    public User() {}

}

@Entity
public class Loan {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    public Loan() {}

}

Using GenerationType.IDENTITY, the database auto-increments the IDs, ensuring uniqueness. For example, in the database we might have:

User Table

id
1

Loan Table

iduser_id
11
21

Now, when retrieving data, you might have a REST endpoint like this (just an example):


@RestController
@RequestMapping("/api")
public class UserLoanController {

    private final UserService userService;
    private final LoanService loanService;

    public UserLoanController(UserService userService, LoanService loanService) {
        this.userService = userService;
        this.loanService = loanService;
    }

    @GetMapping("/users/{userId}/loans/{loanId}")
    public ResponseEntity<?> getUserAndLoan(@PathVariable Long userId, @PathVariable Long loanId) {
        try {
            User user = userService.getUserById(userId);
            Loan loan = loanService.getLoanById(loanId);

            return ResponseEntity.ok().body(new UserLoanResponse(user, loan));
        } catch (Exception e) {
            return ResponseEntity.status(404).body("User or Loan not found: " + e.getMessage());
        }
    }
}

This code works as intended. However, consider a scenario where a developer accidentally writes


User user = userService.getUserById(userId);

// Mistakenly using userId instead of loanId
Loan loan = loanService.getLoanById(userId);

What’s the problem? Both getUserById(Long id) and getLoanById(Long id) accept a Long parameter. The compiler doesn’t know you made a mistake. If a Loan exists with the same numeric ID as the User, it will return an incorrect entity, causing logical inconsistencies and subtle bugs in your application.

Why Does This Happen? Because both IDs are of the same raw type (Long), the compiler and runtime can’t distinguish them. Type safety is lost: any Long value is valid as an input parameter for methods expecting a Long. This error usually surfaces only at runtime or integration testing, making detection more expensive and time-consuming.

The Strongly Typed ID Approach

To avoid these pitfalls, we can leverage strongly typed IDs. Instead of relying on primitive types like Long, we introduce value objects that represent each entity's identity uniquely at the type level. For example, let’s define UserId and LoanId as distinct classes:


import java.util.UUID;

class UserId {

    private String id;

    private UserId(String id) {
        this.id = id;
    }

    public UserId() {
        this.id = UUID.randomUUID().toString();
    }

    public String getId() {
        return id;
    }

    @Override
    public String toString() {
        return id;
    }

    @Override
    public boolean equals(Object o) { ... }

    @Override
    public int hashCode() { ... }
}

class LoanId {

    private String id;

    private LoanId(String id) {
        this.id = id;
    }

    public static LoanId random() {
        this.id = UUID.randomUUID().toString();
    }

     public String getId() {
        return id;
    }

    @Override
    public String toString() {
        return id;
    }

    @Override
    public boolean equals(Object o) { ... }

    @Override
    public int hashCode() { ... }
}

Here, we’ve chosen to use a String- based ID (e.g., a UUID) to ensure uniqueness, but you can adapt this approach to work with numeric IDs or other forms of identifiers as well. The crucial point is that UserIdandLoanId are distinct types—no accidental mixing is possible.

Integrating With JPA


@Entity
public class User {

    @EmbeddedId
    private UserId id;

    public User() {
        this.id = new UserId();
    }

    @OneToMany(mappedBy = "user", cascade = CascadeType.ALL)
    private List<Loan> loans;
}

@Entity
public class Loan {

    @EmbeddedId
    private LoanId id;

    public Loan() {
        this.id = new LoanId();
    }

    @ManyToOne
    @JoinColumn(name = "user_id", nullable = false)
    private User user;
}

Alternatively, if you prefer to keep auto-increment numeric IDs, you can still introduce strong typing by having wrapper classes around Long. The key concept is ensuring that UserId and LoanId are not interchangeable at the type level.

Changing the Service Layer

Your service methods would now look like this:


public User getUserById(UserId userId) {
    // Fetch the User entity by userId
    return userRepository.findById(userId);
}

public Loan getLoanById(LoanId loanId) {
    // Fetch the Loan entity by loanId
    return loanRepository.findById(loanId);
}

Now, if you mistakenly try:


// compile-time error
Loan loan = loanService.getLoanById("some-string-id");

// compile-time error
Loan loan = loanService.getLoanById(new UserId("some-user-id"));

The relief of the compiler immediately flagging this as an error is immense. This is a huge win—no runtime surprises, just the comfort of knowing your code is error-free.

Benefits of Using Strongly Typed IDs

  1. Strong Type Safety: UserId and LoanId are distinct types, preventing accidental misuse at compile time.
  2. Early Error Detection: Bugs that would otherwise only appear at runtime are caught during development, saving time and improving code quality.
  3. Immutability and Consistency: The ID value objects are immutable, ensuring integrity and stability.

Considering Trade-Offs and Alternatives

What’s the downside? Introducing custom ID types adds a bit of complexity—more classes and initial setup. It can also require additional JPA mapping configurations or custom converters. However, this complexity pays off by improving code reliability, readability, and maintainability over the long term. Alternatively, more straightforward solutions might include better parameter naming, stricter code reviews, or leveraging static analysis tools. Yet, strong typing at the language level is more foolproof.

Conclusion

Switching from primitive types like Long to custom, strongly typed value objects for entity identifiers can significantly reduce logical bugs and improve code clarity. Catching errors at compile time increases confidence in your code and reduces the likelihood of subtle runtime issues.

What do you think about this approach? It’s a nice technique that can save you from hours of debugging. If correctness and code quality are priorities for your project, strongly typed IDs are worth considering.