DEV Community

Cover image for Spring Data JPA Best Practices: Entity Design Guide
Dmitry Protsenko
Dmitry Protsenko

Posted on • Originally published at protsenko.dev

Spring Data JPA Best Practices: Entity Design Guide

You can enjoy the original version of this article on my website, protsenko.dev.

This series of articles was written as part of my review of a large legacy code base that employed many poor practices. To address these issues, I created this guide to promote Spring Data JPA Best Practices in designing entities among my former colleagues.

It's a time to clean the dust from the guide, update it, and publish it for a broader audience. The guide is extensive, and I decided to break it down into three separate articles.

Other articles in the series:

Some of the examples may seem obvious, but they're not. It's only from your experienced perspective. They're real examples from the production codebase.

1 Diving deep into Spring Data JPA

For convenient and rapid development of database‑driven software, it is recommended to use the following libraries and frameworks:

  • Spring Boot — simplifies building web applications on top of the Spring Framework by providing auto-configuration, starter dependencies, and opinionated defaults (e.g., embedded server, Actuator). It leverages Spring’s existing dependency-injection model rather than introducing a new one.
  • Spring Data JPA saves time when creating repositories for database operations. It provides ready‑made interfaces for CRUD operations, transaction management, and query definition via annotations or method names. Another advantage is its integration with the Spring context, along with the corresponding benefits of dependency injection.
  • Lombok – reduces boilerplate by generating getters, setters, and other repetitive code.

Entities represent rows of a database table. They are plain Java objects annotated with @Entity other JPA annotations. DTOs (Data Transfer Objects) are plain Java objects used for presenting data in a limited or transformed form compared to the underlying entity.

In a Spring application, a repository is a special interface that provides access to the database/data. Such repositories are typically annotated with @Repository, but actually, you don't have to mark it separately when you extend from JpaRepository, CrudRepository or another Spring Data JPA repository. If you don’t extend a Spring Data base interface, you can use @RepositoryDefinition. Also, use @NoRepositoryBean on shared base interfaces

A service is a special class that encapsulates business logic and functionality. Controllers are the endpoints of your application; users interact with controllers, which in turn inject services rather than repositories.

For clarity, your project should be organized into packages by responsibility or others. Code organization is a good topic and always relies on your service, code agreements, etc. The given example represents a microservice with a single business domain.

  • entity – database entities,
  • repository – data access repositories,
  • service – services, including wrappers for stored procedures,
  • controller – application endpoints,
  • dtos – DTO classes.

Connection to the database is auto-configured when a Spring Boot application starts, based on application.properties/application.yml. Common properties include:

  • spring.datasource.url – database connection URL
  • spring.datasource.`driver-class-name` – database driver class, Spring Boot can often infer it from the JDBC URL, and set it only if inference fails.
  • spring.jpa.database-platform – SQL dialect to use
  • spring.jpa.hibernate.ddl-auto – how Hibernate should create a database schema, available values: none|validate|update|create|create-drop

2 Developing entities with Spring Data JPA

When designing software that interacts with a database, simple Java objects, properly used with Java Persistence API (JPA) annotations, play a crucial role. Such objects typically contain fields that map to table columns and are referred to as entities. Not every field maps one-to-one: relationships, embedded value objects, and @Transient fields are common.

At a minimum, an entity class must be annotated @Entity to mark the class as a database entity and declare a primary key with @Id or @EmbeddedId. JPA also requires a no-arg constructor (public or protected). It is also good practice to include @Table to explicitly define the target table. The @Table annotation is an optional use it when you need to override the default one.

When using @Entity annotation, prefer to set the name attribute, because this name is used in JPQL queries. If you omit it, JPQL uses the simple class name, setting it decouples queries from refactors*.*

There is one more useful annotation @Table that helps you choose the name of the table if it differs from the naming strategy.

The following examples demonstrate bad and good usage:

@Entity
@Table(name = "COMPANY")
public class CompanyEntity {
    // fields omitted
}

// Later:
Query q = entityManager.createQuery("FROM " + CompanyEntity.class.getSimpleName() + " c")
Enter fullscreen mode Exit fullscreen mode

Here, the name attribute is missing on @Entity, so the class name is used in queries. This can lead to fragile code when refactoring. Here is another problem: it's using the entityManager instead of a preconfigured Spring Data JPA repository. It provides more flexibility, but lets you make a mess in the codebase instead of using more preferable ways to fetch the data.

Did you catch one more bad practice here? Definitely, it's a concatenation of strings to build a query. In that case, it wouldn't lead to SQL injection, but it's best to avoid this approach, especially if you pass the user input to query like this.

