15  Object-Relational Mapping (ORM) (Draft)

The persistence of complex data structures in a program can be achieved using a data-base. The Object-Oriented model and the Relational models, while similar in some aspects have a few incompatible aspects – usually called mistmatches) – that make the integration difficult. While low-level interaction through queries is possible – using the Java DataBase Connectivity (JDBC) API – modern applications adopt an adaptation layer called Object-Relational Mapping (ORM) that makes code simpler and allow flexibility and optimization.

15.1 Persistence and Direct data access

15.1.1 Persistence

Persistence is the property of data that can outlive the current process lifespan, i.e. when a program is started it will have data from previous work sessions available.

For Java developers, this means we would like the state of certain objects to live beyond the scope of the JVM, so that the same state is available in a different JVM, or even in a different program written in a different programming language.

The key requirement for persistence is that data must survive program execution. Simple program use in-memory data which is stored in RAM and lost when the program stops. Persistent data need to be stored on disk, databases, or external storage device. The key differences are: volatility, lifespan, and accessibility. In-memory data is volatile and disappears when power is lost or the application terminates, while persistent data remains intact across sessions. The lifespan of in-memory data is limited to the duration of program execution, whereas persistent data can exist indefinitely. Finally, in-memory data is accessible only within the running process, while persistent data can be shared across different applications, users, and even platforms, making it essential for long-term data storage and multi-user systems.

15.1.2 Object/Relational Mismatch

The incompatibilities between the relational data model compared object-oriented paradigm are usually known ad object/Relational Mismatch or impedance mismatch:

  • the relational model represents data in a tabular format with references based on values,
  • object-oriented languages represent data as an interconnected graph of objects with inheritance and polymorphism.

The most important mismatches consist in:

  • Granularity Object models often have more classes than tables in the database.
  • Subtypes (inheritance) The relational model doesn’t have inheritance as a first-class construct, There are standard ways to represent subtyping.
  • Identity The relational model defines exactly one notion of “sameness”: the primary key. Java defines both object identity a==b and value equality a.equals(b). It is common to have more than one object in a given Java program representing the same row of a database table.
  • Associations Associations are represented as unidirectional references in Java Relational databases treat all associations as bidirectional and represent them via foreign keys Bidirectional relationship in Java require code on both sides of the association Just by looking at the Java code we cannot be sure of the multiplicity of an association
  • Data navigation In Java, we navigate associations from one object to the next, walking the object network. That is inefficient when retrieving relational data The goals is to minimize the number of SQL queries

15.1.3 Object-Relational Mapping (ORM)

Object-Relational Mapping (ORM) is a technique for managing database interactions using object-oriented programming, converting data between a relational database and the memory.

An ORM solution:

  • provides an abstraction layer that decouples business logic from database access,
  • translates objects in the application into database records and vice-versa,
  • automates data conversion between object models and relational schemas,
  • reduces (in most cases eliminates) the need for direct SQL queries for basic operations,
  • automates query generation and result mapping to objects,
  • reduces manual connection and transaction management.

The alternative to ORM is Direct Data Access through the JDBC API:

  • Direct Data Access
    • Uses native SQL to interact with the database
    • Directly embeds SQL in the application code
    • Manually handles every relationship and transaction
  • ORM
    • Uses object-based operations to interact with the database
    • SQL queries can be automatically generated based on object models
    • Simplifies complex relationships and data handling

ORM Pros:

  • Reduces the need to insert SQL within our programming language
  • Improves code maintainability and readability
  • Reduces development time by automating database interactions
  • Supports multiple databases with minimal changes

ORM Cons:

  • Less control over query optimization and performance (but it is possible to use direct SQL queries)
  • Potential overhead compared to manually optimized SQL
  • Learning curve for ORM frameworks and configurations

15.2 JPA Mapping

Jakarta Persistence API (JPA), formerly Java Persistence API, is a Java specification for managing relational data using an object-oriented approach. It defines a standard way to map Java objects to database tables, manage entity lifecycle, and execute queries.

JPA itself does not provide an implementation; it relies on providers such as Hibernate to handle persistence operations.

JPA provides two approaches for defining entity mappings: annotation based (modern approach) and XML-based (mostly for legacy systems), in this chapter we will focus on annotations approach.

JPA annotations are applied at different levels:

  • Entity-Level: defines an entity and its table mapping
  • Field-Level: defines the mapping of individual fields to database columns
  • Relation-Level: defines associations between entities

15.2.1 Entity

An entity is a purely structural component that represents a persistent object that is mapped to a database table. It is a fundamental building block of the application’s domain model and serves as an abstraction over relational database records.

Entities encapsulate only data structure and constraints, without containing business logic. Each entity instance corresponds to a row in a relational table. An entity defines fields (mapped to table columns) and relationships with other entities. Must have at least one primary key to ensure uniqueness.

By defining entities, we model the granularity of the object domain, ensuring a structured representation of data in the application.

The following annotations at the class level can be used to define entity mapping:

  • @Entity Marks a class as a JPA entity.

    The class must have a no-argument constructor with at least package visibility.

    If not specified, the table name defaults to the class name.

  • @Table(name, indexes...) Specifies the database table on which the entity will be mapped

    • name (String, optional): the name of the db table
    • indexes (Index[], optional): array of the indexes of the table
  • @Index(name, columnList, unique) Indexes are defined within the @Table annotation

    • name: (String) the name of the index
    • columnList: (String) comma-separated list of column names that will be included in the index.
    • unique: (boolean, default: false) whether the index enforces uniqueness across all indexed columns. If multiple columns are indexed together, their combination must be unique
  • @Inheritance(strategy) specifies the inheritance strategy for an entity hierarchy

    • strategy (Enum: InheritanceType):

    Relational databases do not support subtypes (inheritance) natively, so ORM provides different strategies to map class hierarchies to tables. There are three main mapping strategies:

    • Single Table Inheritance (SINGLE_TABLE) – One table stores all subclasses, with a type discriminator column, this is the most efficient strategy but wastes space;
    • Joined Table Inheritance (JOINED) – Each class has its own table, linked through foreign keys, normalizes data but adds complexity;
    • Table Per Class (TABLE_PER_CLASS) – Each class has a separate table with all its attributes; avoids duplication but makes querying harder.
  • @DiscriminatorColumn(name, discriminatorType, length) Used with SINGLE_TABLE inheritance to distinguish entity types

    • name (String, default: "DTYPE"): name of the discriminator column
    • discriminatorType (Enum: DiscriminatorType): type of the column STRING | CHAR | INTEGER,
    • length (Integer, default: 31): max length for STRING type
  • @DiscriminatorValue(value) Used with SINGLE_TABLE, defines the value stored in the discriminator column for a specific entity

    • value (String): discriminator value for this entity
  • @MappedSuperclass. Defines a non-entity superclass, the class itself does not correspond to a database table.

    It can be used to share mapped fields among subclasses. Its annotated fields (@Id, @Column, etc.) are inherited and mapped into the tables of concrete entity subclasses. Ideal for shared attributes such as id, timestamps, audit fields, or other common metadata. With this annotation, inheritance exists only on the Java side, there is no inheritance structure in the database —- i.e. no join, no discriminator column –.

