Hibernate vs. Exposed: Choosing Kotlin’s Best Persistence Tool - Round 2
The time has come to start the second round of the Hibernate vs Exposed showdown! By now, after reading the previous article from this series, you understand the design differences between Hibernate and Exposed. You've learned how to set up both frameworks in various environments. You know how to map your database tables to corresponding abstractions in your application code. You're also able to automatically generate the schema based on your classes.
But none of that adds any real business capabilities to your application just yet. What truly delivers business value is the ability to query your data, store it, update it and define transactions.
And don't worry - that's exactly what we'll cover in this article.
Data Persistence and Retrieval
Let’s firstly explore various ways of DML (data manipulation language) and querying provided by both frameworks.
Hibernate Criteria API
One of the options Hibernate offers for querying data is the Criteria API. Criteria queries are essentially a type-safe DSL built on top of Hibernate entities, allowing you to express SQL queries programmatically. Well, maybe not exactly SQL queries - because, as mentioned earlier, Hibernate abstracts away low-level SQL behind entities. So when using the Criteria API, you're still interacting with entities and speaking the language of entities, not directly with database tables.
To build a Criteria query, you generally start with a CriteriaBuilder
, and then define the parts and parameters of the query, diving deeper into the Criteria API's object graph.
Let's take a look at an example of how the Criteria API might be used with our familiar BoardGame
Hibernate entity:
val dune = BoardGame("Dune: Imperium", 1, 4, 120)
val everdell = BoardGame("Everdell", 1, 4, 100)
// first we need to save some entities to database
session.persist(dune)
session.persist(everdell)
// actual Criteria API usage starts here
val criteriaBuilder = session.criteriaBuilder
val criteria = criteriaBuilder.createQuery(BoardGame::class.java)
val root = criteria.from(BoardGame::class.java)
criteria.select(root)
criteria.where(criteriaBuilder.greaterThan(root.get("playTimeInMinutes"), 60))
criteria.orderBy(criteriaBuilder.desc(root.get<String>("title")))
val resultList = session.createQuery(criteria).resultList
The Criteria query above simply selects all board games with a play time greater than 60 minutes and sorts them by title in descending order. However, the word "simply" might not feel entirely accurate here. As you might have noticed, even to build this relatively basic query, we had to instantiate criteriaBuilder
, criteria, and root objects. On top of that, we had to construct fairly verbose expressions in the where
and orderBy
clauses - including passing field names as plain strings.
This is just my personal opinion, and you're absolutely free to disagree, but I find the Criteria API quite difficult and cumbersome to use. But if you're interested in learning more, check out the Criteria section in the Hibernate documentation.
Hibernate Query Language - HQL
There's also an alternative way to query the database in Hibernate. If you're willing to trade type-safety for expressiveness and simplicity, then you might want to take a look at Hibernate Query Language (HQL).
HQL is an object-oriented query language that's very similar to SQL - but instead of querying the underlying database tables, you query your entities.
When it comes to HQL query types, we can distinguish two main approaches:
- Inline queries - passed as strings to the
createQuery
method at runtime. Any potential errors in the query will only raise when it's executed. - Named queries - preconfigured using XML or annotations, and then referenced by name in the
createQuery
method. These queries are parsed by Hibernate at application startup, so some issues can be caught much earlier.
In this section, I'll show you how to build simple HQL queries and how to execute them.
Let's start by fetching all board games with a play time greater than 60 minutes and sorting them by title in descending order - this time using HQL:
val resultList = session.createQuery(
"FROM BoardGame WHERE playTimeInMinutes > :playtime ORDER BY title DESC",
BoardGame::class.java
)
.setParameter("playtime", 60)
.resultList
That was an example of an inline query. Now, let's see how to declare the same query as a named one. First, we need to define the named query for the BoardGame
entity:
@NamedQuery(
name = "find_boardgames_with_playtime_at_least",
query = "FROM BoardGame WHERE playTimeInMinutes > :playtime ORDER BY title DESC"
)
@Entity
@Table(name = "board_games")
open class BoardGame(
...
And then we can use it to query the database:
val resultList = session.createNamedQuery(
"find_boardgames_with_playtime_at_least",
BoardGame::class.java
)
.setParameter("playtime", 60)
.resultList
This is just a quick look at what HQL can do. The language itself is quite powerful, so if you'd like to explore it in more depth, the HQL section of the Hibernate documentation is a great place to start.
Spring Data JPA repositories
Even though it's technically out of scope for this article, I wanted to briefly mention another way of querying the database with Hibernate - one that's exclusive to Spring.
Spring Data JPA repositories aren't a separate querying mechanism in Hibernate, but rather an abstraction layer built on top of existing JPA query APIs, integrated into Spring.
While it's not a native Hibernate feature, I felt I couldn't just skip it in an article comparing Hibernate with Exposed - especially given how popular Spring Data JPA repositories are among developers, and because they're available when using Hibernate, but not when using Exposed.
If you're interested in learning more, feel free to explore the Spring documentation on JPA repositories.
Exposed DSL
As mentioned earlier, a core abstraction in Exposed for working with database tables is the Table object. But Tables in Exposed are not just used for defining the schema - they're also used for querying and manipulating data.
Assuming we have our BoardGamesTable
, which you've seen in the previous article, here's how you might perform various operations on it, including CRUD (create, read, update, delete) and more complex queries:
// insert
val boardGameId = BoardGamesTable.insert {
it[title] = "Dune: Imperium"
it[minPlayers] = 1
it[maxPlayers] = 4
it[playTimeInMinutes] = 120
} get BoardGamesTable.id
// add some ratings...
// select all
BoardGamesTable.selectAll().forEach {
println("Board game: id = ${it[BoardGamesTable.id]}, title = ${it[BoardGamesTable.title]}, ...")
}
// select average rating for board game
val averageRatingAlias = RatingsTable.rating.avg().alias("average_rating")
RatingsTable.select(RatingsTable.boardGameId, averageRatingAlias)
.groupBy(RatingsTable.boardGameId)
.forEach {
println("Board game id = ${it[RatingsTable.boardGameId]}, average rating = ${it[averageRatingAlias]}")
}
// update
BoardGamesTable.update({ BoardGamesTable.id eq boardGameId }) {
it[title] = "Dune: Imperium - Uprising"
}
// select single row
val singleBoardGame = BoardGamesTable.select(
BoardGamesTable.title,
BoardGamesTable.minPlayers,
BoardGamesTable.maxPlayers
)
.where(BoardGamesTable.id eq boardGameId)
.single()
// delete
BoardGamesTable.deleteWhere { id eq boardGameId }
Keep in mind that when using the Exposed Table DSL, the properties only map to actual database columns in the specific table.
So if we wanted to fetch ratings for a board game, we would need to explicitly query the RatingsTable
. Alternatively, we could join the tables to enrich each ResultRow
from RatingsTable
with some related board game details:
RatingsTable
.innerJoin(BoardGamesTable)
.select(BoardGamesTable.title, RatingsTable.rating)
.where(RatingsTable.boardGameId eq boardGameId)
.forEach {
println("Rating for board game ${it[BoardGamesTable.title]}: ${it[RatingsTable.rating]}")
}
Exposed DAO
I hope you remember the BoardGame
entity I defined in the Mapping & Schema Definition - Exposed DAO section of the previous article. There, I mentioned the companion object of type LongEntityClass
declared inside the entity. Thanks to this companion object, our entity class gains various database manipulation and querying capabilities.
Let me show you examples of different operations on BoardGame
entities - similar to the operations on BoardGamesTable
that you saw in the previous section:
// insert
val boardGame = BoardGame.new {
title = "Dune: Imperium"
minPlayers = 1
maxPlayers = 4
playTimeInMinutes = 120
}
// add some ratings...
// select all
val allBoardGames = BoardGame.all().toList()
// select average rating for board game
BoardGame.all().with(BoardGame::ratings) //'with' function to eagerly load the ratings
.map { boardGame -> Pair(
boardGame,
boardGame.ratings.map { it.rating }.average()
)}
.forEach {
println("Board game '${it.first.title}', average rating = ${it.second}")
}
// update
BoardGame.findByIdAndUpdate(
boardGame.id.value,
{ it.title = "Dune: Imperium - Uprising" }
)
// or
boardGame.title = "Dune: Imperium - Uprising"
// select single row
BoardGame.findById(boardGame.id)
// delete
boardGame.delete()
A few comments on the code snippet above:
- You may have noticed that although the DAO operates on objects rather than raw table rows, the API is quite similar to the one used in the table DSL. As a result, it may feel familiar to DSL users who are considering switching to DAO.
- To calculate the average ratings for each board game, we no longer need any join statements, since the
BoardGame
entity has aratings
property that fetches the associated ratings. The downside is that we can no longer compute the average on the database side using a custom query - because DAO operates on entity objects and their properties. We need to compute the average in code after fetching the data. - That said, we can still run such queries using the DSL - using DAO in one place doesn't prevent you from using the DSL elsewhere in your application.
- You might have also noticed the
with(BoardGame::ratings)
call on the result of theBoardGame.all()
query. This is used to eagerly fetch the ratings for eachBoardGame
entity, since we know in advance that we'll need them. - There are two ways to update entities. You can either call the
BoardGame.findByIdAndUpdate()
function or assign a new value directly to the entity's property. The first approach works well when you know the ID of the entity you want to update, but haven't fetched it from the database. The second is better suited when the entity object is already loaded, and you can manipulate it directly.
There's one more thing I'd like to emphasize when it comes to Exposed DAO entities. While Hibernate entities are designed to be plain Java (or Kotlin) objects, decoupled from the framework as much as possible, the same cannot be said about Exposed DAO entities.
They are tightly coupled to the framework:
- They need to extend specific base classes.
- They must reference a corresponding Exposed Table.
- All their properties are delegated to columns defined in the Table.
- Finally, they require a configured database connection to function.
This has important implications. If you use Exposed DAO entities as your domain model - i.e. in your business logic layer - you might have a hard time writing unit tests. That's because every test would require you to spin up a (real or in-memory) database and configure a database connection.
For this reason, it's generally advised not to use Exposed DAO entities as domain models. Instead, keep them encapsulated within the persistence layer.
Transaction Handling
When implementing business processes and workflows in your applications, you often expect certain pieces of logic to be executed atomically - all or nothing. We call such a unit of work a transaction. Fortunately for us developers, relational databases also have the concept of transactions, which more or less aligns with our expectations for business logic. This might be one of the reasons we tend to rely heavily on database transaction mechanisms in our code.
So, when you choose a specific persistence framework - an abstraction layer over your database - you likely expect it to provide a way to manage and control these transactions. Fortunately, both frameworks we're discussing today support this, though each of them takes a slightly different approach.
Let's see what they have to offer.
Hibernate Transaction API
Hibernate supports two different mechanisms for managing transactions:
- JDBC - uses the underlying
java.sql.Connection
to manage transactions directly - JTA - manages transactions via JTA (Java Transaction API)
For non-JPA applications (such as plain JVM apps), the transaction coordination mode is set to JDBC by default.
If you want to adjust this configuration, you can add the following lines to your hibernate.properties
file:
hibernate.transaction.coordinator_class=jta
hibernate.transaction.jta.platform=<SOME_JTA_PLATFORM_IMPL>
Note that you'll need to specify a custom JTA platform to make the application work, as Hibernate's default JTAPlatform
implementation is... NoJtaPlatform
. Because of this, you might encounter error messages like: “Could not locate TransactionManager to suspend any current transaction.”
Regardless of how you configure the transaction coordinator, you can use Hibernate's standard, unified API for transaction management.
Assuming you have a SessionFactory
, here's how you can run code within a transaction:
val session = sessionFactory.openSession()
try {
session.transaction.begin()
// perform some DB operations
session.transaction.commit()
} catch (e: Exception) {
if (session.transaction.status == ACTIVE || session.transaction.status == MARKED_ROLLBACK) {
session.transaction.rollback()
}
// further handle the exception
} finally {
session.close()
}
You can also use a more concise and declarative syntax, which gives you slightly less control:
sessionFactory.inTransaction { session ->
// perform some DB operations
}
Beyond basic transaction lifecycle control (begin, commit, rollback), Hibernate also allows further configuration so you can tailor transactions to your needs. Let's take a look at some of the most commonly used options.
Let's start with transaction isolation levels. If you're unfamiliar with the concept, the PostgreSQL documentation offers a solid overview.
The best and most official way to customize the transaction isolation level is by setting it in hibernate.properties
:
hibernate.connection.isolation=REPEATABLE_READ
To see which values and formats are supported, refer to the official Hibernate documentation on connection isolation level configuration.
This configuration is static - it applies to all sessions and transactions throughout your application. You can still change it programmatically on the Connection
instance by using Connection.setTransactionIsolation(). But mind that this setting will be applied to all the sessions (and transactions) this Connection
instance handles.
While changing the isolation level dynamically at runtime per session is technically possible, it's not officially documented, and I consider it unreliable and somewhat hacky. That's why I won't provide an example of how to do it. However, if you're curious, you can look into the Session.doWork() method, which gives you direct access to the underlying Connection
, and explore community examples online.
Another transaction-related setting you can control in Hibernate is whether a transaction is read-only. This is configured at the session level. By default, entities in a session are modifiable. But if the session is set to be read-only, Hibernate disables its dirty-checking mechanism for that session. This means you can still modify entity properties in memory, but those changes won't be persisted to the database.
Once you have a session, you can mark it as read-only like this:
session.isDefaultReadOnly = true
It's worth noting that this read-only setting is handled entirely by Hibernate. It does not interact with or depend on any potential read-only features of the underlying database.
Exposed transaction block
Exposed requires all CRUD operations to be executed within a transaction. The main concept in Exposed for handling transactions is the transaction
block.
Running database operations within a transaction is as simple as:
transaction {
// some DSL or DAO operations go here
}
The transaction
block can also return values, so if you want to continue working with the data returned from the transaction, you can write code like this:
val boardGame = transaction {
BoardGame.findById(boardGameId)
}
doSomethingOutsideOfTransaction(boardGame)
By default, the transaction
block operates on the default database - that is, the most recently connected via Database.connect()
, or the one set as TransactionManager.defaultDatabase
. This is perfectly sufficient if your application uses just a single database. However, if that's not the case, you can explicitly specify which database a given transaction should run on:
val db = Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1", driver = "org.h2.Driver")
transaction(db) {
// DB operations here
}
In addition to selecting the database, the Exposed transaction block offers several configuration options, including transaction isolation, read-only mode, max attempts, and query timeout.
Below is an example of a transaction block customized with all of the above options:
transaction(
transactionIsolation = Connection.TRANSACTION_REPEATABLE_READ, // values from java.sql.Connection
readOnly = true // false by default
) {
maxAttempts = 5 // number of retries on SQLException
queryTimeout = 10 // in seconds
// DSL or DAO operations here
}
It's important to note that all of these parameters, except for maxAttempts
, are not handled directly by Exposed. Instead, they're passed to the underlying JDBC driver and rely entirely on its support. This means that if a specific JDBC driver doesn't support some of these features, they simply won't work.
Exposed also provides more advanced capabilities for transaction handling that I haven't covered here - such as nested transactions, savepoints, and Kotlin coroutines support. I highly encourage you to explore these topics in the Transactions section of the Exposed documentation.
Spring @Transactional annotation
Okay, so you've read through the whole Transaction Handling section of this article, and you're still wondering: "Where is the @Transactional annotation that I use all the time?"
Well, that one belongs to the Spring framework. And there are two reasons why I haven't mentioned it in the previous subsections.
First, although the @Transactional annotation is often associated with Hibernate, it's not actually a Hibernate-specific feature. In fact, there are two @Transactional
annotations you might be thinking of here:
jakarta.transaction.Transactional
from the JTA standard,org.springframework.transaction.annotation.Transactional
, which is Spring's own implementation of the former.
And this blog post isn't specifically about Spring nor JTA.
Second, since you can integrate both Hibernate and Exposed with Spring, you can use the @Transactional annotation with either of them in a Spring-based project. It will work seamlessly with both transaction handling mechanisms.
Just to make it concrete, let's look at how the @Transactional annotation works in practice with both frameworks.
First, a Spring service that invokes a JPA repository backed by Hibernate:
@Service
open class BoardGamesService(
private val boardGamesRepository: BoardGamesRepository
) {
@Transactional
open fun rateBoardGame(title: String, rating: Int) {
val boardGame = boardGamesRepository.findByTitle(title) ?: throw EntityNotFoundException()
val rating = Rating(rating, Instant.now(), boardGame)
boardGame.ratings.add(rating)
}
}
And now, a similar snippet for a Spring service using Exposed DAOs for persistence:
@Service
open class BoardGamesService {
@Transactional
open fun rateBoardGame(title: String, rating: Int) {
val boardGame = BoardGame.find { BoardGamesTable.title eq title }.firstOrNull()
?: throw EntityNotFoundException()
Rating.new {
this.rating = rating
this.boardGame = boardGame
}
}
}
As you can see, in both examples, the only thing needed to ensure the code runs within a transaction was to annotate the function with @Transactional
. So there's no need for an explicit call to a Hibernate session
or an Exposed transaction
block.
The Spring @Transactional
annotation also offers quite a few configuration options. For more details, refer to the official Spring documentation on transaction management.
Summary
That's all for today. Let's briefly summarize the main findings of this article.
Querying
Hibernate offers powerful, flexible querying options (Criteria API, HQL, Spring Data JPA), but at the cost of verbosity and sometimes fragility (e.g., string-based HQL).
Exposed DSL is more concise, type-safe, and expressive - especially if you're comfortable with SQL semantics.
Transaction Handling
Both frameworks offer solid transaction support, but with different philosophies.
Hibernate follows the classic session/transaction pattern (with Spring's @Transactional
smoothing things over).
Exposed's transaction
block is simple, Kotlin-friendly, and supports fine-grained configuration out of the box. And Spring @Transactional
annotation works there as well.
Look out for another article in the series soon. In it, I will include performance consideration and integration with Kotlin comparison.
Reviewed by: Rafał Maciak, Emil Bartnik, Rafał Wokacz