@Entity(name = "Company")
@Table(name = "COMPANY")
public class CompanyEntity {
    // fields omitted
}

// Later:
Query q = entityManager.createQuery("FROM Company c");
Enter fullscreen mode Exit fullscreen mode

In the improved version, the entity name is explicitly specified, so JPQL queries can refer to the entity by name instead of relying on the class name.

Note: the JPQL entity name and the physical table name in @Table are independent concepts.

3 Avoiding magic numbers/literals

Choose the type of your fields wisely:

  • If a field represents a numeric enumeration, then use Integer or an appropriately small numeric type.
  • If selecting types, then base them on domain range and nullability (use wrapper types, such as Integer, if the column can be null); and remember that smaller numeric types rarely yield real benefits in JPA.
  • If a value is monetary or requires precision, then use BigDecimal with appropriate precision/scale.
  • If you need details on enums, then they will be covered later.

For example, suppose a field statusCode represents the status of a company. Using a numeric type and documenting the meaning of each value in comments leads to code that is hard to read and error-prone:

// Company status:
// 1 – active
// 2 – suspended
// 3 – dissolved
// 4 – merged
@Column(name = "STATUS_CODE")
private Long statusCode;
Enter fullscreen mode Exit fullscreen mode

Instead, create an enumeration and use it as the type of the field. This makes the code self-documenting and reduces the chance of mistakes. When persisting an enum with Spring Data JPA, specify how it’s stored, it a good practice. Prefer @Enumerated(EnumType.STRING) so the DB contains readable names, and you’re safe against reordering constants. Also, make sure the column type/length fits the enum names (set length or columnDefinition if needed).

// Stored as readable names; ensure the column can hold them (e.g., length = 32).
@Column(name = "STATUS", length = 32)
@Enumerated(EnumType.STRING)
private CompanyStatus status;

public enum CompanyStatus {
    /** Active company */           ACTIVE,
    /** Temporarily suspended */    SUSPENDED,
    /** Officially dissolved */     DISSOLVED,
    /** Merged into another org */  MERGED;
}
Enter fullscreen mode Exit fullscreen mode

If your existing column stores numeric codes (e.g., 1–4) and must stay numeric, don’t use EnumType.ORDINAL (it writes 0-based ordinals and will not match 1–4). Use an AttributeConverter<CompanyStatus, Integer> to map explicit codes to enum values:

@Converter(autoApply = false)
public class CompanyStatusConverter implements AttributeConverter<CompanyStatus, Integer> {
    @Override
    public Integer convertToDatabaseColumn(CompanyStatus v) {
        if (v == null) return null;
        return switch (v) {
            case ACTIVE    -> 1;
            case SUSPENDED -> 2;
            case DISSOLVED -> 3;
            case MERGED    -> 4;
        };
    }

    @Override
    public CompanyStatus convertToEntityAttribute(Integer db) {
        if (db == null) return null;
        return switch (db) {
            case 1 -> CompanyStatus.ACTIVE;
            case 2 -> CompanyStatus.SUSPENDED;
            case 3 -> CompanyStatus.DISSOLVED;
            case 4 -> CompanyStatus.MERGED;
            default -> throw new IllegalArgumentException("Unknown STATUS_CODE: " + db);
        };
    }
}

// Keeps numeric 1..4 in the column while exposing a typesafe enum in Java.
@Column(name = "STATUS_CODE")
@Convert(converter = CompanyStatusConverter.class)
private CompanyStatus status;
Enter fullscreen mode Exit fullscreen mode

4 Consistent use of types

If a field is used in multiple entities, ensure it has the same type everywhere. Using different types for a conceptually identical field leads to ambiguous business logic. For example, the following bad usage shows two fields that represent a boolean flag but use different types and names:

// Bad choice of types for logically identical fields
// A – automatic, M – manual
@Column(name = "WAY_FLG")
private String wayFlg;

@Column(name = "WAY_FLG")
private Boolean wayFlg;
Enter fullscreen mode Exit fullscreen mode

A better option is to use a Boolean or, if you need more than two values or the two values are domain-labeled (e.g., Automatic/Manual), use an enum for both fields. If it’s truly binary yes/no, Boolean (wrapper for nullable columns) is fine. Otherwise, prefer an enum for clarity and future-proofing. Below are consistent mappings without converters:

// Two labeled states: prefer an enum for clarity
public enum WayMode { A, M } // or AUTOMATIC, MANUAL

// Use the same mapping in every entity touching WAY_FLG
@Column(name = "WAY_FLG", length = 1) // ensure length fits enum names
@Enumerated(EnumType.STRING)
private WayMode wayFlg;