15.2.2 Fields

Each entity field corresponds to a database column and can have the constraints and properties typical of fields in a relational database schema.

Data type conversion between object model and relational model:

  • String in code → VARCHAR in database
  • boolean in code → TINYINT(1) in some databases

Some languages support richer types than relational databases, ORM provides mapping rules for correct conversion. Custom type mapping possible if default conversions are not sufficient.

The fields can be annotated with the following annotations:

  • @Id Declares the field as the primary key (PK) that establish the identity of an entity instance; it defines entity uniqueness. Can be auto-generated (e.g., auto-increment, UUID) or manually assigned.

  • @GeneratedValue(strategy) Define an a PK (marked with @Id) as autogenerated. It can specify which different generation stategy (Enum: GenerationType) is adopted:

    • AUTO: default, ORM decides the best strategy
    • IDENTITY: uses database auto-increment
    • SEQUENCE: uses a database sequence
    • TABLE: uses a table-based sequence
  • @Column(name, nullable, length, unique, insertable, updatable) Details the mapping of a field to a database column, overriding the default one:

    • name (String, optional): the name of the column in the mapping table
    • nullable (boolean, default: false): if true, allows null values
    • length (int, default: 255): defines the maximum length for a String field
    • unique (boolean, default: false): if true, enforces a unique constraint
    • insertable (boolean, default: true): if false, prevents insertion
    • updatable (boolean, default: true): if false, prevents updates
  • @Transient Excludes a field from persistence, thus the field will not be mapped to a column. By default all attributes in an entity class are mapped to fields in the corresponding table.

  • @Enumerated(value) Maps an enumerative type to a database column. The parameter value defines how defines how it is stored in the db:

    • ORDINAL: stores enum positions (0,1,2…)
    • STRING: stores enum names as strings
  • @Lob Marks a field as a large object (BLOB/CLOB).

  • @Temporal(value) Specifies the temporal type of a java.util.Date or java.util.Calendar. Parameter value: (Enum: TemporalType) defines how the date/time is stored

    • DATE: Stores only the date (without time).
    • TIME: Stores only the time (without date).
    • TIMESTAMP: Stores both date and time

15.2.3 Relations

Relationships define the associations of the object-oriented model, how entities are linked in the relational model.

Four main types of relationships:

  • One-to-One (1:1) (@OneToOne): each instance of the entity is associated with exactly one instance of the related entity;
  • Many-to-One (N:1) (@ManyToOne): multiple instances of the entity are associated with a single instance of the related entity;
  • One-to-Many (1:N) (@OneToMany): one instance of the entity is related to multiple instances of the related entity;
  • Many-to-Many (N:N) (@ManyToMany): entities are related in a many-to-many fashion, requiring a join table.

JPA manages relationships using foreign keys and ensures data consistency using entity associations. Foreign keys are automatically managed by JPA based on relationship annotations, customization is allowed by using @JoinColumn (One-to-One, Many-to-One) or @JoinTable (Many-to-Many).

When defining relationships it is often useful to distinguish between Owning vs. Inverse side:

  • the owning side is the entity that stores the foreign key,
  • the inverse side is the entity that is referenced.

Another importan distinction is between Bidirectional vs. Unidirectional relationships:

  • Unidirectional: only the owning side is aware of the relationship
  • Bidirectional: both sides are aware of the relationship; the inverse side uses mappedBy to refer to the field on the owning side.

The fields that implement an association and will be mapped to a relationship in the relational model can be annotated with the following annotations:

  • @OneToOne(cascade, fetch, mappedBy, orphanRemoval, optional)

    • cascade (Enum: CascadeType[], optional): defines the cascading strategy
    • fetch (Enum: FetchType, optional, default: EAGER): defines the fetch strategy
    • mappedBy (String): specifies the field in the owning entity that maps this relationship. Used only on inverse side
    • orphanRemoval (boolean, default: false): if true, removes related entities when they are no longer referenced
    • optional (boolean, default: true): if false, enforces a non-null foreign key constraint
  • @ManyToOne(cascade, fetch, optional)

    • cascade (CascadeType[], optional): defines the cascading strategy
    • fetch (FetchType, optional, default: EAGER): defines the fetch strategy
    • optional (boolean, default: true): if false, enforces a non-null foreign key constraint
  • @OneToMany(mappedBy, cascade, fetch, orphanRemoval)

    • mappedBy (String, required): specifies the field in the owning entity that manages the foreign key
    • cascade (Enum: CascadeType[], optional): defines the cascading strategy
    • fetch (Enum: FetchType, optional, default: LAZY): defines the fetch strategy
    • orphanRemoval (boolean, default: false): if true, removes orphaned entities automatically

    @JoinColumn cannot be used with @OneToMany because the foreign key is in the “many” side (@ManyToOne) It uses mappedBy to indicate that the relationship is managed by the “many” side

  • @ManyToMany(cascade, fetch)

    @JoinTable can be used to specify the table and foreign key mappings

  • @JoinColumn(name, referencedColumnName, nullable, unique, insertable, updatable) used on the owning side with One-to-One, Many-to-One, to define the foreign key, by default, JPA automatically names the foreign key column as {fieldName}_id

    • name (String, optional): defines the foreign key column name
    • referencedColumnName (String, optional): the column name in the referenced entity’s table
    • nullable (boolean, default: true): if false, prevents NULL values
    • unique (boolean, default: false): if true, enforces uniqueness
    • insertable (boolean, default: true): if false, prevents insert operations
    • updatable (boolean, default: true): if false, prevents updates

    It can be used on the owning side only, the inverse side of the relationship uses mappedBy to reference the owning entity and avoid an extra foreign key

  • @JoinTable(name, joinColumns, inverseJoinColumns) Used with @ManyToMany relationships to define a join table, by default JPA automatically creates a join table

    • name (String, required): name of the join table
    • joinColumns (Array of @JoinColumn): defines foreign key columns for the owning entity
    • inverseJoinColumns (Array of @JoinColumn): defines foreign key columns for the inverse entity

15.2.3.1 Fetch strategy

The Fetch (or loading) strategy defines how the related entities are loaded and thus determines the data navigation in the model.

Navigating relationships in ORM requires fetching data efficiently. The two primary data loading strategies are:

  • Lazy Loading (LAZY): related data is only loaded when explicitly accessed it improves initial performance but may cause multiple queries.
  • Eager Loading (EAGER): related data is loaded immediately with the entity. It reduces query count but can load unnecessary data

Choosing the right strategy depends on performance trade-offs and data access patterns

15.2.3.2 Cascading strategy

Cascading allows propagation of operations (e.g., persist, update, delete) from a parent entity to its related entities.

ORMs let you configure cascading per operation type:

  • Persist: saves related entities when the parent is saved.
  • Merge: updates related entities when the parent is updated.
  • Remove: deletes related entities when the parent is deleted.
  • Detach: detaches related entities when the parent is removed from the persistence context.
  • Refresh: reloads related entities when the parent is refreshed.

Cascade strategies:

  • Full cascade: applies all operations to related entities.
  • Selective cascade: allows only specific operations to be propagated.
  • No cascade: relationships must be managed manually.

