As a Java developer, you can choose between various frameworks to store your data in a relational database. If you’re looking for an object-relational mapping framework that follows DDD and is not very complex but still handles most of the standard CRUD operations for you, you should try Spring Data JDBC.

In his recent Expert Session in the Persistence Hub, Jens Schauder, the main developer of Spring Data JDBC, described it as an object-relational mapping framework without the complexity of JPA. JPA stands for Jakarta Persistence API, and its implementation Hibernate is the most popular persistence framework in the Java ecosystem. You can find many articles about both of them here on the blog. They provide lazy loading, automatic dirty checks, multiple caching layers, and many other highly sophisticated features that can help you build highly scalable persistence layers. But these features also require a good understanding of JPA and Hibernate and are often the reasons for bugs and performance problems.

The goal of Spring Data JDBC is to avoid most of this complexity so that it’s easier to understand. Other frameworks automatically execute SQL statements if they detect a new or changed entity. They might also execute statements to get information from the database. Spring Data JDBC doesn’t do any of this.

If you want to read an entity from the database, persist a new entity, or update an existing one, you need to call one of Spring Data JDBC’s repository methods. It then generates the required SQL statement and executes it. This might require an additional line of code in your business code, but it puts you in control of all executed statements.

You no longer need to wonder if or when your persistence layer interacts with the database. Spring Data JDBC only executes SQL statements when you call a repository method. It puts you in full control of your database interactions and, at the same time, allows you to focus on your business logic.

What Spring Data JDBC provides

Even though Spring Data JDBC tries to avoid the complexity you might know from other object-relational mapping (ORM) frameworks, it’s still an ORM framework. It provides a mapping between your Java classes and the tables in a relational database. As you will see later in this article, this mapping is based on several defaults, so you often only need to provide 1 annotation to define your entity class and its mapping to the underlying database table. But you can, of course, provide additional mapping information if your default mapping doesn’t fit your table model.

Spring Data JDBC focuses on the concepts of aggregates and entities as they are defined in domain-driven design (DDD). An aggregate is a cluster of entities that are treated as a single unit. All entities of an aggregate depend on the aggregate root. Based on these concepts, you can model unidirectional associations from the aggregate root to other entities within the same aggregate. And you can define references to other aggregates, which you can resolve via a repository.

Like other Spring Data modules, Spring Data JDBC provides repositories that you can use to load and persist aggregates. They provide standard methods to fetch aggregates by their primary key, persist new ones, and update or remove existing ones. You can also use Spring Data’s popular derived query feature to let Spring Data JDBC generate a query based on the name of a repository method.

What Spring Data JDBC doesn’t provide

In contrast to JPA, Spring Data JDBC doesn’t manage your entity objects or uses a persistence context or 1st level cache. Due to that, it can’t perform any automatic dirty checks and can’t delay the execution of SQL statements. This might sound like a limitation compared to JPA, but it also makes your persistence layer and its database interactions easier to understand.

Whenever you want to persist a new, or change or delete an existing entity or aggregate, you need to call the corresponding method on the repository. Spring Data JDBC then immediately executes the required SQL statement and returns the result.

When you’re loading aggregates from the database, Spring Data JBC executes an SQL statement, maps the result to the defined projection, and returns it. It doesn’t get parts of or the entire result from any caches, and it doesn’t keep any references to the returned objects. That reduces the overhead and avoids a common pitfall in JPA, where you execute a query but get the result from your 1st level cache instead and don’t see the latest changes performed by a database trigger or native query.

Lazy loading of associated entities is another feature that other ORM frameworks, e.g., Spring Data JPA, offer. Spring Data JDBC does not support that. It fetches the entire aggregate with all associated entities when you fetch an aggregate from the database. This makes it important that you familiarize yourself with the concept of aggregates and entities as defined in DDD. If you model it right, your aggregate is relatively small and concise, and you should be able to fetch it without causing performance problems.

As mentioned earlier, Spring Data JDBC uses references to model an association between aggregates. The main difference between a reference to another aggregate and a modeled association to an entity within the same aggregate is that a reference doesn’t get automatically fetched. A reference represents the foreign key stored in the database. If you want to load the referenced aggregate, you can call one of Spring Data JDBC’s repository methods with the reference. Spring Data JDBC then executes an SQL statement and gets the referenced aggregate with all its entities from the database.

OK, enough theory. Let’s take a look at a simple example that defines a simple aggregate and repository. This example will only give you a quick overview of using Spring Data JDBC. I will discuss each part in more detail in future articles.