// Truly binary case (e.g., active/inactive):
@Column(name = "IS_ACTIVE")
private Boolean active; // use wrapper if the column can be NULL
Enter fullscreen mode Exit fullscreen mode

There is an intentional omission of the part about relations between tables in Spring Data JPA, as it is a broad subject that warrants a separate article on best practices.

5 Lombok usage

To reduce the amount of boilerplate source code, it is recommended to use Lombok for code generation — but it should be used wisely. Generating getters and setters is an optimal choice. It’s best to stick to this practice and override getters and setters only if some pre-processing is required.

For JPA, ensure a no-arg constructor exists. With Lombok, you can add @NoArgsConstructor(access = AccessLevel.PROTECTED) to satisfy the spec cleanly.

Warning note: Avoid @Data on entities because its generated equals/hashCode/toString can be problematic with JPA (lazy relations, mutable identifiers). Prefer targeted annotations (@Getter, @Setter, @NoArgsConstructor) and, if needed, explicit equality with @EqualsAndHashCode(onlyExplicitlyIncluded = true) and excludes for associations. Read more about this further.

Among other things, Lombok supports the following commonly used annotations. You can find the full list on the website: https://projectlombok.org/

6 Overriding equals and hashCode

Overriding equals and hashCode in database entities, many questions arise. For example, many applications work fine with the standard methods inherited from Object.

Context: Within a single persistence context, Spring Data JPA/Hibernate already ensures identity semantics (same DB row -> same Java instance). You typically need custom equals/hashCode only if you rely on value semantics across contexts or use hashed collections.

A database entity typically represents a real-world object, and you might choose to override in different ways:

  • Based on the entity’s primary key (it is immutable). Nuance: if the ID is DB-generated, it’s null before persist/flush. Handle transient state so you don’t change the hash while in a hashed collection.
  • Based on a business key (e.g., an employee’s tax ID/INN), since it isn’t tied to the database implementation. Nuance: works well if the key is unique, immutable, and always available; avoid mutable fields/associations.
  • Based on all fields. Unsafe: mutable data, potential lazy loads, recursion through associations, and performance costs make this fragile for JPA entities.

When should you override equals and hashCode?

  • When the object is used as a key in a Map. Nuance: don’t mutate fields used by hashCode while the object is inside a hashed structure.
  • When using structures that store only unique objects (e.g., Set). Nuance: same caution—mutating equality/significant fields breaks collection invariants.
  • When you need to compare database entities. Nuance: often comparing identifiers is sufficient; overriding is not mandatory if identity comparison fits your use case.

From the above, it follows that you should use Lombok’s @EqualsAndHashCode and @Data with caution, because Lombok generates these methods for all fields unless configured otherwise.

Expand: prefer @EqualsAndHashCode(onlyExplicitlyIncluded = true) and mark only stable identifiers/business keys; avoid @Data on entities (it's generated equals/hashCode/toString can interact badly with lazy relations). You can also exclude relations from equality or toString with @EqualsAndHashCode.Exclude / @ToString.Exclude.

Inheritance nuance: if you define equality in a mapped superclass, ensure the rule is consistent for all subclasses and matches how identity is defined for the whole hierarchy.

A) Business-key equality (safe when the key is unique & immutable)

public class Employee {
    private String taxId; // natural key: unique & immutable

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false; // keep simple here
        Employee other = (Employee) o;
        return taxId != null && taxId.equals(other.taxId);
    }

    @Override
    public int hashCode() {
        return (taxId == null) ? 0 : taxId.hashCode();
    }
}
Enter fullscreen mode Exit fullscreen mode

B) ID-based equality (handles transient state; avoids hash changes)

public class Order {
    private Long id; // DB-generated

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Order other = (Order) o;
        // transient entities (id == null) are never equal to anything but themselves
        return id != null && id.equals(other.id);
    }

    @Override
    public int hashCode() {
        // constant, avoids rehash after the ID is assigned later
        return getClass().hashCode();
    }
}
Enter fullscreen mode Exit fullscreen mode

C) Lombok pattern (explicit includes; avoid all-fields default)

@Getter
@Setter
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class Customer {
    @EqualsAndHashCode.Include
    private String externalId; // stable business key

    // exclude associations and mutable details
    // @EqualsAndHashCode.Exclude private List<Order> orders;
}
Enter fullscreen mode Exit fullscreen mode

7 Developing DTOs

DTOs (Data Transfer Objects) are specialized objects designed to present data to a client, as sending raw database entities directly to the client is considered a bad practice. Some teams do pass entities across internal boundaries, but for public/client-facing APIs, DTOs are preferred to avoid leakage of persistence details.

