In Tactical Domain-Driven Design, we learned that that we should refer to other aggregates by ID and use value objects to distinguish between different aggregate types (second aggregate design guideline).
This is perfectly possible to do with JPA and Hibernate, but requires some additional work as it goes against the design principles of JPA where you should not have to care about the IDs at all and just work with your entity objects directly. Let's have a look at how. We will be building on the code and principles laid out in my posts about value objects and aggregates so please read those first if you haven't already.
If you are using another JPA implementation than Hibernate, you have to check that implementation's documentation for how to create custom types.
Attribute Converters Won't Do
The first thought may be to use a simple value object and an attribute converter. Unfortunately this is not possible as JPA does not support using attribute converters for @Id
fields. You can make a compromise and use "raw" IDs for your @Id
fields and simple value objects to refer to them from other aggregates, but I do not personally like this approach as you have to move back and forth between the value objects and their wrapped raw IDs, making it more difficult to write queries. A better, more consistent approach is to create custom Hibernate types.
Creating a Custom Hibernate Type
When you create custom Hibernate types for your ID value objects, they become available for use inside your entire persistence context without any additional annotations anywhere. This involves the following steps:
- Decide what kind of raw ID type you are going to use inside your value object:
UUID
,String
, orLong
- Create a type descriptor for your value object. This descriptor knows how to convert another value into an instance of your value object (wrap) and vice versa (unwrap).
- Create a custom type that ties together your type descriptor with the JDBC column type you want to use for your ID.
- Register your custom type with Hibernate.
Let's have a look at a code example to better illustrate this. We are going to create a value object ID called CustomerId
that wraps a UUID
. The value object looks like this:
package foo.bar.domain.model;
// Imports omitted
public class CustomerId implements ValueObject, Serializable { // <1>
private final UUID uuid;
public CustomerId(@NotNull UUID uuid) {
this.uuid = Objects.requireNonNull(uuid);
}
public @NotNull UUID unwrap() { // <2>
return uuid;
}
// Implementation of equals() and hashCode() omitted.
}
- You have to implement the
Serializable
interface becausePersistable
assumes the ID type is persistable. I sometimes create a new marker interface calledDomainObjectId
that extendsValueObject
andSerializable
. - You need a way of getting the underlying
UUID
when you implement the type descriptor.
Next, we will create the type descriptor. I typically place this in a subpackage called .hibernate
to keep the domain model itself nice and clean.
package foo.bar.domain.model.hibernate;
// Imports omitted
public class CustomerIdTypeDescriptor extends AbstractTypeDescriptor<CustomerId> { // <1>
public CustomerIdTypeDescriptor() {
super(CustomerId.class);
}
@Override
public String toString(CustomerId value) { // <2>
return UUIDTypeDescriptor.ToStringTransformer.INSTANCE.transform(value.unwrap());
}
@Override
public ID fromString(String string) { // <3>
return new CustomerId(UUIDTypeDescriptor.ToStringTransformer.INSTANCE.parse(string));
}
@Override
@SuppressWarnings("unchecked")
public <X> X unwrap(CustomerId value, Class<X> type, WrapperOptions options) { // <4>
if (value == null) {
return null;
}
if (getJavaType().isAssignableFrom(type)) {
return (X) value;
}
if (UUID.class.isAssignableFrom(type)) {
return (X) UUIDTypeDescriptor.PassThroughTransformer.INSTANCE.transform(value.unwrap());
}
if (String.class.isAssignableFrom(type)) {
return (X) UUIDTypeDescriptor.ToStringTransformer.INSTANCE.transform(value.unwrap());
}
if (byte[].class.isAssignableFrom(type)) {
return (X) UUIDTypeDescriptor.ToBytesTransformer.INSTANCE.transform(value.unwrap());
}
throw unknownUnwrap(type);
}
@Override
public <X> CustomerId wrap(X value, WrapperOptions options) { // <5>
if (value == null) {
return null;
}
if (getJavaType().isInstance(value)) {
return getJavaType().cast(value);
}
if (value instanceof UUID) {
return new CustomerId(UUIDTypeDescriptor.PassThroughTransformer.INSTANCE.parse(value));
}
if (value instanceof String) {
return new CustomerId(UUIDTypeDescriptor.ToStringTransformer.INSTANCE.parse(value));
}
if (value instanceof byte[]) {
return new CustomerId(UUIDTypeDescriptor.ToBytesTransformer.INSTANCE.parse(value));
}
throw unknownWrap(value.getClass());
}
public static final CustomerIdTypeDescriptor INSTANCE = new CustomerIdTypeDescriptor(); // <6>
}
-
AbstractTypeDescriptor
is a Hibernate base class that resides in theorg.hibernate.type.descriptor.java
package. - This method converts our value object to a string. We use a helper class from Hibernate's built-in
UUIDTypeDescriptor
(also from theorg.hibernate.type.descriptor.java
package) to perform the conversion. - This method constructs a value object from a string. Again, we use a helper class from
UUIDTypeDescriptor
. - This method converts a value object into a
UUID
, a string or a byte array. Again, we use helper classes fromUUIDTypeDescriptor
. - This method converts a
UUID
, a string or a byte array into a value object. The helper classes are used here as well. - We can access this type descriptor as a singleton since it does not contain any changeable state.
So far we have only dealt with Java types. Now it is time to bring SQL and JDBC into the mix and create our custom type:
package foo.bar.domain.model.hibernate;
// Imports omitted
public class CustomerIdType extends AbstractSingleColumnStandardBasicType<CustomerId> // <1>
implements ResultSetIdentifierConsumer { // <2>
public CustomerIdType() {
super(BinaryTypeDescriptor.INSTANCE, CustomerIdTypeDescriptor.INSTANCE); // <3>
}
@Override
public Serializable consumeIdentifier(ResultSet resultSet) {
try {
var id = resultSet.getBytes(1); // <4>
return getJavaTypeDescriptor().wrap(id, null); // <5>
} catch (SQLException ex) {
throw new IllegalStateException("Could not extract ID from ResultSet", ex);
}
}
@Override
public String getName() {
return getJavaTypeDescriptor().getJavaType().getSimpleName(); // <6>
}
}
-
AbstractSingleColumnStandardBasicType
is a Hibernate base class that resides in theorg.hibernate.type
package. - In order for the custom type to work properly in
@Id
fields, we have to implement this extra interface from theorg.hibernate.id
package. - Here we pass in the SQL type descriptor (in this case binary as we are going to store the UUID in a 16 byte binary column) and our Java type descriptor.
- Here, we retrieve the ID from a JDBC result set as a byte array...
- ... and convert it to a
CustomerId
using our Java type descriptor. - A custom type needs a name so we use the name of the Java Type.
Finally, we just have to register our new type with Hibernate. We will do this inside a package-info.java
file that resides in the same package as our CustomerId
class:
@TypeDef(defaultForType = CustomerId.class, typeClass = CustomerIdType.class) // <1>
package foo.bar.domain.model;
import org.hibernate.annotations.TypeDef; // <2>
import foo.bar.domain.model.hibernate.CustomerIdType;
- This Hibernate annotation tells Hibernate to use
CustomerIdType
whenever it encounters aCustomerId
. - Note that the imports are coming after the annotations in a
package-info.java
file and not before as they do in a class file.
Phew! Now we can use CustomerId
both to identify Customer
aggregates and to refer to them from other aggregates. Please keep in mind, though, that if you let Hibernate generate your SQL schema for you and you use IDs to refer to aggregates instead of @ManyToOne
associations, Hibernate will not create foreign key constraints. You will have to do that yourself, for example using Flyway.
If you have many different ID value object types, you will want to create abstract base classes for your type descriptors and custom types to avoid having to repeat yourself. I'm going to leave this as an exercise to the reader.
But wait, haven't we forgotten something? How are we actually going to generate new CustomerID
instances when we persist newly created Customer
aggregate roots? Let's find out.
Generating Value Object IDs
Once you have your ID value objects and custom types in place, you need a way of generating new IDs. You can create your IDs and assign them manually before persisting your entities (this is really easy if you use UUIDs) or you can configure Hibernate to automatically generate IDs for you when they are needed. The latter approach is more difficult to set up but easier to work with once it is done so let's have a look at that.
Refactoring Your Base Classes
JPA has support for different ID generators. If you look at the @GeneratedValue
annotation, you can specify the name of the generator
to use. Here we run into the first caveat. If you declare your ID field inside a mapped superclass (such as AbstractPersistable
), there is no way for you to override the @GeneratedValue
annotation for that field. In other words, you are stuck using the same ID generator for all of your aggregate roots and entities that extend this base class. If you find yourself in a situation like this, you have to remove your ID field from the base class and have every aggregate root and entity declare its own ID field.
Thus, the BaseEntity
class (we originally defined this class here) changes to something like this:
@MappedSuperclass
public abstract class BaseEntity<Id extends Serializable> implements Persistable<Id> { // <1>
@Version
private Long version;
@Override
@Transient
public abstract @Nullable ID getId(); // <2>
@Override
@Transient
public boolean isNew() { // <3>
return getId() == null;
}
public @NotNull Optional<Long> getVersion() {
return Optional.ofNullable(version);
}
protected void setVersion(@Nullable version) {
this.version = version;
}
@Override
public String toString() { // <4>
return String.format("%s{id=%s}", getClass().getSimpleName(), getId());
}
@Override
public boolean equals(Object obj) { // <5>
if (null == obj) {
return false;
}
if (this == obj) {
return true;
}
if (!getClass().equals(ProxyUtils.getUserClass(obj))) { // <6>
return false;
}
var that = (BaseEntity<?>) obj;
var id = getId();
return id != null && id.equals(that.getId());
}
@Override
public int hashCode() { // <7>
var id = getId();
return id == null ? super.hashCode() : id.hashCode();
}
}
- We no longer extend
AbstractPersistable
but we do implement thePersistable
interface. - This method comes from the
Persistable
interface and will have to be implemented by the subclasses. - This method also comes from the
Persistable
interface. - Since we no longer extend
AbstractPersistable
we have to overridetoString
ourselves to return something useful. I sometimes also include the object identity hash code to make it clear whether we are dealing with different instances of the same entity. - We also have to override
equals
. Remember that two entities of the same type with the same ID are considered the same entity. -
ProxyUtils
is a Spring utility class that is useful for cases where the JPA implementation has made bytecode changes to the entity class, resulting ingetClass()
not necessarily returning what you think it may return. - Since we have overridden
equals
, we also have to overridehashCode
in the same manner.
Now when we have made the necessary changes to BaseEntity
, we can add the ID field to our aggregate root:
@Entity
public class Customer extends BaseAggregateRoot<CustomerId> { // <1>
public static final String ID_GENERATOR_NAME = "customer-id-generator"; // <2>
@Id
@GeneratedValue(generator = ID_GENERATOR_NAME) // <3>
private CustomerId id;
@Override
public @Nullable CustomerId getId() { // <4>
return id;
}
}
- We extend
BaseAggregateRoot
, which in turn extends our refactoredBaseEntity
class. - We declare the name of the ID generator in a constant. We will be using this when we register our custom generator with Hibernate.
- Now we are no longer stuck with whatever annotation was used in the mapped superclass.
- We implement the abstract
getId()
method fromPersistable
.
Implementing the ID Generator
Next, we have to implement our custom ID generator. Since we are using UUIDs this is going to be almost trivial. For other ID generation strategies, I suggest you pick an existing Hibernate generator and build on that (start looking here). The ID generator will look something like this:
package foo.bar.domain.model.hibernate;
public class CustomerIdGenerator implements IdentifierGenerator { // <1>
public static final String STRATEGY = "foo.bar.domain.model.hibernate.CustomerIdGenerator"; // <2>
@Override
public Serializable generate(SharedSessionContractImplementor session, Object object) throws HibernateException {
return new CustomerId(UUID.randomUUID()); // <3>
}
}
-
IdentifierGenerator
is an interface that resides in theorg.hibernate.id
package. - Because of how new generators are registered with Hibernate, we need the full name of the class as a string. We store it in a constant to make future refactoring easier - and minimize the risk of bugs caused by typos.
- In this example we use
UUID.randomUUID()
to create new UUIDs. Please note that you have access to the Hibernate session if you need to do something more advanced, like retrieving a numeric value from a database sequence.
Finally, we have to register our new ID generator with Hibernate. Like with the custom type, this happens in package-info.java
, which becomes:
@TypeDef(defaultForType = CustomerId.class, typeClass = CustomerIdType.class)
@GenericGenerator(name = Customer.ID_GENERATOR_NAME, strategy = CustomerIdGenerator.STRATEGY) // <1>
package foo.bar.domain.model;
import org.hibernate.annotations.GenericGenerator;
import org.hibernate.annotations.TypeDef;
import foo.bar.domain.model.hibernate.CustomerIdType;
import foo.bar.domain.model.hibernate.CustomerIdGenerator;
- This annotation tells Hibernate to use
CustomerIdGenerator
whenever it encounters a generator namedcustomer-id-generator
.
Double-phew! Now our domain model should just work as we expect it to, with auto-generated value objects as IDs.
A Note on Composite Keys
Before we leave the subject of IDs, I just want to mention one thing. By moving the ID field from the mapped superclass (BaseEntity
) to the concrete entity class (Customer
in the example above), we also opened up the possibility to use composite keys in our entities (either using @EmbeddedId
or @IdClass
). You may for example have a situation where the composite key consists of the ID of another aggregate root and an enum constant.
Top comments (3)
Thank you for this very good article.
Two small things caught my eye. The AbstractTypeDescriptor class no longer exists in Hibernate 6. So the solution is not possible anymore. I solved it with AbstractClassJavaType, but this now leads to a problem. The second little thing. If I use a Value Object then I didn't find a way to do e.g. a sum(a.intValueObject) in the SQL.
Thanks! If I can find the time, I'll try to write an updated article for Hibernate 6 but I can't promise anything.
Thank you Petter, very helpful and detailed article!