Adding Spring Data JDBC to your project

If you’re using Spring Boot, it takes 2 steps to add Spring Data JDBC to your project. You first need to add a dependency to spring-boot-starter-data-jdbc and the JDBC driver of your database to the dependencies of your project.

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-jdbc</artifactId>
</dependency>

<dependency>
  <groupId>org.postgresql</groupId>
  <artifactId>postgresql</artifactId>
  <scope>runtime</scope>
</dependency>

In the next step, you need to configure your database connection in your application.properties file.

spring.datasource.url=jdbc:postgresql://localhost:5432/spring-data-jdbc
spring.datasource.username=postgres
spring.datasource.password=postgres
spring.datasource.driver-class-name=org.postgresql.Driver

After you do that, you can start using Spring Data JDBC. A typical first step is to model your aggregates and entity classes.

Defining your first aggregate with multiple entities

Aggregates are a group of entities. And Entities in Spring Data JDBC are simple POJOs that only require an attribute with an @Id annotation to be identified as an entity class. That makes their definition easy.

The following 2 code snippets show the definition of a ChessGame aggregate that consists of the ChessGame entity class as the aggregate root and the ChessMove entity class.

public class ChessGame {

	@Id
	private Long id;
	
    private String playerWhite;

    private String playerBlack;

    private List<ChessMove> moves = new ArrayList<>();

    // getter and setter methods
}
public class ChessMove {

    private Integer moveNumber;

    private MoveColor color;

    private String move;

    // getter and setter methods
}

As you can see in the code snippet, I only annotated the id attribute of the ChessGame class with an @Id annotation. Spring Data JDBC then expects the primary key value to be managed by the database, e.g., by an auto-incremented column and returned in response to the SQL INSERT statement. I rely on Spring Data JDBC’s default mapping for all other attributes.

That also includes the mapping of the one-to-many association from ChessGame to the ChessMove entities. In contrast to JPA, association mappings don’t require additional mapping annotations in Spring Data JDBC. That’s because it doesn’t support any bidirectional associations and many-to-many associations. A mapped association is always from the aggregate root to the dependent child entities, and these can either be one-to-one or one-to-many associations.

A many-to-many association is always an association between 2 aggregates and gets mapped via references. I will explain this in more detail in a future article.

Let’s create a repository for the ChessGame aggregate next.

Creating a repository

Like with all other Spring Data modules, you should define a repository for each aggregate and not for each entity class. The definition of such a repository is also consistent with other Spring Data modules. You create an interface that extends one of Spring Data’s standard repository interfaces and provide the entity class and the type of its primary key as type information. In this example, my ChessGameRepository extends Spring Data’s CrudRepository.

public interface ChessGameRepository extends CrudRepository<ChessGame, Long> {

    List<ChessGame> findByPlayerBlack(String playerBlack);
}

The CrudRepository defines a set of standard methods to persist new, update or remove existing aggregates, count or fetch all aggregates and fetch one aggregate by its primary key.

In the previous example, I added the method findByPlayerBlack. It’s a derived query method. Like Spring Data JPA, Spring Data JDBC generates a query based on the method name. In this case, it generates a query statement that selects all records in the chess_game table with a matching name for playerBlack.

Persisting and querying aggregates

After defining your aggregates and repositories, you can use them in your business code. Let’s persist a ChessGame with some ChessMoves first.

ChessGame game = new ChessGame();
game.setPlayerWhite("Thorben Janssen");
game.setPlayerBlack("A strong player");

ChessMove move1white = new ChessMove();
move1white.setMoveNumber(1);
move1white.setColor(MoveColor.WHITE);
move1white.setMove("e4");
game.getMoves().add(move1white);

ChessMove move1Black = new ChessMove();
move1Black.setMoveNumber(1);
move1Black.setColor(MoveColor.BLACK);
move1Black.setMove("e5");
game.getMoves().add(move1Black);

gameRepo.save(game);

As you can see, you don’t have to do anything specific to Spring Data JDBC. If you used Spring Data JPA or any other Spring Data module, this test case would look the same. That’s one of the great things about Spring Data.

When you execute the code, you can see in the log output that Spring Data JDBC first persists a record in the chess_game table before it persists a record in the chess_move table for each ChessMove object.