Cascading simplifies persistence management but must be configured carefully to avoid unintended side effects.

All relationship annotations share the:

  • cascade (Enum: CascadeType[]): defines how persistence operations affect related entities. It accepts an array of CascadeType values, meaning you can specify different cascade behaviors for different operations:
    • ALL: applies all operations to related entities
    • PERSIST: saves child entities when the parent is saved
    • MERGE: updates child entities when the parent is updated
    • REMOVE: deletes child entities when the parent is deleted
    • REFRESH: reloads child entities when the parent is refreshed
    • DETACH: detaches child entities when the parent is detached

15.2.4 Example

@Entity
public class User {
    @Id
    @Size(min = 16, max = 16)
    private String cf;
    private String name;
    private Integer age;

    @OneToMany(mappedBy = "customer")
    private List<Order> orders = new ArrayList<>();

    User(){}
    // ...
}
@Entity
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne
    private User customer;

    private String status;

    Order(){}

    //...
}

15.3 JPA Persistence

15.3.1 Persistence Unit

JPA provides an abstraction layer over JDBC (Java DataBase Connectivity, the Java native DB connector), handling database connections and transactions. Entities are automatically managed in a persistence context, allowing seamless database interaction. JPA does not directly interact with the database; instead, it relies on a persistence provider (such as Hibernate) to execute queries and manage transactions

A Persistence Unit represents a logical configuration that groups:

  • the database connection settings
  • the list of managed entities
  • transaction handling strategy
  • JPA provider-specific settings

Each JPA application must declare at least one Persistence Unit. The Persistence Unit is the entry point for all JPA operations. The Persistence Unit is created when the application starts and remains available for database interactions.

persistence.xml file is the configuration file required to define how JPA interacts with the database. This file can include provider-specific properties that are not defined by JPA but are used to configure additional features of the chosen JPA provider.

Each Persistence Unit is defined by:

  • a unique name
  • the database connection settings (JDBC URL, driver, credentials)
  • the transaction handling strategy (transaction-type):
    • RESOURCE_LOCAL: transactions are managed manually using EntityTransaction,
    • JTA: transactions are managed externally by a container.
  • List of managed entities (optional if entities are defined with annotations and are in the classpath)
<?xml version="1.0" encoding="UTF-8"?>
<persistence xmlns="https://jakarta.ee/xml/ns/persistence"
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="https://jakarta.ee/xml/ns/persistence https://jakarta.ee/xml/ns/persistence/persistence_3_1.xsd"
             version="3.1">
1    <persistence-unit name="sample">
        <properties>
2          <property name="jakarta.persistence.jdbc.driver"
                    value="org.h2.Driver"/>
3          <property name="jakarta.persistence.jdbc.url"
                    value="jdbc:h2:mem:sampledb;DB_CLOSE_DELAY=-1"/>
4          <property name="jakarta.persistence.jdbc.user" value="sa"/>
          <property name="jakarta.persistence.jdbc.password" value=""/>
5          <property name="jakarta.persistence.schema-generation.database.action"
                    value="drop-and-create"/>
        </properties>
    </persistence-unit>
</persistence>
1
the unique name of the unit
2
JDBC driver used for connection
3
JDBC connection string, with database name
4
database account details
5
Schema generation policy

ORM frameworks provide automatic schema generation policies based on the defined entities. Schema generation policy define how schema creation should be managed by the JPA provider. The possible policies are:

  • create: Generates the schema if it does not exist
  • drop-and-create: Deletes and recreates the schema on each run (useful for testing)
  • update: Modifies the schema to match entity changes (may cause issues with existing data)
  • validate: Checks if the schema matches the entity definitions but does not modify it
  • none: The ORM does not perform any schema validation or modification

Choosing the right policy:

  • Development: Create or Drop-and-Create for rapid iteration,
  • Production: None or Validate to prevent unintended schema changes.

The EntityManagerFactory loads configuration from persistence.xml and initializes the JPA provider (e.g. Hibernate). It is a heavyweight object, meant to be created once per application and reused. It must always be closed when the application shuts down. It is responsible for managing persistence units and creating EntityManager instances

1EntityManagerFactory emf = Persistence.createEntityManagerFactory("myPersistenceUnit");
2EntityManager em = emf.createEntityManager();
1
loads the configuration of the persistence unit myPersistenceUnit defined in persistence.xml
2
creates the EntityManager object that defines a session.

15.3.2 Session and EntityManager

A Session (or persistence context) is a temporary environment where an ORM tracks and manages entities during their lifecycle. It acts as an intermediary between the application and the database, ensuring that objects remain synchronized with their corresponding records in the database. Its scope and lifespan must be carefully managed to avoid performance issues.

Session key features:

  • Manages the identity and lifecycle of entities within an application session
  • Keeps track of changes made to managed entities
  • Automatically synchronizes data between in-memory objects and the database
  • Reduces the need for explicit SQL statements, leveraging automatic persistence
  • Provides a caching mechanism, reducing unnecessary database queries
  • Ensures consistency within a transaction, committing or rolling back changes

Session operations:

  • Opening a session – Creates a new persistence context
  • Persisting an entity – Registers a new entity for saving
  • Fetching an entity – Retrieves an entity from the database into the session
  • Updating an entity – Modifies an entity already tracked by the session
  • Detaching an entity – Stops tracking an entity without deleting it
  • Flushing changes – Writes all pending updates to the database
  • Closing a session – Ends the persistence context and detaches all entities

When an entity is retrieved using an ORM query, it is automatically added to the session, its state is tracked, and any modifications will be synchronized with the database when the session is flushed or committed.

Entities transition through different states during their lifecycle in an ORM Main entity states:

  • Transient – The entity is created in memory but not yet persisted
  • Persistent – The entity is associated with a database session and will be saved
  • Detached – The entity is no longer managed by the ORM but still exists in memory
  • Removed – The entity is marked for deletion and will be removed from the database

These operations modify the state of an entity, when performed

  • Creating a new instance (e.g. new EntityClass()) starts in Transient state
  • Persisting the entity (e.g., save() or persist()) transitions it to Persistent
  • Closing or clearing the session detaches the entity (Detached)
  • Deleting the entity moves it to the Removed state

If the same entity is requested again during the same session, the ORM will return the existing in-memory instance rather than executing another database query.

When executing a native SQL query that retrieves data not directly mapped to a known entity, the results are not added to the session. In such cases, the ORM treats the output as raw data, and no automatic tracking or synchronization occurs.

Detaching an entity means removing it from the session while keeping it in memory. Once detached, the ORM no longer tracks changes made to the entity, and these modifications will not be reflected in the database unless the entity is reattached to a session.

Detaching is not removing. Removing an entity means marking it for deletion from the database. It is still tracked by the session until the session is flushed or committed, at which point the corresponding record is physically deleted from the database.

Detaching only affects the ORM’s ability to track changes, while removing permanently deletes the entity’s data from the database.

Detaching can be useful when dealing with large datasets to reduce memory consumption, whereas removing is necessary when an entity - and all its information - is no longer needed in the system.

