Contents

Hibernate vs. Exposed: Choosing Kotlin’s Best Persistence Tool - Round 3

Hibernate vs. Exposed: Choosing Kotlin’s Best Persistence Tool - Round 3 webp image

Welcome to the third and final round of the Hibernate vs. Exposed showdown!

If you've been following this series from the beginning, you’ve hopefully learned quite a bit, including:

  • The foundations of both Hibernate and Exposed, how to set them up in various environments, and how to model your data using each. All of that was covered in the first article of the series.
  • The different approaches both frameworks offer for creating, updating, deleting, and querying data, as well as how to define and configure transactions. That was the focus of the second article.

In this final article, you’ll learn about more advanced concepts - particularly around query performance, some of the caveats and pitfalls of each framework, and how well they fit into the Kotlin ecosystem.

Enjoy the read!

Performance Considerations

Database interactions, being I/O operations, can become serious performance bottlenecks and may significantly affect both the latency and throughput of our applications. That's why it's crucial to carefully optimize the performance of our database queries. Persistence frameworks can be a great help in achieving this, but unfortunately, they can also introduce additional problems, mainly due to our misuse or misunderstanding of how those frameworks work.

In this section, I'll go over some of the most common performance issues related to persistence frameworks, and I'll try to shed some light on how both Hibernate and Exposed can help us - or make it harder for us - to mitigate them.

Data fetching

Tuning data fetching is a crucial aspect of application performance. Persistence frameworks - especially those that abstract away the complexity of database queries - typically offer various strategies and mechanisms for fetching data, either automatically or semi-automatically. These approaches give you, as the developer, more or less control over how and when the data is loaded.

One of the most well-known features in the context of data fetching, particularly in ORM frameworks, is lazy loading. It's a mechanism that can either work for you or against you. While it's designed to minimize the amount of data loaded when fetching entities with multiple relationships, it can easily lead to issues - the infamous N+1 queries problem being one of them. Additionally, it may result in runtime exceptions that you wouldn't expect at first glance.

But lazy loading isn't the only feature that can cause trouble if misused. Eager loading can also lead to performance problems, especially when there are too many associations between entities or when fetch strategies are configured incorrectly.

That can definitely give you a headache! But fear not - in this section, I'll walk you through how Hibernate and Exposed handle data fetching and dive into the details of their respective mechanisms.

Data fetching - Hibernate

Data fetching in Hibernate is a fairly complex and multidimensional topic. There are various ways to define, redefine, and override previously defined fetching strategies. In fact, this topic is so broad that it really deserves a dedicated blog post. So here, let me just scratch the surface and describe it as concisely as possible.

When discussing data fetching in Hibernate, we should consider two key dimensions:

  • Timing - when the related data is fetched:
    • Eager loading: fetched immediately with the main entity
    • Lazy loading: fetched later, when accessed
  • Scope - how the fetching is defined:
    • Static: defined in the entity mapping
    • Dynamic: defined at runtime, per specific query

With those dimensions in mind, let's take a look at how to define fetching timing within different scopes in Hibernate.

Static fetching strategies declaration

The most common way to define fetching is to statically declare the FetchType for relationships directly in the entity class:

@Entity
@Table(name = "board_games")
open class BoardGame(
// other properties ommited for brevity
) {
    @OneToMany(mappedBy = "boardGame", fetch = FetchType.LAZY)
    @Fetch(FetchMode.SUBSELECT)
    open var ratings: MutableSet<Rating> = mutableSetOf()
// ...
}

@Entity
@Table(name = "ratings")
open class Rating(
// other properties ommited for brevity
) {
    @ManyToOne(fetch = FetchType.LAZY)
    @Fetch(FetchMode.SELECT)
    @JoinColumn(name = "board_game_id", nullable = false, updatable = false)
    open val boardGame: BoardGame

// ...
}

