From SQL to JPA

From SQL to JPA

In the SQL primer, we manipulated rows directly. Our Java application, however, works with objects. How do we cross that boundary, and who should do the work?

This guided exploration takes about 45 to 60 minutes. We will run the same small example first with JDBC, then with JPA. The goal is not to memorize annotations yet, but to discover the problems they address.

Getting ready (5 minutes)

Clone the project locally:

$ git clone https://gitlab.com/noseda_a/jpa-bridge.git

From the jpa-bridge directory, run:

$ ./gradlew bootRun --args=jdbc

On Windows, use gradlew.bat instead of ./gradlew.

Among the build output, you should see:

Before: 2 orders, 2 order lines, 2 products
Order 1 for Ada: [2 x Notebook, 3 x Pen]
Order 2 for Grace: []
After: 2 orders, 2 order lines, 2 products

Every invocation starts a fresh database and reloads src/main/resources/schema.sql and data.sql. Changes from one run do not survive the next. The application validates its JPA mappings against that schema; it does not ask Hibernate to create the tables.

The Java sources are in src/main/java/fr/emse/tb3pwme/jpabridge/. Start with JdbcOrders.java and JpaBridgeApplication.java. The supplied classes already contain JPA annotations for the later experiments. JDBC does not interpret those annotations.

JDBC: executing SQL from Java (10 minutes)

JDBC (Java Database Connectivity) is Java’s standard API for communicating with databases. A database-specific driver, such as the H2 driver, implements this API. We write SQL ourselves and decide how its results become Java objects.

In our application, Spring Boot configures a DataSource, which provides connections to H2. The important JDBC types are:

  • Connection: a connection to the database.

  • PreparedStatement: an SQL statement with parameters.

  • ResultSet: the tabular result of a query, read one row at a time.

Open JdbcOrders.findById. Its query is:

1SELECT o.id AS order_id, o.customer,
2       l.id AS line_id, l.quantity,
3       p.id AS product_id, p.name, p.price
4FROM orders o
5LEFT JOIN order_lines l ON l.order_id = o.id
6LEFT JOIN products p ON p.id = l.product_id
7WHERE o.id = ?
8ORDER BY l.id

The method gets a connection, prepares this statement, binds the ID using statement.setLong(1, id), and executes it. The ? is a parameter. The surrounding try-with-resources blocks close the result set, statement, and connection even if execution fails.

For order 1, the result looks like this:

order_id customer line_id quantity product_id name price

1

Ada

1

2

1

Notebook

4.50

1

Ada

2

3

2

Pen

1.20

But the application needs one Order containing two OrderLine objects, each referring to a Product. The mapping loop therefore creates the order only once, then builds its lines:

 1Order order = null;
 2while (rows.next()) {
 3    if (order == null) {
 4        order = new Order(rows.getLong("order_id"), rows.getString("customer"));
 5    }
 6    Long lineId = rows.getObject("line_id", Long.class);
 7    if (lineId != null) {
 8        Product product = new Product(
 9                rows.getLong("product_id"),
10                rows.getString("name"),
11                rows.getBigDecimal("price"));
12        order.addOrderLine(new OrderLine(lineId, rows.getInt("quantity"), product));
13    }
14}

Predict, change, run, explain:

  1. Why would creating a new Order on every row be wrong?

  2. Replace both LEFT JOIN`s with `JOIN`s and rerun the `jdbc experiment. What happens when it reaches Grace’s order, which has no lines? Explain why an existing empty order is not the same thing as a missing order. Restore the `LEFT JOIN`s afterward.

  3. In data.sql, change the quantity of line 1 from 2 to 5. Rerun and verify that the reconstructed object now reports 5 x Notebook. Restore the original quantity before continuing.

  4. Why does getObject("line_id", Long.class) need to distinguish SQL NULL from an actual identifier?

Two models, not just two syntaxes (10 minutes)

This translation problem is called the object-relational impedance mismatch. The term describes differences between the object model and the relational model, not merely differences in naming conventions.

In the object model In the relational model

An Order holds a collection of OrderLine references.

Rows of order_lines contain an order_id foreign key; there is no collection stored in the orders row.

We navigate references, such as line.getProduct().

We join tables or execute additional queries.

An object has Java identity, tested with ==.

A stored entity is identified by its primary key; loading the same row twice does not inherently create the same Java instance.

Objects may be mutable and reachable through several references.

Changes must become explicit INSERT, UPDATE, and DELETE statements inside transactions.

A domain model can express composition and ownership.