EntityManager is the implementation of the concept of session and is the basis of all querying operations in JPA.

It is the main interface for interacting with the database in JPA. It manages the lifecycle of entities and executes queries using both JPQL and native SQL It is a lightweight, short-lived object, typically created per transaction or request When obtained directly from the EntityManagerFactory, it must be explicitly closed with close() to release resources.

It provides the core API for:

  • Entity lifecycle management:

    • persist(Object entity): registers a new entity to be inserted into the database when a transaction is committed
    • merge(Object entity): updates an existing entity or reattaches a detached entity to the persistence context
    • detach(Object entity): removes the given entity from the persistence context
    • remove(Object entity): marks an entity for deletion from the database when the transaction is committed

    In addition there are two operation that allow synchronizing the in-memory content with the database:

    • flush(): write to the database all the pending changes without waiting for the transaction commit,
    • refresh(): updated the in-memory objects with the latest changes present in the database, possibly overwriting attributes.

    Note that any operation that modify data in the database requires an active transaction.

  • Querying: supports different query methods allowing structured, reusable, dynamic, or raw SQL queries to retrieve data

    • find(Class<T>, Object primaryKey): retrieves a single entity by its primary key

    For more complex queries, EntityManager provides methods that return a Query object, which allows customization and execution of different types of queries.

    Operations that read data do not require an active transaction

  • Transaction management: explicitly controls transactions when a RESOURCE_LOCAL transaction type is used.

    • getTransaction(): creates a transaction object

Session can be managed as:

  • short-lived: A new session is created for each operation (or web request) and closed immediately after. This ensures that entities are not kept in memory longer than necessary. Prevents excessive memory consumption and avoids issues such as long-running transactions. Recommended for most applications, especially web-based systems where each request is stateless.
  • long-lived: The session remains open for a longer period, potentially throughout a user session or across multiple operations. This allows entities to be reused without repeated database queries. However, it increases the risk of memory leaks, concurrency issues, and stale data. Can be useful in specific cases, such as batch processing or maintaining user state in desktop applications.
Note

Use short-lived sessions by default unless there is a clear need for long-lived sessions. Avoid keeping a session open longer than necessary to reduce memory consumption.

Flush and commit changes regularly to maintain database consistency.

Be cautious with lazy loading when using detached entities, as it may trigger unexpected database queries outside of an active session. Modern ORM frameworks manage session automatically. Manual session management can be necessary to optimize performances

15.3.3 Transaction

JPA operates in a transactional context: modifications to the database must be executed within a transaction.

A Transaction is a sequence of one or more database operations that are executed as a single unit of work. Transactions, following the ACID properties, ensure data integrity by guaranteeing that either all operations within the transaction are successfully completed or none of them take effect. This prevents situations where partial updates could leave the database in an inconsistent state.

Transaction key features:

  • Transactions ensure data integrity by grouping multiple operations together.
  • A transaction must be explicitly committed to persist changes or rolled back to undo them.
  • If an error occurs during a transaction, the system can abort all changes to maintain database consistency.
  • Transactions typically operate within an active session, meaning they are tied to the ORM’s persistence context.

Transaction operations:

  • Begin – Starts a new transaction.
  • Commit – Saves all changes made within the transaction permanently.
  • Rollback – Reverts all changes made within the transaction.
  • Savepoint – Creates a checkpoint within a transaction to allow partial rollbacks.
  • Isolation Level Configuration – Defines how transactions interact with each other to manage concurrent access to data.

Transactions must be explicitly started and committed to apply changes. If an error occurs, the transaction can be rolled back to maintain data integrity.

In a multi-user database system, multiple transactions can be executed concurrently. The most common concurrency issues are:

  • Dirty Reads – A transaction reads uncommitted changes from another transaction.
  • Non-Repeatable Reads – A transaction reads the same row twice but gets different values because another transaction modified it in between.
  • Phantom Reads – A transaction executes the same query twice and gets different results because another transaction inserted or deleted rows.

Isolation Levels define how transactions interact with each other and how data consistency is maintained in concurrent operations. A lower isolation level increases performance but increases the risk of incurring in concurrency issues, while a higher isolation level ensures stronger data integrity but may reduce performance.

The possible isolation levels are:

  • Read Uncommitted

    Transactions can read uncommitted changes from other transactions (dirty reads allowed). Highest concurrency, lowest data consistency.

  • Read Committed

    A transaction can only read committed changes (no dirty reads). However, non-repeatable reads and phantom reads can still occur.

  • Repeatable Read

    Prevents dirty reads and non-repeatable reads by ensuring that a row read within a transaction does not change until the transaction completes.

    Phantom reads may still occur.

  • Serializable

    The strictest level: transactions are fully isolated from each other by locking rows or entire tables. Prevents all concurrency issues but significantly reduces parallelism.

Isolation level depends on the application’s performance and consistency requirements. Typically high-performance application use Read Committed, while data-critical applications adopt Serializable.

Modern ORM frameworks manage transactions automatically Manual transaction management can be necessary to optimize performances

Note

Keep transactions as short as possible to avoid locking resources for too long. Always handle rollback scenarios to prevent data inconsistencies in case of failures. Use proper isolation levels based on the application’s concurrency requirements.

EntityTransaction is the interface that represents a database transaction in JPA. EntityManager allows to retrieve a transaction via the method getTransaction() Its implementation is provided by the JPA provider. It provides methods to control transaction flow:

  • begin(): starts a new transaction,
  • isActive(): checks if the transaction is currently running,
  • commit(): saves all changes made since the transaction began,
  • rollback(): discards all changes if an error occurs.

15.4 JPA Querying

15.4.1 Querying in ORM

ORMs generate SQL automatically based on the structure of the defined entities. Basic queries can be constructed using entity attributes, allowing developers to work at the object level without writing SQL.

Simple queries are fully managed by the ORM (e.g., finding by primary key, inserting, updating, deleting). ORMs optimize queries by dynamically translating object-oriented queries into SQL tailored for the underlying database.

Key advantages:

  • Minimizes direct SQL interaction, reducing complexity
  • Ensures consistency by enforcing entity relationships and constraints
  • Improves code readability and maintainability by abstracting low-level database interactions

When basic queries are not enough, ORMs offer more advanced techniques:

  • Criteria API & Query Builders – Programmatic query construction
  • Joins and custom queries – Fetching related data efficiently
  • Native SQL queries – Direct SQL execution when necessary

Performance considerations:

  • Use pagination and indexing to improve query speed
  • Avoid N+1 query problems with proper relationship fetching (e.g., JOIN FETCH, batch fetching)
  • Cache frequently accessed data

JPA provides multiple ways to query and manipulate data in a relational database:

  • JPQL (Java Persistence Query Language): object-oriented query language like SQL but based on JPA entities instead of tables
  • Named Queries: precompiled queries defined inside JPA entities for improved performance and reusability
  • Criteria API: programmatic approach to dynamically build type-safe queries
  • Native Queries: direct execution of raw SQL queries for advanced operations and database-specific optimizations

Each method serves a specific use case:

  • JPQL and Named Queries: suitable for structured, static queries
  • Criteria API: useful for dynamic queries with runtime conditions
  • Native Queries: necessary when JPQL is insufficient or when using database-specific functions

