Get ahead
VMware offers training and certification to turbo-charge your progress.
Learn moreThis is the first article of a series about how to tackle various challenges you might encounter when using Spring Data JDBC. The series consists of
Spring Data JDBC - How to use custom ID generation. (this article).
Spring Data JDBC - How do I make bidirectional relationships?
Spring Data JDBC - How Can I Do a Partial Update of an Aggregate Root?
Spring Data JDBC - How do I Generate the Schema for my Domain Model?
If you are new to Spring Data JDBC, you should start by reading its introduction and this article, which explains the relevance of aggregates in the context of Spring Data JDBC. Trust me, it is important.
This article is based on part of a talk I did at Spring One 2021.
Now we can get started with IDs - especially about what your options are when you want to control the ID of an entity and do not want to leave it to the database. But let us first reiterate Spring Data JDBC’s default strategy for this.
By default, Spring Data JDBC assumes that IDs get generated by some kind of SERIAL
, IDENTITY
, or AUTOINCREMENT
column. It basically checks whether the ID of an aggregate root is null
or 0
for primitive number types. If it is, the aggregate is assumed to be new and an insert is performed for the aggregate root. The database generates an ID, and the ID is set in the aggregate root by Spring Data JDBC. If the ID is not null
, the aggregate is assumed to be an existing one and an update is performed for the aggregate root.
Consider a simple aggregate consisting of a single simple class:
class Minion {
@Id
Long id;
String name;
Minion(String name) {
this.name = name;
}
}
Further consider a default CrudRepository
.
interface MinionRepository extends CrudRepository<Minion, Long> {
}
The repository gets autowired into your code with a line like the following:
@Autowired
MinionRepository minions;
The following works just fine:
Minion before = new Minion("Bob");
assertThat(before.id).isNull();
Minion after = minions.save(before);
assertThat(after.id).isNotNull();
But this next bit does not work:
Minion before = new Minion("Stuart");
before.id = 42L;
minions.save(before);
As described earlier, Spring Data JDBC tries to perform an update, because the ID is already set. However, because the aggregate is actually new, the update statement affects zero rows and Spring Data JDBC throws an exception.
There are a few ways around this. I found four different approaches for this, and I listed them with what I consider easiest first, so you can stop reading once you find a solution that works for you. You can come back later to read about the other options and improve your Spring Data skills.
Add a version attribute to your aggregate attribute. By "version attribute" I mean an attribute annotated with @Version
. The primary purpose of such an attribute is to enable optimistic locking. However, as a side effect, the version attribute also gets used by Spring Data JDBC to determine whether the aggregate root is new or not. As long as the version is null
or 0
for primitive types, the aggregate is considered to be new, even when the id
is set.
With this approach, you have to change the entity and (of course) the schema but nothing else.
Also, for many applications optimistic locking is a nice thing to have in the first place.
We turn the original Minion
into a VersionedMinion
:
class VersionedMinion {
@Id Long id;
String name;
@Version Integer version;
VersionedMinion(long id, String name) {
this.id = id;
this.name = name;
}
}
The repository and autowiring looks basically the same as the original example. With this change, the following construct works:
VersionedMinion before = new VersionedMinion(23L, "Bob");
assertThat(before.id).isNotNull();
versionedMinions.save(before);
VersionedMinion reloaded = versionedMinions.findById(before.id).get();
assertThat(reloaded.name).isEqualTo("Bob");
Another way to have your will with IDs is to make the insert yourself. You can do so by injecting a JdbcAggregateTemplate
and calling JdbcAggregateTemplate.insert(T)
. The JdbcAggregateTemplate
is an abstraction layer below the repository, so you use the same code that a repository would use for an insert, but you decide when an insert is used:
Minion before = new Minion("Stuart");
before.id = 42L;
template.insert(before);
Minion reloaded = minions.findById(42L).get();
assertThat(reloaded.name).isEqualTo("Stuart");
Note that we do not use a repository but a template, which got injected with the following:
@Autowired
JdbcAggregateTemplate template;
The template approach is great for situations where you already know the ID - for example, when you import data from another system and you want to reuse the ID of that system.
In cases where you do not know the ID and do not want to have anything ID-elated in your business code, using a callback might be the better option.
A callback is a bean that gets called during certain life cycle events. The right callback for our purpose is the BeforeConvertCallback
. It returns the potentially modified aggregate root, so it works for immutable entity classes as well.
In the callback, we determine whether the aggregate root in question needs a fresh ID. If so, we generate it by using an algorithm of our choice.
We use another variation of the Minion
class StringIdMinion {
@Id
String id;
String name;
StringIdMinion(String name) {
this.name = name;
}
}
Repository and injection point still look analogous to the original example. However, we register the callback in the configuration:
@Bean
BeforeConvertCallback<StringIdMinion> beforeConvertCallback() {
return (minion) -> {
if (minion.id == null) {
minion.id = UUID.randomUUID().toString();
}
return minion;
};
}
The code for saving the entity now looks just as if the id
had been generated by the database:
StringIdMinion before = new StringIdMinion("Kevin");
stringions.save(before);
assertThat(before.id).isNotNull();
StringIdMinion reloaded = stringions.findById(before.id).get();
assertThat(reloaded.name).isEqualTo("Kevin");
The final option is to let the aggregate root control whether an update or insert should be made. You can do that by implementing the Persistable
interface (especially the isNew
method). The easiest way is to return true
all the time, thereby forcing an insert all the time. Of course, this does not work when you want to use the aggregate root for updates as well. In that case, you need to come up with a more flexible strategy.
We need to tweak our Minion
again:
class PersistableMinion implements Persistable<Long> {
@Id Long id;
String name;
PersistableMinion(Long id, String name) {
this.id = id;
this.name = name;
}
@Override
public Long getId() {
return id;
}
@Override
public boolean isNew() {
// this implementation is most certainly not suitable for production use
return true;
}
}
The code for saving a PersistableMinion
looks just the same:
PersistableMinion before = new PersistableMinion(23L, "Dave");
persistableMinions.save(before);
PersistableMinion reloaded = persistableMinions.findById(before.id).get();
assertThat(reloaded.name).isEqualTo("Dave");
Spring Data JDBC offers a plethora of options for how you can control the IDs of your aggregates. While I used trivial logic for the examples, nothing keeps you from implementing whatever logic comes to your mind, since they all boil down to pretty basic Java code.
The complete example code is available in the Spring Data Example repository.
There will be more articles like this. Let me know if you would like me to cover specific topics.