Creating various DTOs increases development and maintenance time. If libraries like ModelMapper are used, there is also a memory overhead for object mapping.

Another feature of DTOs is reducing the amount of data transmitted over the network and lowering the load on the DBMS by requesting fewer fields. The most important thing is that you actually reduce the database load only if you select fewer columns (using constructor expressions, Spring Data JPA projections, or native queries that return only the required fields). Fetching full entities and then mapping will not reduce the number of selected columns, your captain.

There are different ways to design DTOs:

  • Using classes (objects). Classes or Java records are typically clearer for external APIs (serialization, validation, documentation).
  • Using interfaces. Interfaces fit Spring Data interface-based projections (read-only, getter-only views), not write models.

There are different ways to convert entity objects to DTOs:

  • The optimal approach is to project data from the database directly into the required DTO. This both avoids extra mapping work and ensures fewer columns are selected.
  • You can also use a library like ModelMapper. Prefer MapStruct instead (compile-time code generation, faster at runtime, explicit mappings).
  • You can also write your own object converter. Handwritten mappers provide full control but increase maintenance requirements.

Good practices for developing DTOs:

  • Prefer purpose-specific DTOs per use case (e.g., Summary/Detail/ListItem; CreateRequest vs Response).
  • Avoid one mega-DTO tied to an entity, which causes over-fetching and tight coupling.

8 Spring Data JPA Summary Best Practices

  1. Developing entities with JPA annotations
  • Entities map fields to columns; relationships, embeddables, and @Transient are common (not always 1:1).
  • Minimum: @Entity + primary key (@Id / @EmbeddedId) + no-args ctor (public/protected).
  • Use @Table only to override defaults (table, schema, constraints).
  • Prefer explicit @Entity(name="…") to decouple JPQL from class names so JPQL stays stable across class renames.
  • Avoid string concatenation in JPQL and use parameters.
  • JPQL entity name (@Entity(name)) and physical table name (@Table(name)) are independent.
  1. Avoiding magic numbers/literals
  • Choose types by domain range and nullability; use wrapper types (Integer, Boolean) if the column can be NULL.
  • Money/precision -> BigDecimal with proper precision/scale.
  • Replace numeric codes with enums. Persist with @Enumerated(EnumType.STRING) and ensure column length fits names.
  • Legacy numeric code columns: use an AttributeConverter<Enum, Integer>. Don’t use EnumType.ORDINAL.
  1. Consistent use of types
  • Use the same Java type for the same conceptual column everywhere.
  • Binary flags -> Boolean (wrapper). Domain-labeled or future-expandable flags -> enum consistently.
  • Map enums uniformly (@Enumerated(EnumType.STRING), @Column(length=…)); avoid mixing String/Boolean/enum for the same column.
  1. Lombok usage
  • Use Lombok for boilerplate: @Getter, @Setter, @NoArgsConstructor(access = PROTECTED) for JPA.
  • Avoid @Data on entities (generated equals/hashCode/toString can conflict with lazy relations and identifiers).
  • Override accessors only when pre-/post-processing is needed.
  1. Overriding equals and hashCode
  • Override only if you need value semantics across contexts or in hashed collections.
  • Business-key strategy: compare a unique, immutable key.
  • ID-based strategy: treat transient (id == null) entities as unequal; use a stable/constant hashCode() to avoid rehash after persist.
  • Avoid all-fields equality; exclude associations to prevent lazy loads/recursion.
  • With Lombok, prefer @EqualsAndHashCode(onlyExplicitlyIncluded = true) and explicitly include stable identifiers; use @EqualsAndHashCode.Exclude / @ToString.Exclude for relations.
  • Maintain consistency in equality rules across hierarchies (mapped superclasses vs subclasses).
  1. Developing DTOs
  • Don’t expose entities to clients, even if you return them with annotation @JsonIgnore; design purpose-specific DTOs (Summary/Detail/ListItem; Create/Update/Response).
  • Reduce database load by selecting fewer columns: project directly to DTOs (using constructor expressions), utilize interface-based projections, or use native queries that return only the necessary fields.
  • Mapping full entities doesn’t reduce selected columns.
  • Prefer MapStruct (compile-time, fast, explicit) over ModelMapper; handwritten mappers give control at a higher maintenance cost.

At the end

I hope you find this article helpful. The continuation of the series articles will be published soon, so connect with me on LinkedIn to stay informed about new articles. If you're curious about Spring Data JPA, read the next article: "Spring Data JPA Best Practices: Repositories Design Guide"

Bye!

Top comments (0)