15.4.2 Query interface

Query is the JPA interface used to execute queries. Its implementation is provided by the JPA provider. TypedQuery<T> is a query subtype, it ensures type safety by specifying the return type.

Created through EntityManager, it represents a compiled query ready for execution Supports different query types: JPQL, Named Queries, Native Queries Criteria API follows a different approach to query definition, using a programmatic, type-safe construction mechanism instead of string-based queries

EntityManager provides methods to create untyped generic Query objects. For these Query objects result type is not enforced at compile time, so manual casting may be required:

  • createQuery(String qlString): creates a JPQL query
  • createNamedQuery(String name): retrieves a predefined Named Query
  • createNativeQuery(String sqlString): creates a query using raw SQL
  • createNativeQuery(String sqlString, String resultSetMapping): executes a native query using a custom mapping

EntityManager also provides methods to create TypedQuery<T> objects. This allows the query to return the specified object when executed, ensures type safety, reducing the need for manual casting:

  • createQuery(String qlString, Class<T>): creates a typed JPQL query and maps results to entities
  • createNativeQuery(String sqlString, Class<T>): creates a typed native query and maps results to entities
  • createNamedQuery(String name, Class<T>): retrieves a predefined Named Query and maps results to entities

The Query interface provides methods to configure and customize queries before execution. Parameters can be assigned using named or positional parameters. Date and time values require a TemporalType to specify their granularity.

  • setParameter(String name, Object value): assigns a value to a named parameter
  • setParameter(int position, Object value): assigns a value to a positional parameter
  • setParameter(String name, Date value, TemporalType temporalType): assigns a date/time parameter with a specific TemporalType
  • setParameter(int position, Date value, TemporalType temporalType): same as above, but for positional parameters

Once a query is configured, it must be executed to retrieve results These are the most common method that execute a query and get the results

  • getResultList(): retrieves multiple results; return type depends on the query type and operation:
  • getResultStream(): similar to the previous, but it returns a Stream instead of a List
  • getSingleResult(): retrieves a single result; return type depends on the query type and operation:
  • executeUpdate(): executes UPDATE or DELETE queries; returns the number of affected rows

The result type depends on the query type and on the selection it performs

  • TypedQuery<T> returns objects of the specified type T
  • Standard Query returns Object or Object[] when selecting multiple fields
  • Partial field projections return Object[] (each element corresponds to a selected field)
  • Aggregates (SUM, COUNT, etc.) return a Number, but the actual type depends on the database. Explicit casting is required

The execution method (getSingleResult(), getResultList(), …) determines if the result is a single item or a collection of iems

Pagination allows limiting the number of results and setting an offset

  • setMaxResults(int maxResults): limits the number of results
  • setFirstResult(int startPosition): sets the offset for pagination

15.4.3 Java Persistence Query Language (JPQL)

JPQL (Java Persistence Query Language) is the query language defined by JPA to retrieve and manipulate entity data. It is similar to SQL, but operates on JPA entities and their attributes instead of database tables and columns. JPQL queries are translated into SQL by the JPA provider, making them database-independent.

Supports SELECT, UPDATE, DELETE operations, but does not allow INSERT statements. Entities are persisted (i.e. rows are inserted) using EntityManager.persist() method.

JPQL enables navigating entity relationships using JOIN, FETCH, and path expressions. Queries can be statically defined (Named Queries) or dynamically created at runtime.

JPQL – Query Structure

Queries must use entity class names and field names, not database-specific identifiers. The alias is required when using conditions or sorting. Joins are based on entity relationships instead of foreign keys, this enables a type-safe navigation through associations

Example:

SELECT u                       : <1>
FROM User u                    : <2>
JOIN u.orders o                : <3>
WHERE o.status = 'SHIPPED'     : <4>
ORDER BY u.name ASC            : <5>
  1. select all the colums (like User.* in SQL)
  2. define an alias since used in sorting
  3. the join uses the relation field in the class and defines an alias since uses it in a condition
  4. condition on a column (same as in SQL)
  5. ordering on a column (same as in SQL)

Supported operators in JPQL:

  • Comparison operators: =, <>, <, >, <=, >=
  • Logical operators: AND, OR, NOT
  • LIKE operator: pattern matching (LIKE '%value%')
  • IN operator: checks if a value is in a list (IN ('A', 'B', 'C'))
  • BETWEEN operator: checks range (BETWEEN 10 AND 20)
  • IS NULL / IS NOT NULL

JPQL supports two types of query parameters, which allow values to be dynamically assigned at runtime instead of hardcoding them in the query:

  • positional parameters (?1, ?2)

    Query query = em.createQuery("SELECT u FROM User u WHERE u.age > ?1 AND u.name = ?2");
    query.setParameter(1, 30);
    query.setParameter(2, "Bob");
  • named parameters (:paramName)

    TypedQuery<User> query = em.createQuery("SELECT u FROM User u WHERE u.age > :age", User.class);
    query.setParameter("age", 25);

    15.4.4 Named queries

Named Queries are predefined, reusable JPQL queries declared at the entity level. They allow better performance because the query is compiled once and cached. Named Queries help centralize query definitions, making the code more maintainable.

They are defined using the @NamedQuery annotation in the entity class Useful for static queries that do not change dynamically at runtime Multiple @NamedQuery on a single entity should be grouped using @NamedQueries to ensures cleaner and more organized code.

In fact some Java versions or JPA implementations do not support multiple standalone @NamedQuery annotations.

Named queries example:

@Entity
@NamedQueries({
    @NamedQuery(name = "User.findByAge", query = "SELECT u FROM User u WHERE u.age = :age"),
    @NamedQuery(name = "User.findMinors", query = "SELECT u FROM User u WHERE u.age < 18")
})
public class User {
    @Id private String cf;
    private String name;
    private int age;
}

Named Queries are executed using EntityManager.createNamedQuery() The returned Query or TypedQuery<T> object is used to set parameters and execute the query.

Query query = em.createNamedQuery("User.findByAge");
query.setParameter("age", 25);
List<User> users = query.getResultList();
TypedQuery<User> query = em.createNamedQuery("User.findMinors", User.class);
List<User> minors = query.getResultList();

Native queries allows executing raw SQL directly on the database. They are used when JPQL is insufficient or when leveraging database-specific functions.

Main difference from JPQL:

  • Use table and column names instead of entity and field names
  • Queries are written in pure SQL, not JPQL
  • Created via createNativeQuery(), instead of createQuery()

Parameter binding remains the same as JPQL, using setParameter(). Execution is performed using the same methods as JPQL queries

15.5 Repository Pattern

The Repository Pattern is an architectural pattern that provides an abstraction layer between the application’s business logic and the database. It defines a dedicated component responsible for retrieving, persisting, and managing entities, ensuring that data access operations are encapsulated and decoupled from other parts of the system.