In the example above, we define a @OneToMany relationship between the BoardGame entity and its Ratings. It's explicitly declared as lazy, although in this case it's not necessary - in Hibernate, all @OneToMany and @ManyToMany relationships are lazy by default.

There's also the Rating entity, where we define a @ManyToOne relationship back to BoardGame. Here, we explicitly mark it as lazy, because in Hibernate, both @ManyToOne and @OneToOne relationships are eager by default.

Besides the FetchType definition, you may have noticed the additional @Fetch annotation. While FetchType is part of the JPA standard and defines when the related entities are fetched, the @Fetch annotation is Hibernate-specific and controls how the fetching is performed.

The @Fetch annotation supports three options:

  • SELECT - Suitable for both eager and lazy associations. This fetch mode loads related entities in a secondary query (executed after the main one). It's the default for non-eager relationships and is prone to the N+1 queries problem.
  • JOIN - Only works with eager associations. It instructs Hibernate to fetch related entities in the primary query using a JOIN. This is the default fetch mode for eager relationships.
  • SUBSELECT - Works with both eager and lazy associations, but only for collections. Like SELECT fetch type, it uses a secondary query - but instead of issuing one query per parent entity, it wraps the initial SELECT in an IN clause to load all related entities at once. This avoids the N+1 problem.

Let's pause for a moment and explore when the infamous N+1 queries problem can arise.

Suppose we ignore the @Fetch(FetchMode.SUBSELECT) annotation on the BoardGame.ratings property - treating it as a regular lazy association with the default FetchMode.SELECT.

Now consider the following code:

val boardGames = session.byMultipleIds(BoardGame::class.java).multiLoad(boardGameIds)

boardGames.forEach { boardGame ->
   println("Found board game: ${boardGame.title}")
   println("Found board game ratings: ${boardGame.ratings}") // N + 1 queries!
}

In the code above, a separate SQL query will be executed every time boardGame.ratings is accessed.

However, if you changed the fetch strategy to FetchType.EAGER, this issue would disappear, because the default fetch mode for FetchType.EAGER is JOIN. As a result, both the board games and their ratings would be fetched in a single query using a JOIN.

BUT - if you explicitly set @Fetch(FetchMode.SELECT) on an eager association, the N+1 problem would come back. And in this case, it would even manifest earlier than for the lazy variant – immediately after the main query is executed.

Dynamic fetching strategies declaration

In the previous section, I showed you how to declare fetch strategies statically. But as I mentioned earlier, Hibernate also allows you to define dynamic fetch strategies – that is, fetch settings applied at runtime, per query. It's important to note that dynamic strategies always override any static fetch configurations defined on your entities.

So let's explore what Hibernate offers in this area.

Hibernate provides three main mechanisms for defining fetch strategies dynamically:

  1. Queries (HQL and Criteria API)

Both HQL (Hibernate Query Language) and the Criteria API allow you to control which associations are fetched eagerly. You can do this using:

  • the JOIN FETCH clause in HQL,
  • the Root.fetch() method in the Criteria API.

Here's an example from our board game store application, where we want to fetch a BoardGame along with all its Ratings eagerly:

// HQL
val boardGame = session.createQuery(
   "FROM BoardGame bg LEFT JOIN FETCH bg.ratings WHERE bg.title = :title",
   BoardGame::class.java)
   .setParameter("title", "Dune: Imperium")
   .singleResult

// Criteria API
val criteriaBuilder = session.criteriaBuilder
val query = criteriaBuilder.createQuery(BoardGame::class.java)
val root = query.from(BoardGame::class.java)

root.fetch<BoardGame, Rating>("ratings", JoinType.LEFT)

query.select(root).where(
   criteriaBuilder.equal(root.get<String>("title"), "Dune: Imperium")
)

val boardGame = session.createQuery(query).singleResult

Here's where the N+1 queries problem can quietly sneak in if you're not careful.

