If you’re like me, and you find JPA/Hibernate to be bulky, complex and full of gotchas, and you want to try a simpler, lighter weight approach, you might want to try using JDBC directly. If you’re using Spring (and Spring Boot) you might consider using Spring Data JDBC instead of Spring Data JPA.

For the most part, Spring Data JDBC is similar to its JPA counterpart but with way less functionality. You can still create objects that map to database records, and you can still use Spring Data provided repositories to give you a starting point for interacting with the database.

However, there are tradeoffs. With the simplicity of JDBC, there are some things that don’t quite work as you might expect, coming from JPA and I found working with UUIDs to be one of them.

The problems I faced seem surprisingly under-documented. So this post attempts to fill that gap.

Context

I’m using MySQL and want to use a UUID as my primary key.

I’m aware that it’s often discouraged to do this as using UUID comes with significant drawbacks, often related to storage size and ordering (due to the randomness). If you want to read more about this, this article and also this one are good starting points

This is my schema:

CREATE TABLE IF NOT EXISTS `users` (
    `id` char(36) not null primary key,
    `name` varchar(255)
);

This is my Java object that models a record of this table:

@Table("users")
@Getter
@Setter
public class User {
  @Id
  @Column("id")
  private UUID id;

  @Column("name")
  private String name;
  
  public User(UUID id, String name) {
    this.id = id;
    this.name = name;
  }
}

And here’s my repository:

@Repository
public interface UserRepository extends CrudRepository<User, UUID> {
  // ...
}

Problem 1: Inserting vs Updating with Spring Data JDBC

Let’s say I want to create a new user. You might consider doing something like this:

var user = new User(null, "Foo");
userRepository.save(user);

But since we don’t have a default value for our primary key, we get an exception:

java.sql.SQLException: Field 'id' doesn't have a default value

Okay, let’s generate a UUID and provide it:

var user = new User(UUID.randomUUID(), "Foo");
userRepository.save(user);

This time we get a different exception:

org.springframework.dao.IncorrectUpdateSemanticsDataAccessException: Failed to update entity [com.example.User@6fe517fc]; Id [d1089c08-151c-441a-9278-9cff0bd043e6] not found in database

This is because the default strategy that Spring Data JDBC uses to determine whether it should perform an insert vs an update is whether the @Id annotated field is null or 0 (in the case of numeric values). Since we’ve given it a non-null value, it’ll try to do an update statement.

To work around this, we either need to make sure that we provide null as the id value and get it to generate an id before inserting into the DB, or we can choose from a number of different strategies that can be used to tell the framework whether our object is “new” (we should insert) or not (we should update).

We’ll go with the former for now, that we just want to generate an id before inserting. So how do we do that?

@Component
public class UserIdGenerator implements BeforeConvertCallback<User> {
  @Override
  public User onBeforeConvert(User user) {
    if (user.getId() == null) {
      user.setId(UUID.randomUUID());
    }
    return user;
  }
}

We create a bean (@Component) that implements BeforeConvertCallback<User>. The framework will call our callback just before running the insert or update SQL statement.

Crucially, it’s called after it’s already decided to perform an insert (because the value of id is currently null):

var user = new User(null, "Foo");
userRepository.save(user);

Our callback executes, then generates and sets a new UUID as the id field.

Now this should do the trick, right? Nope!

java.sql.SQLException: Incorrect string value: '\xAC\xED\x00\x05sr...' for column 'id' at row 1

Problem 2: Serialising UUID values with Spring Data JDBC

Spring Data JDBC doesn’t natively support translating UUID values into a value that can be written to MySQL. There are multiple different ways to store it in MySQL (such as CHAR(36) and VARBINARY(16)). When it comes to attempting to write this value to the database, it ends up falling back to attempting to write this in a sort of “catch-all” binary format, and since we’re using CHAR(36), we get an exception.

So how do we let Spring Data JDBC know how we want to serialise UUID?

There’s a few ways to do this, but the one I landed on was to register some custom converters to handle reading and writing UUID types.

First, register a bean to handle converting UUID into String. Note that we’re using @WritingConverter here so that the conversion is only applied when attempting to write UUID values to the database.

@Component
@WritingConverter
public class UUIDToStringConverter implements Converter<UUID, String> {
  @Override
  public String convert(UUID source) {
    return source.toString();
  }
}

Next, we register a bean to handle converting String back to UUID. Note that we’re using a @ReadingConverter so that it only applies when turning a string-like value from MySQL (like CHAR(36)) back into a UUID:

@Component
@ReadingConverter
public class StringToUUIDConverter implements Converter<String, UUID> {
  @Override
  public UUID convert(String source) {
    return UUID.fromString(source);
  }
}

Finally, we need to register these custom converters with the framework by providing a JdbcCustomConversions bean that will be picked up by the framework to make our two new converters available:

@Configuration
public class JdbcConfiguration {
  @Bean
  public JdbcCustomConversions jdbcCustomConversions(
      UUIDToStringConverter uuidToStringConverter, StringToUUIDConverter stringToUUIDConverter) {
    return new JdbcCustomConversions(List.of(uuidToStringConverter, stringToUUIDConverter));
  }
}

Now we’ve both registered our UUID to String converters and we’re also auto-generating a UUID when the id field is null at the point of saving.

Does this work?

var user = new User(null, "Foo");
var saved = userRepository.save(user);
System.out.println(saved.getId() + " / " + saved.getName());
// Outputs: 539c13bd-87e3-4730-9943-23360e1f8735 / Foo bar

Hurray!

Bonus: Controlling whether you want to insert or update

I mentioned earlier that I chose to lean into the default insert vs update decision strategy, and just use null as my default value for my primary key to indicate a new object, and I would automatically generate the UUID just before the query is executed.

But what if you want to pass in a UUID yourself, and you want control over whether to insert or update?

Persistable

There are multiple strategies that Spring Data JDBC will apply to determine if your object represents a new value. As previously discussed, the default is to inspect the @Id annotated field to see if it’s null or 0.

There are other approaches such as implementing Persistable on your object so that you can customise the logic for determining if your object is new (should be inserted on save) or already exists (should be updated on save).

@Table("users")
@Getter
@Setter
public class User implements Persistable<UUID> {
  @Id
  @Column("id")
  private UUID id;

  @Column("name")
  private String name;
  
  @Transient
  private boolean isNew = false;

  public User(UUID id, String name) {
    this.id = id;
    this.name = name;
  }
}

Implementing boolean isNew() from Persistable<T> on the class allows us to tell the framework that our value should be inserted (if true) or updated (if false).

The @Transient annotation tells the framework that the field is not intended to be saved to the database.

You can of course implement it in whatever way you want. If you can somehow tell from just your object’s internal state whether you want to insert without adding any additional fields, this approach would be great

In this (less than ideal) case, the isNew method is created by Lombok’s @Getter and returns the value of the isNew field meaning we have to manage additional internal state that’s not really just the object’s data. We can use the setter to mark the object to be inserted or updated:

var user = new User(UUID.randomUUID(), "Foo");
user.setNew(true);
userRepository.save(user); // inserts a new user

This is not perfect as it means having to add another property, as well as setting it manually. We could simplify this a bit by doing this in a create method in a service, but the field is still accessible from the outside.

Explicit query on the repository

It’s also possible to simply force a creation by adding a @Query to a method in our repository interface. The downside here is that we need to specify all the fields individually:

@Repository
public interface UserRepository extends CrudRepository<User, UUID> {
  @Modifying
  @Query("INSERT INTO users (id, name) VALUES (:id, :name)")
  void create(@Param("id") UUID id, @Param("name") String name);
}

We can’t return a User here since we’re not selecting any rows in this query. If we want to refresh the User object from the DB, we’d need to look it up again.

JdbcAggregateTemplate

Another way, to force an insert or an update (and in my opinion, I think it’s the best way) is to directly use JdbcAggregateTemplate to insert or update our entity, which guarantees insert or update under the hood:

var user = new User(UUID.randomUUID(), "Foo");
var savedNewUser = jdbcAggregateTemplate.insert(user);
var updatedUser = jdbcAggregateTemplate.update(savedNewUser);

We just pass our aggregate object to the method and the rest is taken care of.

If you’ve got a service class (e.g. UserService) that you’re calling to create/update your entities (which we should be doing rather than using repositories directly), then this is a fantastic alternative.

Conclusion

While Spring Data JDBC offers a simpler and more predictable alternative to JPA, it comes with its own quirks — especially when working with UUIDs as primary keys. From understanding how insert/update detection works to customising UUID generation and serialization, there are several details to get right in order to avoid cryptic errors. Some of those details aren’t particularly well documented.

If you’re aiming for lightweight data access in Spring, it’s well worth investing the time to understand how Spring Data JDBC handles these edge cases.

On a final note, I do wish that we had direct create and update methods available on the CrudRepository, rather than just save. It would make things quite a bit easier and give more control to the developer. Similarly, I think it would be nice if there was a built-in way to handle how UUIDs are persisted, even if it means that the developer has to explicitly declare them.