Repository key features:

  • Acts as an intermediary between the ORM and business logic.
  • Encapsulates data access logic within a dedicated component.
  • Keeps database operations separate from business logic.
  • Provides a structured interface for querying and modifying entities, improving code maintainability.
  • Centralizes data access in one place, reducing code duplication and enforcing consistency.
  • Supports standard data access patterns, including CRUD operations, query methods, and custom queries.
  • Improves testability by abstracting persistence, making it easier to mock DB interactions or repositories entirely.

Repositories provide a standard set of methods to manage entities in the database. The four primary operations (CRUD)in a repository are:

  • Create – Inserts a new entity into the database.
  • Read – Retrieves entities by ID or search criteria.
  • Update – Modifies an existing entity and saves changes.
  • Delete – Removes an entity from the database.

Using a repository for CRUD operations ensures a consistent API across the application.

Repositories leverage ORM capabilities to query the database using entity attributes. Query methods allow filtering and retrieving entities based on their attributes, reducing the need to write explicit SQL.

Queries can be composed dynamically, combining multiple conditions within repository methods:

  • findByNameAndEmail (name, email): Finds an entity based on its name and email attribute.
  • existsByEmail(String email): Checks if an entity with a given email exists

Using repository query methods ensures cleaner and more maintainable business logic, keeping data access centralized.

When query methods are not enough, repositories can define custom query methods. Custom query methods are useful for:

  • Complex filtering with advanced multiple conditions
  • Aggregation queries (e.g., count, sum, averages)
  • Joins and nested queries involving multiple entities
  • Custom queries can be written using
    • ORM query languages (e.g., JPQL, TypeORM Query Builder)
    • Native SQL queries, for performance optimizations

While custom queries provide flexibility, they should be used carefully to maintain abstraction. If the result structure of a custom query does not match an entity, raw data (tuples) may be returned instead, causing a loss of abstraction

When handling large datasets, repositories provide mechanisms for efficient pagination and sorting.

  • Pagination: limits the number of results returned per request to improve performance. Example strategies: limit/offset, cursor-based pagination.
  • Sorting: orders results based on specified fields. Example: findAll(Sort.by(“name”).ascending()).

Proper use of pagination and sorting prevents excessive memory consumption and speeds up query execution.

15.6 Hibernate & H2

JPA is designed to be abstract, it requires:

  • a JPA provider, implementing all the methods and managing the persistence unit,
  • a Database, that will contain the data.

In the course we use Hibernate and H2 respectively for the two roles.

15.6.1 Hibernate

Hibernate is an Object-Relational Mapping (ORM) framework for Java that simplifies database interactions by allowing developers to work with Java objects instead of writing SQL queries manually.

It fully implements the Jakarta Persistence API (JPA), meaning it can function as a pure JPA provider, following all JPA specifications without requiring additional features. However, Hibernate is more than just a JPA provider. When integrated into higher-level frameworks such as Spring or Jakarta EE, it extends JPA with advanced capabilities such as caching, batch processing, and more efficient query execution Hibernate as JPA Provider.

When used as a pure JPA provider, Hibernate strictly adheres to the JPA specification:

  • uses JPA interfaces, such as EntityManagerFactory and EntityManager, to handle persistence.
  • manages transactions through EntityTransaction
  • provides its implementation of all the interfaces defined by JPA, making them available to work with.
  • supports JPQL and Criteria API as query languages
  • reads standard persistence.xml configuration, allowing easy portability across different JPA providers

When integrated with higher-level frameworks like Spring or Jakarta EE, Hibernate extends its basic JPA implementation with additional features, including:

  • Sessions: a flexible and powerful alternative to EntityManager with automatic transaction control capabilities
  • HQL (Hibernate Query Language): a more powerful alternative to JPQL with additional flexibility
  • Advanced Caching Mechanisms: first-level cache (always active) and optional second-level cache for optimized performance
  • Batch Processing: efficient handling of bulk data operations to improve write performance
  • Event Listeners and Interceptors: hooks that allow executing custom logic during entity lifecycle events
  • Flexible Fetch Strategies: advanced options for lazy and eager loading to optimize database queries
  • NoSQL Support: ability to interact with NoSQL databases

Although Hibernate works as a pure JPA provider, it also provides additional properties that enhance performance and database interaction. Some database-related settings are better optimized using Hibernate properties. Hibernate adds dialect support, which allows it to optimize SQL for different DBMS. Schema generation via Hibernate is faster and more efficient than the standard JPA mechanism.

Key Hibernate-Specific Properties:

  • hibernate.dialect: defines the database type, allowing Hibernate to generate optimized SQL
  • hibernate.hbm2ddl.auto: handles schema generation with more flexibility than JPA’s standard option
  • hibernate.show_sql: enables logging of SQL statements executed by Hibernate
  • hibernate.format_sql: formats SQL output for better readability Caching

Hibernate automatically enables first-level caching, even when used as a JPA provider. Entities retrieved within the same transaction are stored in the persistence context, avoiding redundant queries.

Hibernate supports lazy loading using proxy objects, even when acting as a JPA provider. When an entity is marked as FetchType.LAZY, Hibernate does not load the related entity immediately. Hibernate automatically loads lazy associations when accessed, unless the session is closed. It does not require explicit calls to JOIN FETCH to load lazy associations.

Common issue: LazyInitializationException

Occurs if an entity with a lazy-loaded relation is accessed outside an active transaction. Hibernate expects the session to be open when accessing proxies.

15.6.2 H2

H2 is an open-source relational database written in Java, designed to be lightweight, fast, and easy to integrate into Java applications. It requires no installation and supports standard SQL, JDBC, and in-memory operations, making it a flexible solution for both development and testing environments.

H2 is fully compatible with JPA and ORM frameworks, allowing seamless integration with Hibernate. It also includes a web-based console that enables developers to debug queries and inspect the database structure efficiently.

This database supports three different execution modes:

  • In-Memory Mode

    The database exists only in RAM and is lost when the application stops. Ideal for unit tests and temporary data storage

    JDBC URL: jdbc:h2:mem:testdb

  • Embedded Mode

    The database is stored in a local file accessible only from the same Java process. Suitable for small standalone applications.

    JDBC URL: jdbc:h2:file:./data/mydb

  • Server Mode

    H2 runs as a separate process, allowing multiple clients to connect. Can be accessed remotely via TCP or Web API.

    Useful when multiple applications need to share the same database

    JDBC URL: jdbc:h2:tcp://localhost/~/mydb

The H2 database includes a built-in web-based management interface. It provides access to the database for executing queries, modifying data, and managing schemas. Works similarly to traditional database workbenches but runs in a web browser. Useful for debugging, testing, and database administration

To start the console manually:

java -jar h2.jar

Alternatively is is possible to programmatically start the console server in Java:

Server.createWebServer("-web").start();

The console runs by default on port 8082: http://localhost:8082

There are a few basic properties that must be configure in Hibernate to operate with an H2 databse: These properties must be set in persistence.xml:

Database Connection

Driver and Dialect

