DEV Community

loading...

Using Value Objects as Aggregate Identifiers with Hibernate

Petter Holmström
Father, husband, voluntary fire fighter, software engineer, drummer.
Updated on ・9 min read

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:

  1. Decide what kind of raw ID type you are going to use inside your value object: UUID, String, or Long
  2. 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).
  3. Create a custom type that ties together your type descriptor with the JDBC column type you want to use for your ID.
  4. 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.
}
Enter fullscreen mode Exit fullscreen mode
  1. You have to implement the Serializable interface because Persistable assumes the ID type is persistable. I sometimes create a new marker interface called DomainObjectId that extends ValueObject and Serializable.
  2. 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>
}
Enter fullscreen mode Exit fullscreen mode
  1. AbstractTypeDescriptor is a Hibernate base class that resides in the org.hibernate.type.descriptor.java package.
  2. This method converts our value object to a string. We use a helper class from Hibernate's built-in UUIDTypeDescriptor (also from the org.hibernate.type.descriptor.java package) to perform the conversion.
  3. This method constructs a value object from a string. Again, we use a helper class from UUIDTypeDescriptor.
  4. This method converts a value object into a UUID, a string or a byte array. Again, we use helper classes from UUIDTypeDescriptor.
  5. This method converts a UUID, a string or a byte array into a value object. The helper classes are used here as well.
  6. 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>
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. AbstractSingleColumnStandardBasicType is a Hibernate base class that resides in the org.hibernate.type package.
  2. In order for the custom type to work properly in @Id fields, we have to implement this extra interface from the org.hibernate.id package.
  3. 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.
  4. Here, we retrieve the ID from a JDBC result set as a byte array...
  5. ... and convert it to a CustomerId using our Java type descriptor.
  6. 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;
Enter fullscreen mode Exit fullscreen mode
  1. This Hibernate annotation tells Hibernate to use CustomerIdType whenever it encounters a CustomerId.
  2. 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();
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. We no longer extend AbstractPersistable but we do implement the Persistable interface.
  2. This method comes from the Persistable interface and will have to be implemented by the subclasses.
  3. This method also comes from the Persistable interface.
  4. Since we no longer extend AbstractPersistable we have to override toString 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.
  5. We also have to override equals. Remember that two entities of the same type with the same ID are considered the same entity.
  6. ProxyUtils is a Spring utility class that is useful for cases where the JPA implementation has made bytecode changes to the entity class, resulting in getClass() not necessarily returning what you think it may return.
  7. Since we have overridden equals, we also have to override hashCode 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;
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. We extend BaseAggregateRoot, which in turn extends our refactored BaseEntity class.
  2. We declare the name of the ID generator in a constant. We will be using this when we register our custom generator with Hibernate.
  3. Now we are no longer stuck with whatever annotation was used in the mapped superclass.
  4. We implement the abstract getId() method from Persistable.

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>
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. IdentifierGenerator is an interface that resides in the org.hibernate.id package.
  2. 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.
  3. 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;
Enter fullscreen mode Exit fullscreen mode
  1. This annotation tells Hibernate to use CustomerIdGenerator whenever it encounters a generator named customer-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.

Discussion (1)

Collapse
cauchypeano profile image
Igor Konoplyanko

Thank you Petter, very helpful and detailed article!