2022-05-19 14:24:42.294 DEBUG 31848 --- [           main] o.s.jdbc.core.JdbcTemplate               : Executing SQL update and returning generated keys
2022-05-19 14:24:42.295 DEBUG 31848 --- [           main] o.s.jdbc.core.JdbcTemplate               : Executing prepared SQL statement [INSERT INTO "chess_game" ("player_black", "player_white") VALUES (?, ?)]
2022-05-19 14:24:42.338 DEBUG 31848 --- [           main] o.s.jdbc.core.JdbcTemplate               : Executing SQL update and returning generated keys
2022-05-19 14:24:42.338 DEBUG 31848 --- [           main] o.s.jdbc.core.JdbcTemplate               : Executing prepared SQL statement [INSERT INTO "chess_move" ("chess_game", "chess_game_key", "color", "move", "move_number") VALUES (?, ?, ?, ?, ?)]
2022-05-19 14:24:42.346 DEBUG 31848 --- [           main] o.s.jdbc.core.JdbcTemplate               : Executing SQL update and returning generated keys
2022-05-19 14:24:42.346 DEBUG 31848 --- [           main] o.s.jdbc.core.JdbcTemplate               : Executing prepared SQL statement [INSERT INTO "chess_move" ("chess_game", "chess_game_key", "color", "move", "move_number") VALUES (?, ?, ?, ?, ?)]

This shows that Spring Data JDBC handles an aggregate as a unit. When you persist the aggregate root, it automatically persists all associated entities.

The same happens when you fetch an aggregate from the database. Let’s call the findByPlayerBlack method of the ChessGameRepository that we defined in the previous section. It returns a ChessGame entity, which is the aggregate root, and all associated entities. You can, of course, also use a different projection. I will show you how to do that in a future article.

List<ChessGame> games = gameRepo.findByPlayerBlack("A strong player");
games.forEach(g -> log.info(g.toString()));

The log output shows that Spring Data JDBC first executed a query that returns all ChessGame entities played by a player with the black pieces whose name matches the provided bind parameter value.

2022-05-25 09:00:26.230 DEBUG 36564 --- [           main] o.s.jdbc.core.JdbcTemplate               : Executing prepared SQL statement [SELECT "chess_game"."id" AS "id", "chess_game"."player_black" AS "player_black", "chess_game"."player_white" AS "player_white" FROM "chess_game" WHERE "chess_game"."player_black" = ?]
2022-05-25 09:00:26.267 DEBUG 36564 --- [           main] o.s.jdbc.core.JdbcTemplate               : Executing prepared SQL query
2022-05-25 09:00:26.268 DEBUG 36564 --- [           main] o.s.jdbc.core.JdbcTemplate               : Executing prepared SQL statement [SELECT "chess_move"."move" AS "move", "chess_move"."color" AS "color", "chess_move"."move_number" AS "move_number", "chess_move"."chess_game_key" AS "chess_game_key" FROM "chess_move" WHERE "chess_move"."chess_game" = ? ORDER BY "chess_game_key"]
2022-05-25 09:00:26.281  INFO 36564 --- [           main] com.thorben.janssen.TestQueryMethod      : ChessGame [id=1, playerBlack=A strong player, playerWhite=Thorben Janssen, moves=[ChessMove [moveNumber=1, color=WHITE, move=e4], ChessMove [moveNumber=1, color=BLACK, move=e5]]]

When it retrieves the query result and maps each record to a ChessGame object, Spring Data JDBC executes another query to get the associated ChessMove objects. This causes an n+1 select issue, and you should be careful about fetching aggregates if you only need some of its fields. In that case, it’s better to choose a different projection.

Conclusion

Spring Data JDBC is an object-relational mapping framework for relational databases that aims to avoid most of the complexity of other ORM frameworks. It does that by avoiding features like lazy loading, managed lifecycles of entity objects and caching. It instead puts the developer in control of all executed SQL statements. That makes it easier to predict when your persistence layer executes which SQL statements, but it also requires you to trigger all write and read operations.

Working with Spring Data JDBC is very similar to working with other Spring Data modules. You define aggregates that consist of multiple entity objects and repositories.

The implementation of an entity is very simple. You define a POJO and annotate the primary key attribute with @Id. Associations to other entities within the same aggregate are modeled as an attribute of the type of the associated entity class or a java.util.List of the associated entity class. If you want to reference another aggregate, you need to model it as a reference and not as an association.

The definition of a repository follows the standard Spring Data pattern. You only need to define an interface that extends one of Spring Data’s standard repository interfaces, and Spring Data JDBC provides the required implementation. You can also add your own repository methods as derived queries or use the @Query annotation that you might know from other Spring Data modules.