Schema Management Controls automatic table creation and migration SQL Logging Enables logging of executed queries for debugging and performance analysis Configuration

  • Database Connection: defines how the application connects to H2

    jakarta.persistence.jdbc.driver = org.h2.Driver
    jakarta.persistence.jdbc.url = jdbc:h2:mem:testdb
    jakarta.persistence.jdbc.user = sa (default value)
    jakarta.persistence.jdbc.password = (empty by default)

    The JDBC URL determines whether the database runs in memory, as a file, or as a server. The JDBC driver must be specified to allow the application to interact with H2.

  • Dialect and Schema Management (implemented by hibernate)

    hibernate.dialect = org.hibernate.dialect.H2Dialect
    hibernate.hbm2ddl.auto = update

    The SQL dialect helps Hibernate generate efficient database-specific queries.

    The property hibernate.hbm2ddl.auto has a set of available values

    • none: no automatic schema generation. The database schema must be created manually.
    • validate: verifies that the database schema matches the entity definitions but does not create or modify tables. It throws an error if the schema is incorrect
    • update: updates the schema without dropping existing tables, it adds missing columns or constraints but does not remove them.
    • create: drops existing tables and creates the schema from scratch every time the application starts.
    • create-drop: creates the schema from scratch every time the application starts drops it when the session factory is closed.
  • SQL Logging (implemented by hibernate)

    hibernate.show_sql = true
    hibernate.format_sql = true

15.7 Implementation of an ORM-based application

In this section, we will implement a simple ORM-based application using Hibernate and H2 step-by-step

  1. Setting up the environment
  2. Persistence configuration
  3. Handling EntityManager
  4. Defining entities
  5. Implementing repositories
  6. Implementing a main executor for our application

15.7.1 Dependencies import

First step is to define the pom.xml file of the application, to import the necessary dependencies.

  • jakarta.persistence-api: JPA API for ORM
  • hibernate-core: Hibernate as the JPA provider
  • h2: the database

In the pom.xml:

        <dependency>
            <groupId>org.hibernate</groupId>
            <artifactId>hibernate-core</artifactId>
            <version>6.4.0.Final</version>
        </dependency>
        <dependency>
            <groupId>jakarta.persistence</groupId>
            <artifactId>jakarta.persistence-api</artifactId>
            <version>3.1.0</version>
        </dependency>
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <version>2.1.214</version>
        </dependency>

15.7.2 Persistence configuration

In persistence.xml we must set up

  • persistence unit
  • JPA provider (Hibernate)
  • database connection
  • driver, URL and access credentials
  • sql dialect
  • automatic schema generation
  • logging and debugging features

The persistence.xml must be placed in the resources/META-INF folder.

<persistence xmlns="http://xmlns.jcp.org/xml/ns/persistence" version="2.1">
    <persistence-unit name="flightPU">
        <provider>org.hibernate.jpa.HibernatePersistenceProvider</provider>
        <properties>
            <property name="jakarta.persistence.jdbc.driver" value="org.h2.Driver"/>
            <property name="jakarta.persistence.jdbc.url" 
                   value="jdbc:h2:file:./data/flightdb"/>
            <property name="jakarta.persistence.jdbc.user" value="sa"/>
            <property name="jakarta.persistence.jdbc.password" value=""/>            
            <property name="hibernate.dialect" 
                   value="org.hibernate.dialect.H2Dialect"/>
            <property name="hibernate.hbm2ddl.auto" value="update"/>
            <property name="hibernate.show_sql" value="true"/>
            <property name="hibernate.format_sql" value="true"/>
        </properties>
    </persistence-unit>
</persistence>

15.7.3 EntityManager handling

To avoid creating multiple EntityManagerFactory instances, we use a singleton utility class JPAUtil. The advantages is that it avoids redundant factory instances since EntityManagerFactory is expensive to create.

This class must offer some core features - Ensure proper resource management Providing a close() method to shut down Hibernate properly when the - application ends its lifecycle - Encapsulate EntityManagerFactory The factory is not exposed directly, but a controlled method provides access to EntityManager Allows application logic to interact only through EntityManager, ensuring proper lifecycle management

public class JPAUtil {
    private static final EntityManagerFactory emf = 
        Persistence.
        createEntityManagerFactory("flightPU");

    public static EntityManager getEntityManager() {
        return emf.createEntityManager();
    }
    public static void close() {
        if (emf.isOpen()) {
            emf.close();
        }
    }
}

15.7.4 Entity definition

In this step we must create the data model of our application:

  • Entities will be defined using JPA annotations: how Java objects and their attributes are mapped to database tables and columns.
  • Relationships: how Java objects are related and database data are linked across tables

15.7.4.1 Entities

@Entity @Table(name = "airplanes")
public class Airplane {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Column(nullable = false)
    private String model;
    @Column(nullable = false)
    private String airline;

    private int capacity;
    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private AirplaneStatus status;    
}

15.7.4.2 OneToOne relationships

A One-to-One relationship links two entities where each instance of one entity is associated with exactly one instance of another entity By default, One-to-One relationships are FetchType.EAGER Owner side It contains the @JoinColumn annotation because it holds the foreign key Hibernate considers this entity responsible for managing the relationship Inverse side It uses mappedBy to reference the relationship without creating an extra foreign key column It defines cascading and orphanRemoval behavior When querying the inverse side, Hibernate will JOIN the owner table to retrieve the associated object OneToOne relationships

Owner side:

@Entity
@Table(name = "passports")
public class Passport {
    @Id
    @Size(min = 8, max = 8)
    private String number;

    @Column(nullable = false)
    private String nationality;

    @OneToOne
    @JoinColumn(name = "person_id", 
                nullable = false)
    private Person person;
}

Inverse side:

@Entity
@Table(name = "people")
public abstract class Person {
    // ...
    @OneToOne(mappedBy = "person", 
              cascade = CascadeType.ALL, 
              orphanRemoval = true)
    private Passport passport;
}

15.7.4.3 OneToMany/ManyToOne relationships

A One-to-Many (@OneToMany) means that one entity is related to multiple instances of another entity. A Many-to-One (@ManyToOne) means that multiple instances of one entity reference a single instance of another entity. These two annotations represent the same database relationship, just seen from different perspectives.

@ManyToOne - Owner Side This side contains the foreign key column in the database This is where the relationship is actually managed Default FetchType.EAGER

@Entity
@Table(name = "flights")
public class Flight {
    //...
    @ManyToOne
    @JoinColumn(name="airplane_id", 
                nullable=false)
    private Airplane airplane;
}

@OneToMany - Inverse Side Uses mappedBy to reference the owner Does not create an additional foreign key column Default FetchType.LAZY OneToMany/ManyToOne relationships

@Entity
@Table(name = "airplanes")
public class Airplane {
   // ...
    @OneToMany(mappedBy = "airplane")
    private Set<Flight> flights;
}

15.7.4.4 ManyToMany relationships

A Many-to-Many relationship means that multiple instances of one entity are associated with multiple instances of another entity This relationship is represented by a join table, which stores the associations between the two entities JPA automatically creates the join table with two foreign keys Default FetchType: LAZY (on both sides)

Owner Side

@Entity @Table(name = "flights")
public class Flight { 
    // ...
    @ManyToMany
    @JoinTable(
        name = "flight_person",
        joinColumns = @JoinColumn(name = "flight_id"),
        inverseJoinColumns = @JoinColumn(name = "person_id")
    )
    private Set<Person> passengers = new HashSet<>();
}

