close

Spring Data JDBC - How to use custom ID generation

This 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

  1. Spring Data JDBC - How to use custom ID generation. (this article).

  2. Spring Data JDBC - How do I make bidirectional relationships? 3 Spring Data JDBC - How do I implement caching?

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.

Version

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");

Template

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;

EventListener

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 BeforeSaveCallback. 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
BeforeSaveCallback<StringIdMinion> beforeSaveCallback() {

	return (minion, mutableAggregateChange) -> {
		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");

Persistable

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");

Conclusion

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.

There will be more articles like this. Let me know if you would like me to cover specific topics.

comments powered by Disqus