Thursday, April 16, 2026

A bit about relational data in Java / Spring

Stepping away from JPA 

I recently found myself back in the Java world.

tl;dr

I belatedly discover spring data relational jdbc and realise it would have been a better fit for a lot of the things I used to use JPA for. 

the issues

If you've worked with JPA/Hibernate you will likely have encountered one or more of the following issues:

lazy initialisation issues / N+1 query performance issues

You defined your model - now when do you load relations and how does that impact performance? The tendency is to defer thinking about this to a later point in time and probably end up with something hackish like open session in view. (This is perfectly fine depending on the given context, as long as you're aware of the little bit of extra risk you take on.)

problems correctly defining cascading to ensure that related entities get created or deleted when modifying the root entity

You try to stick to domain driven design's definition of repositories and using aggregate roots. E.g. if you're adding a line item to and order, you want that to be persisted by saving the order and not by saving each line item individually through a line item repository. This is not inherently difficult, but configuring this correctly can get complex.

unexpected behaviour because of how proxied entity instances handle calls to internal methods

Hibernate/JPA does its magic (tracking changes, lazy loading, etc) by proxying the entities it handles and wrapping access to entity attributes with aspects. This makes it almost impossible to put any higher order logic into the entity code. This is rarely a problem in itself but tends to lead to excessive use of DTOs.

lots of redundant code mapping back and forth from anaemic DTO objects

It seems all too common to have a complete mirror of all JPA Entities in a set of DTO classes. Then this usually gets mapped back and forth by some automated mapper. This is seen as necessary mainly because of the issues already listed here. In the better cases it will look something like described in this post. Ultimately this makes changes to your models harder while providing very little benefit. 

(Compare to Python's FastAPI with pydantic, especially using different model projections to e.g. hide something like a password attribute)

problems testing all of this because of awkward transaction handling in integration tests

All these problems are solvable and don't mean there is no value in JPA. And a lot of the complexities can be designed and evolved by using tests. But a big issue there used to be in how lazy loading and cascading would behave subtly different depending on how transactions behaved in tests versus in production code. Presumably this should be slightly easier to handle with the proliferation of testcontainers and generally decent integration test support in spring. I haven't had a recent look at this.

a fresh look

So you take on these burdens for the benefit of automagic ways of defining your OR model. "ORMs, it's just what you do to work with relational data in Java." And some of that automagic in e.g. JPARepository is quite useful for basic query operations. 

I've never really questioned this because I do remember the pre-hibernate world of working directly with JDBC. But coming back to the Java world after a two-year period working in a different stack, I was curious to try out some of the project reactor features and in the process looked a bit closer at spring data relational. And with that then finally came the question, why am I even doing this (JPA) to myself? 

The difficulties inherent to trying to map arbitrary complexity of your domain model onto a relational database still exists and don't just disappear. I would just make the claim that in a lot of cases, dealing with those difficulties head on ends up producing simpler, more maintainable code than using JPA. There's still a good amount of querying automagic there (e.g. in CrudRepository/PagingAndSortingRepository) if you want it. And basic relationship handling with @MappedCollection also works well. 

(IntelliJ is also quite good at creating DDL sql for @Table annotated entities that can go straight into, e.g. Flyway scripts)

some examples

Taking the following simplistic model...
@Table("orders")
public record Order(
        @Id UUID id,
        UUID customerId,
        @MappedCollection(idColumn = "order_id", keyColumn = "list_key") List<LineItem> items,
        @Version int version
) {
    static Order forCustomer(UUID customerId, List<LineItem> items) {
        return new Order(null, customerId, items, 0);
    }

    public Order withItems(List<LineItem> items) {
        return new Order(id, customerId, items, version);
    }
}
@Table("order_items")
public record LineItem(
        @Id UUID id,
        String sku,
        int count,
        @Embedded(onEmpty = USE_NULL, prefix = "amount_") Money amount
) {
    static LineItem randItem(String sku, long amount) {
        return new LineItem(null, sku, 1, Money.rands(BigDecimal.valueOf(amount)));
    }
}
public record Money(BigDecimal amount, String currency) {
    static Money rands(BigDecimal value) {
        return new Money(value, "ZAR");
    }
}

 

It maps nicely to the following tables: (sql generated by IntelliJ with the exception of defaulting PKs to uuidv7(), which I added manually)

CREATE TABLE orders
(
    id          UUID PRIMARY KEY DEFAULT uuidv7(),
    customer_id UUID,
    version     INTEGER
);

CREATE TABLE order_items
(
    id              UUID PRIMARY KEY DEFAULT uuidv7(),
    order_id        UUID NOT NULL,
    sku             VARCHAR,
    count           INTEGER,
    amount_amount   DECIMAL,
    amount_currency VARCHAR,
    list_key        VARCHAR
);

ALTER TABLE order_items
    ADD CONSTRAINT fk_order_items_on_order FOREIGN KEY (order_id) REFERENCES orders (id) ON DELETE CASCADE;

 

@MappedCollection and @Embedded work as expected. I also like using records to make it clear that instances aren't mutable. You can then use either CrudRepository or JdbcAggregateTemplate to work with these. E.g.:

public interface OrderRepo extends CrudRepository<Order, UUID> {}

OrderRepo repo;
JdbcAggregateTemplate jdbc;

void examples() {
    UUID someCustomerId; // ...
    Order order = repo.save(Order.forCustomer(someCustomerId, List.of(LineItem.randItem("sku1", 11))));
    order = order.withItems(List.of(
            order.items().get(0),
            LineItem.randItem("sku2", 22)
    ));
    repo.save(order);
    
    List<Order> customerOrders = repo.findByCustomerId(someCustomerId);

    // more complex queries can be done with JdbcTemplate. JdbcAggregateTemplate provides easy mapping to entities though:
    List<Order> customerOrdersByTemplate = jdbc.findAll(Query.query(where("customerId").is(someCustomerId)), Order.class);
}
 

side note: on project reactor

Interesting to dig into a little. Adds a sizable amount of complexity with a lot of caveats (e.g. no support for nested entities in r2dbc). Ultimately not worth the effort except for very constrained use cases. Performance gains for IO bound tasks can instead be achieved with virtual threads.