Working with Value Classes in JDBI and Spring Boot
If you just want to see the completed demo for this, check out the repository here.
I've been playing around with Java and Spring Boot recently and ran into some serialization issues with JDBI when I tried to add value classes for UUIDs.
A quick primer on value classes: A value class wraps a primitive value or simple object (like a UUID
, String
, int
, etc.). The key characteristic of a value class is that it is defined by its value, not its identity—two identical value classes are equal.
The primary benefits are type safety and clearer intent. For example, consider a library application where you need a method to add a book:
void addBookToLibrary(UUID libraryId, UUID bookId);
Using the raw UUID type here means we could accidentally pass the wrong one:
var bookId = UUID.randomUUID();
var libraryId = UUID.randomUUID();
addBookToLibrary(bookId, libraryId); // 😱 compiles, but it's wrong
This method with a value class would look like:
void addBookToLibrary(LibraryId libraryId, BookId bookId);
With BookId
and LibraryId
, the compiler guarantees correctness — you physically can’t mix them up.
JDBI is a lightweight wrapper around the Java Database Connectivity (JDBC) API that simplifies database operations.
One of JDBI's main features is mapping Java objects to SQL query arguments (through argument factories) and result sets (through column mappers). However, this feature only works with a set of supported types. JDBI will throw an error if your Java class contains any unsupported fields.
For JDBI to work with a value class, we need to create two classes: a ColumnMapperFactory
that maps database columns to the value class, and an ArgumentFactory
that maps arguments to the value class.
My base UUID value class looks like this:
public abstract class BaseUUID {
private final UUID value;
protected BaseUUID(UUID value) {
this.value = Objects.requireNonNull(value, "UUID cannot be null");
}
@JsonCreator //For jackson JSON deserialization
protected BaseUUID(String id) {
this(UUID.fromString(id));
}
protected BaseUUID() {
this(UUID.randomUUID());
}
@JsonValue //For jackson JSON serialization
public String toString() {
return value.toString();
}
public UUID getValue() {
return this.value;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
BaseUUID baseId = (BaseUUID) o;
return Objects.equals(value, baseId.value);
}
@Override
public int hashCode() {
return Objects.hash(value);
}
}
This abstract class wraps the UUID
class. To create a new UUID type, we simply extend this class and implement the required constructors. For example:
public class LibraryId extends BaseUUID {
public LibraryId() { super(); }
public LibraryId(UUID id) { super(id); }
public LibraryId(String id) { super(id); }
public static LibraryId of(String id) { return new LibraryId(id); }
public static LibraryId of(UUID id) { return new LibraryId(id); }
}
To map database values to this value class in JDBI, we need to implement the ColumnMapperFactory
interface:
public class BaseUUIDColumnMapperFactory implements ColumnMapperFactory {
@Override
public Optional<ColumnMapper<?>> build(Type type, ConfigRegistry config) {
//checks that the requested type is a Class
if (!(type instanceof Class<?> clazz)) {
return Optional.empty();
}
//Checks that the class extends our BaseUUID abstract class
if (!BaseUUID.class.isAssignableFrom(clazz)) {
return Optional.empty();
}
//Map the column
return Optional.of((rs, columnNumber, ctx) -> {
try {
var uuid = extractRawUUIDFromColumn(rs, columnNumber);
if (uuid == null) {
return null;
}
return wrapNativeUUID(clazz, uuid);
} catch (Exception e) {
throw new SQLException("Could not map column " + columnNumber + " to " + clazz.getSimpleName() + ": " + e.getMessage(), e);
}
});
}
private static Object wrapNativeUUID(Class<?> clazz, UUID uuid) throws
InstantiationException, IllegalAccessException, InvocationTargetException, NoSuchMethodException {
try {
// First try the UUID constructor
Constructor<?> constructor = clazz.getDeclaredConstructor(UUID.class);
constructor.setAccessible(true);
return constructor.newInstance(uuid);
} catch (NoSuchMethodException e) {
// Fall back to String constructor if UUID constructor isn't available
Constructor<?> constructor = clazz.getDeclaredConstructor(String.class);
constructor.setAccessible(true);
return constructor.newInstance(uuid.toString());
}
}
private UUID extractRawUUIDFromColumn(ResultSet rs, int columnNumber) throws SQLException {
// First attempt to read as a native UUID object
UUID uuid = null;
try {
// Try to get as a native UUID
uuid = rs.getObject(columnNumber, UUID.class);
} catch (SQLException | UnsupportedOperationException e) {
// If that fails, try with a string
String idValue = rs.getString(columnNumber);
if (idValue != null) {
try {
uuid = UUID.fromString(idValue);
} catch (IllegalArgumentException ex) {
throw new SQLException("Invalid UUID format: " + idValue, ex);
}
}
}
return uuid;
}
}
There is a bit going on here, but conceptually, we are doing three things:
-
Type checking: First, we verify that the passed type is a class extending our
BaseUUID
abstract class. If not, we returnOptional.empty()
to satisfy theColumnMapperFactory
interface. -
Extracting the raw UUID value: We attempt to extract the column value as a native
UUID
class usingrs.getObject(columnNumber, UUID.class)
. If that fails, we fall back to reading the UUID as a string and convert it usingUUID.fromString(idValue)
. -
Wrapping the raw UUID value: Finally, we wrap the raw UUID in our value class. Using Java reflection, we obtain the constructor from the passed class and create a new instance by passing in the raw UUID value, which we then return.
To bind our value class to SQL query arguments in JDBI, we need to extend the AbstractArgumentFactory
abstract class:
//https://jdbi.org/releases/3.31.0/#_argumentfactory
public class BaseUUIDArgumentFactory extends AbstractArgumentFactory<BaseUUID> {
public BaseUUIDArgumentFactory() {
super(Types.OTHER);
}
@Override
protected Argument build(BaseUUID value, ConfigRegistry config) {
return (position, statement, ctx) -> {
statement.setObject(position, value.getValue());
};
}
}
The constructor calls the parent class's constructor with the JDBC SQL type constant that will be used for binding UUIDs. We use OTHER
to indicate that this type is a Java Object.
In the build method, we simply set the argument's value to the raw UUID value that's wrapped by our value class, using the getValue
getter from the BaseUUID
abstract class.
To wire all of this together, we need to register both the BaseUUIDArgumentFactory
and BaseUUIDColumnMapperFactory
with JDBI:
@Bean
public Jdbi jdbi(DataSource ds) {
var cf = new SpringConnectionFactory(ds);
var jdbi = Jdbi.create(cf);
jdbi.installPlugin(new SqlObjectPlugin());
jdbi.registerArgument(new BaseUUIDArgumentFactory());
jdbi.registerColumnMapper(new BaseUUIDColumnMapperFactory());
return jdbi;
}
You can check out the repo for a full demo of how this all works together here.