Let's assume the BoardGame.ratings property is declared with FetchType.EAGER, either implicitly (no @Fetch annotation) or explicitly using @Fetch(FetchMode.JOIN).

Now let’s compare the two different ways of fetching a board game by ID:

// direct entity find by ID 
val boardGameByFind = session.find(BoardGame::class.java, boardGameId)
   ?: throw EntityNotFoundException()

println("Board game ${boardGameByFind.title} has the following ratings: ${boardGameByFind.ratings}")

// HQL query
val boardGameByHQL = session.createQuery(
   "FROM BoardGame WHERE id = :id",
   BoardGame::class.java
)
.setParameter("id", boardGameId)
.singleResult

println("Board game ${boardGameByHQL.title} has the following ratings: ${boardGameByHQL.ratings}")

You might think both approaches should fetch the ratings for the BoardGame eagerly via the JOIN clause of the main query, right?

Wrong.

In the first case (session.find(...)), the FetchMode.JOIN is honored, and everything is loaded with a single query.

In the second case (using HQL), however, the static @Fetch(FetchMode.JOIN) annotation is ignored, and Hibernate issues a separate SELECT for the ratings instead.

This happens because dynamic fetching always overrides static declarations. For HQL, that means: if you don't explicitly use JOIN FETCH, you won't get any joins - no matter what the entity says. So if you rely on @Fetch(FetchMode) annotations and use HQL queries, be mindful of this nuance.

  1. JPA Entity Graphs

JPA's entity graphs let you define a custom fetch graph by explicitly listing the associations to fetch. These graphs can be named, reused, and even include subgraphs for nested relationships.

Let's define a simple entity graph to fetch BoardGame along with its Ratings:

@NamedEntityGraph(
   name = "BoardGame.ratings",
   attributeNodes = [NamedAttributeNode("ratings")]
)
@Entity
@Table(name = "board_games")
open class BoardGame(

And the usage of such graph in a query:

val boardGame = session.find(
   BoardGame::class.java,
   boardGameId,
   mapOf("jakarta.persistence.fetchgraph" to session.getEntityGraph("BoardGame.ratings")))

With this configuration, Hibernate will eagerly fetch Ratings using a JOIN - even if the ratings property is marked as FetchType.LAZY in the entity class.

Important caveat: When using an entity graph, all associations included in the graph are treated as FetchType.EAGER whereas all other associations (not mentioned in the graph) are treated as FetchType.LAZY - regardless of the static annotations!

  1. Hibernate Fetch Profiles

Fetch profiles are Hibernate's alternative to JPA entity graphs. They're particularly useful when entity graphs aren't supported – for instance, when fetching by natural ID. Entity graphs and fetch profiles are mutually exclusive. If both are defined, entity graphs will take precedence.

Let's say the title field is marked as the @NaturalId of the BoardGame entity. In this case, we can define and use a fetch profile like this:

@FetchProfile(
   name = "BoardGame.ratings",
   fetchOverrides = [
       FetchOverride(
           entity = BoardGame::class,
           association = "ratings",
           mode = FetchMode.JOIN
       )
   ]
)
@Entity
@Table(name = "board_games")
open class BoardGame(
   @NaturalId
   @Column(name = "title", nullable = false)
   open var title: String,

And then use it in the query in the following way:

session.enableFetchProfile("BoardGame.ratings")

val boardGame = session.bySimpleNaturalId(BoardGame::class.java)
   .load("Dune: Imperium")

This way we asked Hibernate to eagerly fetch ratings for a BoardGame in a single query using JOIN.

It’s worth mentioning that for the Fetch Profile you specify a list of FetchOverrides. So, as the name suggests, Fetch Profile just overrides the specific, selected associations’ fetch strategies, leaving the remaining ones with their default or statically defined FetchTypes. This is contrary to the Fetch Graph behaviour described earlier, which overrides all the associations of the corresponding entity.

Lazy initialization exception trap

There is one more thing I'd like to draw your attention to. As I mentioned at the beginning of the Data Fetching section, lazy loading can be a really helpful feature when it comes to improving the performance of your queries - but it's also quite easy to misuse, leading to potential runtime errors. Here's what I mean.

Let's imagine a situation where you have a REST endpoint for fetching a BoardGame by its ID. To fulfill such a request, your application first calls the REST Controller, which then invokes a Domain Service, which in turn uses a JPA Repository to fetch the BoardGame entity. After the entity is returned to your Controller, it is converted to a DTO that uses all the entity's fields, including the ratings, which are marked with FetchType.LAZY. The transaction wraps the Service method.

The diagram below illustrates the flow I just described and “gently” marks the place in the flow where you can expect an exception to be thrown:

board game diagram

And this is how it could look in the code:

// BoardGamesService
@Transactional(readOnly = true)
open fun findBoardGame(id: Long): BoardGame {
   val game = boardGamesRepository.findById(id).orElseThrow { EntityNotFoundException() }
   // some additional business logic here
   return game
}

// BoardGamesController
val boardGame = boardGameService.findBoardGame(boardGameId)
val boardGameDto = BoardGameDto(
   ratings = boardGame?.ratings?.map { RatingDto(it.rating) } ?: emptyList() // BOOM!
   // other properties omitted
)

The exception you'd get in this scenario is org.hibernate.LazyInitializationException. It would be thrown by Hibernate because you're attempting to access the ratings property of a BoardGame entity that hasn't been loaded yet due to its FetchType.LAZY. Since you're doing this outside of the transaction boundaries, Hibernate can no longer fetch the ratings from the database - hence the runtime failure.

I'm sure you're aware of this problem and generally avoid such situations in your code. But unfortunately, not everyone is - and you can still find this issue in the codebases of various production applications. That's why I decided to elaborate on it a bit more in this section.

That's it for Hibernate data fetching. Everything you need to know on this topic, including even more in-depth coverage than what I've provided here, you can find in the Fetching section of the Hibernate documentation.

Data Fetching - Exposed

When it comes to data fetching, things are much simpler with Exposed than with Hibernate, so expect a much shorter section.

First of all, this section only applies to Exposed DAO, as Exposed DSL is mostly a wrapper over SQL queries. Exposed will fetch only the data you explicitly ask for in your DSL query – no lazy loading there.

Eager and lazy loading in Exposed DAO

The creators of Exposed made a simple assumption for their lightweight ORM module – Exposed DAO. They decided that all relationships within Exposed entities are lazy-loaded by default. But that doesn't mean you're limited to only lazy-loaded associations when using Exposed. You have the option to eagerly load specific associated entities at runtime, on a per-query basis.

First, let me remind you how to define relationships in Exposed DAO entities, using our BoardGame and Rating entities:

class BoardGame(id: EntityID<Long>): LongEntity(id) {
   companion object: EntityClass<Long, BoardGame>(BoardGamesTable)

   val ratings by Rating referrersOn RatingsTable.boardGameId
    // other properties omitted for brevity
}

class Rating(id: EntityID<Long>) : LongEntity(id) {
   companion object : LongEntityClass<Rating>(RatingsTable)

   var boardGame by BoardGame referencedOn RatingsTable.boardGameId
   // other properties omitted as well
}

As you can see, there are two different infix functions to define a relationship:

  • referencedOn - defines the relationship in the entity that is the owning side of the relationship (the child). In our case, the owning side is the Rating entity, which maps to the RatingsTable where the actual boardGameId column is persisted.
  • referrersOn - defines the relationship in the entity that is the other side of the relationship (the parent). In the example above, the BoardGame entity is the other side, because its underlying table doesn't store any information about this relationship. You can see that the relationship in the BoardGame entity explicitly refers to the RatingsTable.boardGameId column as the referrer.

So, with that in mind, let's see how you can fetch a BoardGame along with all its Ratings eagerly:

// Eagerly loading ratings for a single board game
val boardGame = BoardGame.findById(1L)?.load(BoardGame::ratings)
println(boardGame)
println(boardGame?.ratings?.toList())

// Eagerly loading ratings for multiple board games
val boardGames = BoardGame.all().with(BoardGame::ratings)
boardGames.forEach {
   println(it)
   println(it.ratings.toList())
}

Let's break down what happens in the code above.

You can see two separate examples of eager loading in Exposed DAO. In the first one, we find a single BoardGame entity and use the load(BoardGame::ratings) function on the result to immediately load all the ratings for this BoardGame. What Exposed will do under the hood is issue a separate SQL query to fetch all associated Rating entities immediately after the BoardGame has been returned from the primary query. If we hadn't called the load() function, that same SQL query would be executed later – the first time we referenced the BoardGame.ratings property.

In the second example, you can see how to eagerly fetch associated ratings for multiple BoardGame entities – in this case, fetched via the all() function. This time, we used the with(BoardGame::ratings) function on the returned BoardGames collection to eagerly fetch all Ratings for all the BoardGame instances in the collection. Now Exposed will execute just one additional SQL query to fetch all associated Ratings, using an IN clause with all the BoardGame IDs as arguments. If we hadn't used the with() function, ratings would be loaded separately for each BoardGame when first referenced – leading to the N+1 queries problem.

As you might have noticed, Exposed DAO doesn't offer any way to eagerly fetch the main entity along with all associated entities using a single query via a JOIN clause, as Hibernate does with FetchMode.JOIN. In Exposed, a secondary query is always needed.

Using relationships outside of a transaction

One more thing is worth mentioning here. Since all (lazily or eagerly) loaded associations are stored inside a transaction cache, they are available only within the transaction block. If you'd like to use them outside of a transaction, you need to configure keepLoadedReferencesOutOfTransaction in DatabaseConfig:

val db = Database.connect(
   url = "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1",
   driver = "org.h2.Driver",
   databaseConfig = DatabaseConfig {
       keepLoadedReferencesOutOfTransaction = true // this option allows to use associations outside of transaction block
   })

val boardGame = transaction {
   BoardGame.findById(1L)?.load(BoardGame::ratings)
}

println(boardGame)
println(boardGame?.ratings?.toList())

If you haven't done that, then calling boardGame?.ratings?.toList() would cause an exception in the example above.

This case is quite similar to Hibernate's LazyInitializationException described earlier. However, it also affects eagerly loaded associations.

You can read more about Exposed's lazy and eager loading in the Relationships section of the Exposed documentation.

Batching

Batching is another aspect related to the performance of applications working with relational databases. JDBC drivers have a built-in batching feature that allows batched SQL queries to be sent to the database server in one go, instead of one by one. This can minimize the number of network calls between the application and the database server.

In this section, we'll see how both persistence frameworks leverage this JDBC batching feature.

Batching - Hibernate

By default, Hibernate doesn't use batching and sends all the queries one by one. But this behavior can be easily changed by setting the following property (e.g. via hibernate.properties):

hibernate.jdbc.batch_size=20

The setting above tells Hibernate to leverage JDBC batching by sending up to 20 queries per batch to the database server.

You can also set this parameter on a per-session basis:

sessionFactory.inTransaction { session ->
   session.jdbcBatchSize = 10
   // ...
}

This setting will override the hibernate.jdbc.batch_size property.

Now you can just insert new entities in a loop, and Hibernate will take care of batching the queries under the hood:

sessionFactory.inTransaction { session ->
   val batchSize = session.properties.getOrDefault("hibernate.jdbc.batch_size", 10) as Int

   for (i in 0..99) {
       if (i > 0 && i % batchSize == 0) {
           session.flush()
           session.clear()
       }

       session.persist(BoardGame("BoardGame$i", 1, i, 10 * i))
       println("BoardGame$i created")
   }
}

You probably noticed the code for flushing and clearing the session periodically, depending on the batchSize variable. This bit of boilerplate code is needed to keep the persistence context's memory footprint as low as possible. In our case, it might not be necessary since we only create 100 BoardGame entities, but in general, it is a recommended practice.

Once you have the hibernate.jdbc.batch_size configured, you can also benefit from JDBC batching in update queries. Take a look at the example below:

sessionFactory.inTransaction { session ->
   val batchSize = session.properties.getOrDefault("hibernate.jdbc.batch_size", 10) as Int

   val scrollableResults = session.createQuery("FROM BoardGame", BoardGame::class.java)
       .scroll()

   var count = 0
   while (scrollableResults.next()) {
       val boardGame = scrollableResults.get()
       boardGame.title = boardGame.title + "_new"

       if (count++ % batchSize == 0) {
           session.flush()
           session.clear()
       }
   }
}

Apart from calling the session.flush() and session.clear() that I already explained above, the Hibernate team additionally recommends using ScrollableResults when fetching data for batch updates. This is achieved by calling scroll() on the session.createQuery() in the example. The reason is to take advantage of server-side cursors for queries that may return many rows.

There's one more important thing I'd like to share with you at the end of this section. Please be aware that Hibernate will automatically and silently disable JDBC batching for entities that use the IDENTITY identifier generator. So if your entity has the following annotation on its primary key property, batching won't work:

@GeneratedValue(strategy = GenerationType.IDENTITY)

Batching - Exposed

Exposed is also capable of leveraging JDBC batch inserts, and this is done via the Table DSL. So, if you wanted to insert multiple rows into the board_games table using Exposed, it would look like this:

val boardGamesToCreate = (0..99).map {
   BoardGameData(title = "BoardGame$it", minPlayers = 1, maxPlayers = 4, playTime = 60)
}

BoardGamesTable.batchInsert(data = boardGamesToCreate, shouldReturnGeneratedValues = false) {
   this[BoardGamesTable.title] = it.title
   this[BoardGamesTable.minPlayers] = it.minPlayers
   this[BoardGamesTable.maxPlayers] = it.maxPlayers
   this[BoardGamesTable.playTimeInMinutes] = it.playTime
}

BoardGamesTable.batchInsert() accepts several parameters, but the most important and required ones are:

  • data - a collection of values to be used in the batch insert body,
  • body - a function whose task is to convert a single entry from the data parameter into a new Exposed Table row.

Apart from those two, there's also one more parameter in the example above:

  • shouldReturnGeneratedValues - whether or not the function should return auto-generated values for new rows (like auto-incremented IDs). If you don't need those values, it's better to set this to false for performance reasons.

Exposed also allows you to perform batch updates, although it's not as easy to find or as well-documented as batch inserts. Table DSL doesn't provide a convenient batchUpdate() function (at least at the time of this writing). Still, you can do it like this:

BatchUpdateStatement(BoardGamesTable).apply {
   boardGamesForUpdate.forEach { boardGameRow ->
       addBatch(boardGameRow[BoardGamesTable.id])

       this[BoardGamesTable.title] = "Updated ${boardGameRow[BoardGamesTable.title]}"
   }

   execute(TransactionManager.current())
}

In the example above, boardGamesForUpdate is of type List<ResultRow> but it can be any collection of data. What's important in BatchUpdateStatement is that you provide an addBatch() function with the ID of the specific row you want to update, along with the actual values to be updated.

It's easy to spot that Exposed approaches batching a bit differently from Hibernate. In Exposed, there's no batch_size configuration – instead, it gives you an explicit way to invoke batch operations. This means you have to manually decide where batching should occur using a dedicated API, but this pays off in terms of better control and transparency.

Surprisingly, Exposed doesn't have any official, in-depth documentation about batching, but you can still find some information in the section dedicated to CRUD operations - especially batch inserts.

Integration with Kotlin Features

We're focusing on Kotlin in this article, so it's no wonder that we'd like to know how each of the two frameworks described here utilizes native Kotlin features, and how easy and convenient it is to write Kotlin code with them. Let's examine them one by one.

Hibernate

It's no surprise that Hibernate was designed purely for Java and with just Java in mind. It's hard to blame anyone – Hibernate is actually much older than Kotlin, and probably older than most other JVM non-Java languages as well. That makes Hibernate a very mature and battle-tested framework, but at the same time, deeply integrated with Java syntax, making it feel a bit less natural when used with other languages.

So, when it comes to Kotlin features, Hibernate doesn't offer any explicit integration, APIs, DSLs, or anything like that. It's totally usable in Kotlin projects, but that's thanks to Kotlin's interoperability with Java, rather than Hibernate itself. You can use Kotlin's syntactic sugar in Hibernate entities or querying APIs just like in any other pure Java library.

What's a bit more concerning is that, due to some specific and strict requirements in the JPA specification, Hibernate forces us to give up certain Kotlin goodies, resulting in even more boilerplate code. I already mentioned most of these issues in the Mapping & Schema Definition section, where I discussed Hibernate entities. Just to recap, I'm mainly referring to these JPA restrictions:

  • the entity class must have a no-arg constructor,
  • the entity class must not be final,
  • no methods or persistent instance variables of the entity class may be final.

They force you to either manually open all entity classes and their properties, add default constructors, and give up on data classes and immutability, or to use third-party build plugins that modify your bytecode.

Exposed

As Exposed is written in Kotlin and for Kotlin, it's obvious that it should have much better integration with native Kotlin features – and that is indeed the case.

Let me focus on some specific Kotlin idioms and show how Exposed leverages them for your convenience and developer experience:

  • data classes - Interestingly, Exposed doesn't explicitly make use of data classes – one of Kotlin's coolest features (personal opinion alert!). Exposed DSL Tables are defined as objects, and Exposed DAO entity classes are structured in a way that makes it hard to define them as data classes. But that doesn't mean you can't make use of data classes in your code that works with Exposed. Actually, it's the opposite – you absolutely can, and it's very natural and straightforward. It's especially useful when you want to convert ResultRows into some domain objects:
val boardGames = BoardGamesTable.selectAll().map {
   BoardGameData(
       it[BoardGamesTable.id].value,
       it[BoardGamesTable.title],
       it[BoardGamesTable.minPlayers],
       it[BoardGamesTable.maxPlayers],
       it[BoardGamesTable.playTimeInMinutes]
   )
}
  • infix functions - The benefit of infix functions is especially noticeable in Exposed DSL queries. Thanks to infix functions like eq, greater, lessEq, like, and, or, etc., conditions in where expressions look more natural and gain better readability:
BoardGamesTable.selectAll()
   .where { (BoardGamesTable.maxPlayers lessEq 4) and (BoardGamesTable.title like "Aeon's End%") }
   .toList()
  • extension functions - Exposed uses extension functions a lot (see various functions from the org.jetbrains.exposed.sql package for reference). But it also encourages developers to leverage extension functions in their own code for query customization and reusability. An example might be writing your own extension functions for ResultRow to elegantly convert it to your domain objects:
fun ResultRow.toBoardGameData() = BoardGameData(
   id = this[BoardGamesTable.id].value,
   title = this[BoardGamesTable.title],
   minPlayers = this[BoardGamesTable.minPlayers],
   maxPlayers = this[BoardGamesTable.maxPlayers],
   playTime = this[BoardGamesTable.playTimeInMinutes]
)
  • lambdas with receivers - Kotlin's ability to pass a lambda expression as a parameter outside the main parameter list has found its place in Exposed as well. Thanks to the lambdas with receivers feature, Exposed DSL queries are far more expressive, concise, and readable:
BoardGamesTable.insert {
   it[title] = "Champions of Midgard"
   it[minPlayers] = 2
   it[maxPlayers] = 4
   it[playTimeInMinutes] = 90
}

BoardGamesTable.selectAll()
   .where { BoardGamesTable.playTimeInMinutes lessEq 90 }
   .forEach {
       // ...
   }
  • type safety - The whole Exposed DSL is advertised as a type-safe, SQL-like syntax. This is especially beneficial when writing Exposed queries, where all parts of a typical SQL query (like SELECT, JOIN, WHERE), including the arguments of those clauses, are checked at compile time and benefit from syntax completion in your IDE:
BoardGamesTable.innerJoin(RatingsTable)
   .select(BoardGamesTable.title, RatingsTable.rating.avg())
   .groupBy(BoardGamesTable.title)
   .where { BoardGamesTable.title like "Aeon's End%" }
   .forEach {
       println("Average rating of ${it[BoardGamesTable.title]} is ${it[RatingsTable.rating.avg()]}")
   }
  • nullability handling - In my opinion, Kotlin's nullability handling shines the most when declaring the schema - in other words, the properties of Exposed tables representing database columns. In Exposed, you don't use annotations or other configuration means to define columns; instead, you do it entirely with Kotlin code. This gives you compile-time checks of both the type and the nullability of your properties:
object RatingsTable: LongIdTable("ratings") {
   val rating: Column<Int> = integer("rating")
   val comment: Column<String?> = varchar("comment", 256).nullable() // actual nullable type
  • coroutines - While database access with most of today's JDBC drivers is still blocking, coroutines are slowly making their way into Exposed as well. Functions like newSuspendedTransaction() and withSuspendTransaction() allow you to interact with Exposed within suspend blocks, passing a CoroutineContext, while suspendedTransactionAsync() gives you the ability to run some Exposed code asynchronously and await the result further down in your code:
suspend fun findBoardGame(id: Long): BoardGameData? = newSuspendedTransaction(Dispatchers.IO) {
   BoardGamesTable.selectAll().find { it[BoardGamesTable.id].value == id }?.toBoardGameData()
}

suspend fun getAllBardGames(): Deferred<List<BoardGameData>> = suspendedTransactionAsync(Dispatchers.IO) {
   BoardGamesTable.selectAll().map { it.toBoardGameData() }
}

Summary: Hibernate or Exposed?

Let's briefly recap what we discussed today (performance considerations and Kotlin integration). Below you will find the final verdict summarizing the entire series.

Performance Considerations

Lazy loading, eager loading, and batching are supported in both – but with different ergonomics and gotchas.

Hibernate gives you more options (e.g. fetch profiles, entity graphs), but also more ways to shoot yourself in the foot.

Exposed is simpler and more predictable - especially for those who prefer explicit data fetching and leaner abstractions.

Kotlin Integration

This might be the deciding factor for many.

Hibernate works with Kotlin, but it was never designed for it. It sometimes even makes it harder to leverage specific Kotlin idioms due to various restrictions.

Exposed, on the other hand, embraces Kotlin idioms, like extension functions, DSLs, lambdas with receivers, coroutine support, and more. If writing idiomatic, expressive Kotlin is a priority, Exposed will likely feel more natural.

Final Thoughts

If you're building a Spring-based enterprise application that needs JPA compliance, mature tooling, and industry support, Hibernate is a safe and powerful choice (especially if you’re already familiar with it). But be aware of its verbosity and the Kotlin friction points.

If you're building a lightweight Kotlin app, especially with Ktor or other modern stacks, and you value control, conciseness, and Kotlin-first APIs – then Exposed will likely be the better fit.

In short: Hibernate is great when you need abstraction and compatibility. Exposed is great when you want clarity and Kotlin-native developer experience.

Whatever you choose, understand the trade-offs, embrace the idioms of your stack - and don't let your persistence framework dictate your architecture.

Reviewed by: Rafał Maciak, Emil Bartnik, Rafał Wokacz