Separating Persistence and Domain Models
Building a good library for object-relational mapping is a complex task. On the one hand, you want to make it easy to define the mapping (mapping effort) but on the other hand, you don’t want to limit the kind of object graphs that can be persisted (complex model support).
Spring Data JDBC project has found a good balance between these two when using a relatively primitive domain model. It’s very easy to use and it does a pretty good job of reducing boilerplate in persistence mapping. However, when the domain model gets more complex it is less convenient. One issue is support for multilevel nested graphs (for example: a Subscription
that contains Order
s that each contain different OrderLine
s). This is where JOOQ really shines.
Another problem, I recently found is not supporting Kotlin value classes.
Inline value classes is a feature in Kotlin that allows the use of Value Objects with no runtime overhead (at runtime, these types get replaced by their underlying primitive). Our codebase was extensively using them to avoid primitively typed values so I had no desire to eliminate them just because of the persistence library.
Brute force solution
When faced with this problem my first reaction was to dig into the underlying library (Spring Data JDBC in this case) and find some magical combination of features/tricks to make it work.
I tried the following things among others:
- “dear ChatGPT help me” — suggested using some nice imaginary features that do not exist
- using custom factory methods with the
@PersistenceCreator
- using a constructor with primitives and mapping internally to value class types.
None of them was completely satisfying. Either the resulting domain model looked very convoluted or the idea simply wasn’t working with the version of Spring Data JDBC I was able to use in our project.
Separate domain and persistence model implementations
Then I realised that maybe instead of trying to fit the domain model and Spring Data JDBC into the same room I should just give up and create an internal persistence model that fits Spring Data JDBC. Then manually map that into the domain model.
Of course, this means having some boilerplate code. But in the days of AI coding assistants writing this kind of mapping is very low effort. Compared to the low-level mapping between ResultSet
and domain objects when using plain Spring JdbcTemplate
this code maps between two sets of custom types and hence is significantly cheaper to update/maintain.
Thanks to separating the persistence and the domain model I could completely ignore the persistence tool limitations when writing business logic. All this without having to do any of the low-level mapping manually — win-win.
Although this is an example with Kotlin and Spring Data JDBC, it’s really a more generic approach.
Sometimes it’s better to accept the differences between domain and persistence models and not force them into a single implementation.
For example, although JPA is pretty powerful in some cases it still requires altering the domain model just because a specific combination of relationships and types is not supported. There too using separate classes for JPA and domain can be a nice compromise.
Code example
A sample repository from our project looks like this:
class SubscriptionRepository(private val subscriptionTable: SubscriptionTable) {
fun findBy(customerId: CustomerId): Subscription? {
return subscriptionTable.findByCustomerId(customerId.toLong())?.toDomain()
}
fun save(subscription: Subscription) {
subscriptionTable.save(subscription.toData())
}
}
Here SubscriptionTable
is the “repository” provided by Spring Data JDBC. I did not want to call it a Repository to avoid confusion. In this approach, it is an internal implementation detail.
Extension methods containing the mapping between persistence and domain model:
fun SubscriptionData.toDomain() = Subscription(
id = id,
customerId = CustomerId(customerId),
address = address,
...
orders = orders.map { it.toDomain() }.toList()
)
fun Subscription.toData() = SubscriptionData(
id = id,
customerId = customerId.toLong(),
address = address,
...
orders = orders.map { it.toData() }.toSet()
)
SubscriptionData
is the persistence model type used to satisfy Spring Data JDBC and Subscription
is the counterpart in the domain model.
Since SubscriptionData
maps 1–1 to the database table then Spring Data JDBC takes care of all the mapping and generates SQL for it.