Foreign keys constrain references; they do not, by themselves, express the complete lifecycle policy of a domain object.

Who owns an order line?

An order is composed of its lines: each OrderLine belongs to exactly one Order and has no existence apart from it. A product, in contrast, exists independently and can be referenced by many orders: it is associated with order lines, not composed into them.

Object composition compared with relational foreign keys

The filled diamond denotes composition: the whole governs its part’s lifecycle, not merely a structural link. The arrows on the relational side only point from a foreign key to the referenced primary key; by itself, a foreign key says nothing about who owns whom.

Because Order-OrderLine is composition, two consequences follow directly, without any extra rule to invent:

  • Deleting an order should delete its lines.

  • Removing a line from an order’s collection should delete that line, not merely detach it, since it cannot exist outside an order.

Because OrderLine-Product is a plain association rather than composition, neither consequence should apply to products.

Before running the next experiments, think through:

  1. A foreign key, by itself, does not make the database delete referencing rows when the referenced row is deleted. So how should order deletion actually happen at the SQL level?

  2. Setting a Java reference to null does not delete anything in the database. So what has to happen for "removing a line from the collection" to become a real deletion?

Keep three meanings of "ownership" distinct:

  • Domain ownership: the order governs its lines' lifecycle, because we modeled that relationship as composition.

  • Foreign-key placement: order_lines.order_id references orders.id. In the bidirectional JPA mapping supplied here, OrderLine.order is the owning side of the association: it determines the foreign-key value. That technical term does not make the line the domain owner of the order.

  • Java object lifetime: an object may become eligible for garbage collection when it is no longer reachable. Garbage collection does not delete its database row, and deleting a row does not immediately destroy any Java object.

A foreign key without a deletion action normally rejects deletion of an order while its lines still reference it. Our schema deliberately has no ON DELETE CASCADE. With manual SQL, one way to implement order deletion is to delete its lines first and then the order, in one transaction. Otherwise a failure between statements could leave only part of the intended change applied.

ORM: delegating the recurring translation work (5 minutes)

An object-relational mapper (ORM) uses mapping metadata to connect classes, fields, and associations to tables, columns, and foreign keys. Instead of repeatedly writing the mapping loop and every persistence statement, we describe the mapping and let the ORM perform much of that recurring work.

Depending on the ORM and configuration, this includes:

  • Constructing objects and associations from query results.

  • Tracking persistent identity within a unit of work, so that the same row is represented consistently.

  • Detecting changes to managed objects and generating SQL when changes are flushed to the database.

  • Propagating selected persistence operations across associations, when explicitly configured.

This is not the elimination of SQL, transactions, or database design. It is a different way to organize the translation between two models. For example, the ORM still needs a policy for loading a collection: immediately, on demand, or as part of an explicit query.

JPA: a shared contract, not a particular ORM (5 minutes)

Java persistence tools existed before JPA, each with its own APIs and mapping conventions. JPA, originally the Java Persistence API, emerged as a standard in Java EE 5 in 2006. Today it is Jakarta Persistence, with types in the jakarta.persistence package. The familiar acronym JPA is still widely used.

JPA standardizes such things as entity mappings, the EntityManager API, persistence contexts, and the JPQL query language. It is a specification, not a library that can perform persistence on its own. Hibernate ORM is a provider: an implementation of that specification. EclipseLink is another. Standardization reduces dependence on a proprietary API, but provider-specific features and SQL dialects can still affect portability.

In the supplied application, Spring Boot configures Hibernate, and the JpaOrders service uses the JPA EntityManager. There are no Spring Data repository interfaces yet.

Compare the behavior with JPA (15 minutes)

First, load the same orders:

$ ./gradlew bootRun --args=jpa-load

You should see the same two order descriptions as in the JDBC experiment, with unchanged row counts. You will also see SQL prefixed with Hibernate:.

Open JpaOrders.java. At the center of the loading operation is:

Order order = entityManager.find(Order.class, id);

The supplied mappings tell Hibernate how to build the objects. The service describes them while its transaction is still active, allowing it to load lazily fetched associations as they are accessed. The JDBC mapping loop has disappeared from the application code; equivalent work still happens inside Hibernate.

Observe: how many SQL statements are executed to describe Ada’s order? Look for queries loading its lines and products. Do not assume that traversing an object graph means executing a single join. The exact SQL is provider-dependent, and changing a fetch strategy can change the number of queries.

Experiment 1: delete the whole order

Predict the final counts, then run:

$ ./gradlew bootRun --args=jpa-delete

Expected final counts:

After: 1 orders, 0 order lines, 2 products

The service calls entityManager.remove(order) inside a transaction. The supplied mapping cascades removal from the order to its lines, but not from a line to its product. Inspect the emitted SQL: Hibernate deletes the lines before the order. This is ORM-driven cascading, not a database ON DELETE CASCADE action.

Experiment 2: remove just one line

This run starts again with both orders and both lines. Predict the final counts, then run:

$ ./gradlew bootRun --args=jpa-remove-line

Expected final counts:

After: 2 orders, 1 order lines, 2 products

Read JpaOrders.removeLine and Order.removeOrderLine. The application removes line 1 from the collection and clears its back-reference. It never explicitly calls entityManager.remove(line). With the supplied orphan-removal mapping, Hibernate detects the collection change and deletes that line’s row when the transaction is flushed. It does not store a line with a NULL order.

This behavior depends on a managed object, an active transaction, and the chosen mapping. Removing an element from an ordinary Java collection does not generally delete a row. Nor would changing the JDBC-created objects automatically write anything back. Transaction completion flushes the managed changes and commits them before our separate JDBC count query runs.

Explain to a partner: what is different between cascading an order’s deletion and deleting a line that has been removed from a still-existing order? Why would propagating deletion from an order line to its product violate our domain rules?

We deliberately treated the supplied annotations as a black box. The next chapter opens that box: mappedBy, cascade, orphanRemoval, and the two sides of a bidirectional association.

A useful abstraction, not an uncontested one

ORMs reduce repetitive persistence code, but their abstraction has costs. Lazy navigation can cause many small queries, often called the N+1 query problem. A query that looks convenient in Java can load too much data or generate inefficient SQL. Complex reporting and bulk operations may be clearer and more efficient when expressed directly in SQL. Mappings and persistence-context behavior are also concepts developers must learn.

These trade-offs have been debated for a long time. In 2006, Ned Batchelder discussed Ted Neward’s critique of ORM, emphasizing the difficulty of reconciling the object and relational models. The debate continues: the FOSDEM 2026 talk "You do not need an ORM" presented Squirrel, a Gleam library using code generation from SQL as an alternative approach. That is not a Java/JPA tutorial, but it illustrates that an ORM is not the only answer to the translation problem.

The practical conclusion is not "always use an ORM" or "never use an ORM". Understand what it does for you, inspect its SQL, and choose tools according to the workload.

Where Spring Data JPA fits

We have now used JDBC directly and JPA’s EntityManager. Spring Data JPA adds another layer: repository interfaces, implementations supplied by Spring, query derivation, and other conveniences on top of JPA. It neither replaces the JPA provider nor makes database access independent of SQL.

In the typical Spring Boot configuration used here, the layers are:

Application code using Spring Data JPA repositories
                        |
                        v
                JPA API / EntityManager
                        |
                        v
             Hibernate ORM (JPA provider)
                        |
                        v
                 JDBC + H2 driver
                        |
                        v
                    H2 database

Application code can also use the EntityManager directly, as our experiments do, without the repository layer. Spring Boot configures these components; it is not itself an ORM.

Other Spring integrations make different choices:

  • Spring JDBC, part of the Spring Framework, offers tools such as JdbcTemplate and JdbcClient. It reduces JDBC resource-handling and error-handling boilerplate while leaving SQL and row mapping explicit.

  • Spring Data JDBC offers repository support with a different, aggregate-oriented mapping model; it is not JPA with a different provider.

  • Other Spring Data projects support technologies such as MongoDB and Redis, with their own models rather than JPA mappings.

Next, we will examine entity mappings, then introduce Spring Data repositories.

Check your understanding

  1. Which work did you do manually with JDBC that Hibernate did for you?

  2. Why is OrderLine.order the JPA owning side even though Order owns the lines in the domain?

  3. What distinguishes removing a reference, garbage-collecting an object, and deleting a row?

  4. What determines whether order lines and products survive an order’s deletion?

  5. Which of JDBC, JPA, Hibernate, and Spring Data JPA is an API, a specification, a provider, or a repository abstraction?

  6. Give one reason to choose an ORM and one reason to keep an operation in explicit SQL.

Tip

The exercise includes automated checks for loading, empty orders, deletion, orphan removal, and foreign-key enforcement. Run ./gradlew test after restoring your experimental changes.

For course maintainers: the source application lives in examples/jpa-bridge at the repository root. From that directory, run ./gradlew test lessonZip to test it and regenerate the download beside this page. The diagram source is available as PlantUML.