The entity that defines the @JoinTable annotation owns the relationship. It explicitly specifies the join table name and the foreign key columns.

Inverse side

@Entity
@Table(name = "people")
public class Person {
    //...
    @ManyToMany(mappedBy = "passengers")
    private Set<Flight> flights = new HashSet<>();
}

Inverse side uses mappedBy to reference the owner side. Does not create the join table, as it is already managed by the owner ManyToMany relationships.

15.7.5 Repository

JPA does not define a repository concept, and neither does Hibernate when used as a pure JPA provider. This means that repository classes must be implemented manually to handle entity persistence.

The main purpose of a repository is to limit direct access to EntityManager, ensuring a structured and maintainable approach. Instead of passing EntityManager throughout the application, repositories encapsulate database operations, exposing only controlled methods for data access.

15.7.5.1 Generic Repository

Since JPA does not define a repository concept, a Generic Repository is a way to standardize common database operations. A GenericRepository class defines reusable methods using Java Generics, making it adaptable to different entity types. It allows to centralize basic CRUD operations for any entity type, avoiding code duplication. Create, update, and delete operations can be fully generalized, as they follow the same persistence logic for any entity. The only generic read operations, independent of entity-specific condition, are search by id and unconditioned search. This approach ensures that basic persistence logic is implemented once, and specific repositories can extend it for more advanced queries.

public class GenericRepository<T, ID> {
    private final Class<T> entityClass;
    protected GenericRepository(Class<T> entityClass) {
        this.entityClass = entityClass;
    }
    public void update(T entity) {
        EntityManager em = JPAUtil.getEntityManager();
        EntityTransaction tx = em.getTransaction();
        tx.begin();
        em.merge(entity);
        tx.commit();
        em.close();
    }
    public void delete(ID id) {
        EntityManager em = JPAUtil.getEntityManager();
        EntityTransaction tx = em.getTransaction();
        tx.begin();
        T entity = em.find(entityClass, id);
        if (entity != null) {
            em.remove(entity); }
        tx.commit();
        em.close();
    }
    public void save(T entity) {
        EntityManager em = JPAUtil.getEntityManager();
        EntityTransaction tx = em.getTransaction();
        tx.begin();
        em.persist(entity);
        tx.commit();
        em.close();
    }
    public T findById(ID id) {
        EntityManager em = JPAUtil.getEntityManager();
        T entity = em.find(entityClass, id);
        em.close();
        return entity;
    }
    public List<T> findAll() {
        EntityManager em = JPAUtil.getEntityManager();
        List<T> result = em.createQuery("SELECT e FROM " 
                          + entityClass.getSimpleName() 
                          + " e", entityClass)
                .getResultList();
        em.close();
        return result;
    }
}

15.7.5.2 Typed Repository

While a Generic Repository provides common CRUD operations for any entity, a Typed Repository is a specialized implementation for a specific entity type.

It extends the Generic Repository and allows defining custom queries tailored to the entity’s attributes and business logic. A Typed Repository enables more complex data retrieval using JPQL, Criteria API, or native SQL queries, without exposing raw EntityManager operations in the application logic. This approach ensures a structured separation of concerns, keeping generic persistence logic in the Generic Repository and custom queries in the Typed Repository.

public class FlightRepository extends GenericRepository<Flight, Long> {
    public FlightRepository() {
        super(Flight.class);
    }    
    public List<Flight> findByAirplaneModel(String model) {
        EntityManager em = JPAUtil.getEntityManager();
        CriteriaBuilder cb = em.getCriteriaBuilder();
        CriteriaQuery<Flight> query = cb.createQuery(Flight.class);
        Root<Flight> root = query.from(Flight.class);
        Join<Flight, Airplane> airplaneJoin = root.join("airplane");
        query.select(root)
        .where(cb.equal(airplaneJoin.get("model"), model));
        List<Flight> flights = em.createQuery(query).getResultList();
        em.close();
        return flights;
    }
}

15.7.6 Main executor

The Main Executor serves as the entry point for the application, handling entity persistence and data manipulation in the database. It is necessary to explicitly close EntityManagerFactory when the application shuts down. A shutdown hook is registered to ensure that Hibernate properly releases resources at the end of execution. Note that in frameworks like Spring or Jakarta EE, this process is managed automatically it must be handled manually.

public class DatabaseInitializer {

    private static final GenericRepository<Airplane, Long> airplaneRepository = new GenericRepository<>(Airplane.class) {};
    private static final FlightRepository flightRepository = new FlightRepository();

    public static void main(String[] args) {
        Runtime.getRuntime().addShutdownHook(new Thread(JPAUtil::close));
        DatabaseInitializer.initialize();
    }

    public static void initialize() {
        Airplane airplane1 = createAirplane("Boeing 737", "Airways", 180, AirplaneStatus.IN_SERVICE);
        Airplane airplane2 = createAirplane("Airbus A320", "Sky Airlines", 160, AirplaneStatus.IN_SERVICE);
        airplaneRepository.save(airplane1);
        airplaneRepository.save(airplane2);

        Flight flight1 = createFlight("FL1234", "2024-04-01T10:00", "2024-04-01T13:00", airplane1);
        Flight flight2 = createFlight("FL5678", "2024-04-02T12:00", "2024-04-02T16:00", airplane2);

        flightRepository.save(flight1);
        flightRepository.save(flight2);
    }
}

15.8 Testing JPA Applications

To test an application using an ORM:

  • Use a PU dedicated to testing using drop-and-create policy
  • Each test should start with well defined database contents
  • Cleanup is required to avoid poisoning successive test cases
  • Use different data (especially keys) in different tests Tests may be executed in parallel so they should not interfere in insert, delete, and updated operations.

Entities are simple data holders, representing the structure of the database

They do not contain business logic, only fields, relationships, and metadata (e.g., constraints)

Testing them is not necessary, as their correctness is enforced by

  • The ORM itself, which ensures mappings are correctly applied
  • The database schema, which enforces constraints (e.g., foreign keys, uniqueness)
  • Other application tests, where entities are implicitly used and validated

The persistence unit in test can be a in-memory db to reduce test time. The database must be recreate often, on every test session. Enable logging to make debugging easier.

<property name="jakarta.persistence.jdbc.url" 
  value="jdbc:h2:mem:testuniversitydb;DB_CLOSE_DELAY=-1"/>
<property name="jakarta.persistence.schema-generation.database.action" value="drop-and-create"/>
<property name="hibernate.show_sql" value="true"/>
<property name="hibernate.format_sql" value="true"/>

A typical set-up and tear-down of tests when tests do no write to db

    @BeforeAll
    static void populateDb() {
        JPAUtil.setTestMode();
//…
    }

    @AfterAll
    static void cleanDb() {
        JPAUtil.close();
    }

While, in a settings where test cases need to write to the db, it must be reset before each test to avoid interference.

    @BeforeEach
    static void populateDb() {
        JPAUtil.setTestMode();
//…
    }

    @AfterEach
    static void cleanDb() {
        JPAUtil.close();
    }

This latter configuration is much more expensive in terms of computation and time, so it should be adopted when strictly needed.