Hibernate vs. Exposed: Choosing Kotlin’s Best Persistence Tool - Round 1
Introduction
Persistence and data storage are crucial concepts in IT systems. Throughout my career, I've encountered far more stateful applications than purely stateless ones. The sheer number of different databases, each serving a different purpose, seems to support that.
Digging deeper, among all available databases, relational ones still dominate in terms of popularity. According to the latest db-engines.com ranking, relational databases hold nearly 72% of the market share based on popularity.
With that in mind, it's clear how important it is to have a solid, reliable, high-performance, and easy-to-use SQL persistence framework in your toolbox.
Each programming language has its own battle-tested SQL frameworks. In this article, I'll compare two very popular - yet fundamentally different - JVM SQL frameworks and their use in Kotlin projects.
Please welcome:
- Hibernate - a Java persistence framework for relational databases,
- Exposed - a lightweight, type-safe SQL persistence framework designed for Kotlin.
What a showdown it's going to be! It's time to start the first round.
Philosophy & Design Goals
Before you pick a persistence framework for your next project, you will first need to find answers to several questions. One of the less obvious might be: "What's the philosophy behind a particular framework?"
That may not sound like an obvious driver when choosing a framework, but surprisingly, it can save you a lot of problems in the long run. The framework's philosophy is about the purpose of its existence, what problems it solves (and what it doesn't), but also how it approaches solving them. This knowledge might help you when combined with your understanding of your upcoming project and awareness of your (or your developers') skill set and mindset when dealing with SQL integrations.
Hibernate
Hibernate is a persistence framework fully based on the ORM (object-relational mapping) concept. Its main goal is to hide the complexity of SQL behind Java (or, in our case, Kotlin) classes. It strives to offer developers the possibility to invoke complex interactions with the database by performing simple operations on plain Java or Kotlin objects. There is even this motto on the project's website: “Relational persistence for idiomatic Java”. Thus, the whole complexity is hidden under the hood, in the framework's code.
Hibernate, however, does give developers the capability to interact with the underlying database in a more direct and controlled manner by offering a special query dialect called HQL (more about it in the next article in the series). This is, however, still an abstraction layer over pure SQL and still hides the real complexity from developers.
Another significant aspect of Hibernate's design principles is its compatibility with JPA (Jakarta Persistence, formerly known as Java Persistence API). Hibernate even once influenced the shape of JPA, and now it's one of the most popular implementations of this standard. That means that Hibernate can be used whenever JPA is required, but on the other hand, it also means that it needs to follow the standard and consistently provide the functionality and API that are part of JPA. That means Hibernate developers might not have as much freedom and flexibility when developing the framework as they could have if not conforming to a specific, third-party standard.
When discussing Hibernate's philosophy, it's hard not to mention its compatibility with a wide range of databases. At the time of this writing, Hibernate maintainers claim that it's compatible with (and tested on) PostgreSQL, MySQL, Db2, Oracle, SQL Server, Sybase ASE, EDB, TiDB, MariaDB, HANA, CockroachDB, H2, and HSQLDB.
One of the main selling points of Hibernate (and the object-relational mapping concept as a whole) is the database portability. So, apart from a wide range of supported databases, Hibernate also claims that it can help you run your application against any number of databases without changes to your code, and ideally without any changes to the mapping metadata.
Exposed
The creators of Exposed call it an ORM framework for Kotlin, but also a lightweight SQL library on top of a JDBC driver for Kotlin. And while I find both those definitions accurate, for me personally, the second one better describes the main philosophy behind Exposed.
The main and default way of interacting with databases in Exposed is its DSL, which is like type-safe SQL for Kotlin. You can also include an optional module for lightweight ORM and use it on top of the same model which you defined for the basic DSL queries. This gives you, as a developer, the flexibility to choose the database interaction model that better suits your needs.
What is important to mention - no matter which style you choose (DSL or ORM) - Exposed is still "just" a relatively thin wrapper over JDBC, which results in one very crucial trait: it gives you more control over the actual queries that will be invoked and is more transparent about the actual database interactions.
Another crucial design principle behind the Exposed framework is its ability to work with many different relational databases. Currently, the Exposed team declares support for the following databases: H2, MariaDB, MySQL, Oracle, PostgreSQL, Microsoft SQL Server, and SQLite.
Last but not least - Exposed is a framework written purely in and for Kotlin, maintained by JetBrains - creators of Kotlin. This shows a strong commitment to Kotlin idioms, semantics, and syntax as a foundational design principle.
Getting Started & Setup
The first thing you have to do to work with a framework is set it up and configure it. Usually, it's a one-time job; however, if you work with microservices, you might need to repeat the setup more than once.
In this section, I will give you some simple examples of how to set up both Hibernate and Exposed in a Kotlin project in three different, popular ecosystems:
- plain JVM
- Spring
- Ktor
Usually, there are a lot of different configuration options for each tool in each of these ecosystems. So apart from the simple examples showing the actual code, I'm also going to give you an idea of what the more advanced or alternative ways of configuring a given tool are.
Enough talking - let's dive straight into the code!
Plain JVM project - Hibernate
To include Hibernate in your Kotlin project, you'll first need to declare the proper dependencies in your build.gradle.kts
file. You'll need at least a dependency on hibernate-core
, and also a database driver (PostgreSQL in this case):
dependencies {
implementation("org.hibernate.orm:hibernate-core:6.6.13.Final")
implementation("org.postgresql:postgresql:42.7.5")
}
Hibernate offers many additional modules specified as optional dependencies. The full list can be found in the Hibernate ORM Modules section of the Getting Started guide.
Next, you'll have to define some connection details and other properties. The default way for a native JVM application is to specify them in a hibernate.properties
file, which needs to be located on the classpath (in the src/main/resources directory in this case):
hibernate.connection.url=jdbc:postgresql://localhost:5432/boardgames
hibernate.connection.username=bgamer
hibernate.connection.password=pass
hibernate.hbm2ddl.auto=create
These are the basic connection (and DDL generation mode) options. The full list of configuration options is available in the Appendix to the Hibernate User Guide.
The final step is to create a SessionFactory
in code, which will allow you to make actual database connections:
fun main() {
val registry = StandardServiceRegistryBuilder().build()
val sessionFactory =
MetadataSources(registry)
.addAnnotatedClass(MyEntity::class.java)
.buildMetadata()
.buildSessionFactory()
Once you have the SessionFactory
, you can use it to create sessions and transactions to interact with the underlying database.
Plain JVM project - Exposed
To use Exposed in a plain Kotlin project, you have to include some dependencies in your build.gradle.kts
file - both for Exposed itself and the database driver (PostgreSQL in our case):
dependencies {
implementation("org.jetbrains.exposed:exposed-core:$exposed_version")
implementation("org.jetbrains.exposed:exposed-jdbc:$exposed_version")
implementation("org.postgresql:postgresql:42.7.5")
}
There are also some optional modules in Exposed. You can find all of them in the Modules section of the Exposed documentation website.
The next and final step of the Exposed setup is to define a database connection. You can do this directly in Kotlin code by calling the Database.connect()
function:
Database.connect(
url = "jdbc:postgresql://localhost:5432/boardgames",
driver = "org.postgresql.Driver",
user = "bgamer",
password = "pass"
)
The Database.connect()
function has quite a few variants, giving you the ability to configure it with various optional parameters. There’s also a version of this function that accepts a javax.sql.DataSource, so you can configure it using more advanced features provided by popular database connection pooling libraries, etc.
And that’s it - Exposed is ready to interact with your database.
Spring - Hibernate
Hibernate with Spring (Boot) is a very popular setup, seen in many enterprise applications. You can find plenty of materials on the web showing how to configure it. Anyway, I thought it was worth including here, just to have the full picture.
Let’s start, as usual, by setting up Gradle dependencies:
dependencies {
implementation(platform("org.springframework.boot:spring-boot-starter-parent:3.4.4"))
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.postgresql:postgresql:42.7.5")
}
Once you have that, you can configure various data sources and Hibernate properties in the application.yaml
or application.properties
file:
spring:
datasource:
url: "jdbc:postgresql://localhost:5432/boardgames"
username: bgamer
password: pass
jpa:
hibernate:
ddl-auto: create
There are a lot of other properties that you can configure for your data source. To find out some of them, please refer to the appendix listing common application properties in the official Spring Boot documentation. Just look for the properties with the spring.datasource
prefix.
From now on, a SessionFactory
can be injected into your persistence layer to interact with the database. As you may know, Spring offers some higher-level APIs for database interactions than a bare SessionFactory
, but that will be covered in the next article in the series.
Spring - Exposed
Exposed can be used in Spring applications as well. There is a dedicated Spring Boot starter for Exposed, developed by JetBrains. To benefit from Exposed in your Spring Boot project, you’ll need to include this starter as well as some other dependencies in your build.gradle.kts
file:
dependencies {
implementation(platform("org.springframework.boot:spring-boot-starter-parent:3.4.4"))
implementation("org.jetbrains.exposed:exposed-spring-boot-starter:0.61.0")
implementation("org.postgresql:postgresql:42.7.5")
}
Now, as you might expect, you can configure your database connection via application.yaml
(or .properties
) file. As I mentioned earlier, Exposed can be configured using a regular javax.sql.DataSource
, so the properties should be very familiar to you and would look like this:
spring:
datasource:
url: "jdbc:postgresql://localhost:5432/boardgames"
username: bgamer
password: pass
exposed:
generate-ddl: true
Now you can interact with the Exposed DSL or DAO classes in your application code!
Ktor - Hibernate
When it comes to Spring, I could safely assume it's such a well-known and popular framework that I don't need to introduce it to you. Things might be a bit different for Ktor, however. This article is not dedicated to Ktor, and its goal is not to elaborate on this web framework. However, if you haven't heard about it before but would like to familiarize yourself with it, I'd highly encourage you to read my previous blog post about the top 7 Kotlin frameworks and libraries for backend development. And if you’d like to dive deeper into Ktor itself and its comparison with Spring, I highly recommend you to read two great articles from Rafał Maciak on that topic: Learning Ktor Through a Spring Boot Lens: Part 1 and Part 2.
The first thing you might notice when looking for examples of Ktor and Hibernate integration on the web is that... there really aren't many of them. Simply put, Ktor and Hibernate seem to be such a rare combination that it's hard to find documentation, blog posts, or use cases online. It might be because Hibernate is so tightly coupled with the JPA standard that it feels more like a Spring domain than a Ktor one. It might also be due to (spoiler alert!) the high degree of compatibility between Ktor and Exposed, which you'll learn about in the next section. One way or another, it isn’t a common practice to use Hibernate in Ktor applications. At the time of this writing, Ktor doesn't offer any plugin for Hibernate.
With that in mind, what would I suggest if you want to use Hibernate in your Ktor project? Just refer to the earlier section of this article called "Hibernate in plain JVM project" and follow the instructions there. After all, a Ktor project is also just a JVM project, so the plain Hibernate setup will work there as well. Or... you might want to implement your own Ktor plugin for Hibernate!
Ktor - Exposed
In the previous section, I emphasized the lack of official support and compatibility between Ktor and Hibernate. Well, this section will be the complete opposite. If you'd like to use Exposed in a Ktor project, you're in luck - the two work in total symbiosis. There's an official Exposed plugin for Ktor, available in the Ktor Project Generator. There are also code examples of integrating Exposed with Ktor in the Exposed documentation. Plus, you'll find plenty of articles, blog posts, tutorials, and more on the topic.
Including Exposed in Ktor feels very natural. The easiest way to start is by using the Ktor Project Generator mentioned above, where you can include the Exposed plugin. The generated scaffold of the project will already contain the necessary Gradle dependencies. I'll list them here just for reference:
dependencies {
implementation("org.jetbrains.exposed:exposed-core:$exposed_version")
implementation("org.jetbrains.exposed:exposed-jdbc:$exposed_version")
implementation("com.h2database:h2:$h2_version")
...
}
There will be more dependencies generated, but these are crucial for working with Exposed.
By default Ktor Project Generator will add the dependency on H2 database driver, but you can of course change it to any JDBC driver you’d like.
What you’ll also get in this initial project structure is a Databases
module in a Databases.kt
file, where the database connection is predefined:
fun Application.configureDatabases() {
val database = Database.connect(
url = "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1",
user = "root",
driver = "org.h2.Driver",
password = "",
)
...
}
Again, by default you’ll get the Database
configuration against H2, but this can be changed to match any database you’re using in your project.
You'll most likely also see some more code generated, like a UserService
, but that's just a sample showing how to interact with the DB using the Exposed API, and can be removed or adjusted to your needs.
As you can see, the connection details are hardcoded in the generated code, but nothing stops you from injecting them via properties.
The last step (which is also done for you by the Ktor Generator) is to configure the Database module in the Application. That's done in the Application.kt
file:
fun Application.module() {
...
configureDatabases()
...
}
You may also see some other modules configured there.
That’s it, when it comes to setting up Exposed with Ktor in a Kotlin project.
Mapping & Schema Definition
Every persistence framework needs some data structures to operate on. In a relational database, we have tables, but persistence frameworks rarely interact with raw tables directly. They usually need some abstraction on top. That's no different for Hibernate and Exposed. In this section, I'll walk you through how each framework abstracts database tables and represents your data schema.
Hibernate Entities
Hibernate is an object-relational mapping framework, and it takes that role seriously. While the database tables represent the relational part of the equation, Hibernate ensures that your code works with objects, hiding the underlying database structure. To enable this, Hibernate relies heavily on entities - plain Java (or in our case, Kotlin) objects that represent database tables. The entity is a fundamental concept in Hibernate.
Below is a sample entity representing a database table in a hypothetical board game store:
@Entity
@Table(name = "board_games")
open class BoardGame(
@Column(name = "title", nullable = false)
open var title: String,
@Column(name = "min_players", nullable = false)
open var minPlayers: Int,
@Column(name = "max_players", nullable = false)
open var maxPlayers: Int,
@Column(name = "playtime_minutes", nullable = false)
open var playTimeInMinutes: Int,
@OneToMany(mappedBy = "boardGame", cascade = [CascadeType.ALL], orphanRemoval = true)
open val ratings: MutableSet<Rating> = mutableSetOf(),
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", nullable = false)
open var id: Long? = null
) {
constructor() : this("", 0, 0, 0)
}
One important fact about Hibernate entities is that they're just plain objects. They don't need to extend any specific base classes, or include any other framework-specific code. What makes them Hibernate’s entities are annotations. This is central to Hibernate's philosophy – to stay out of the way of your domain code and avoid enforcing specific inheritance hierarchies or framework-specific constructs (aside from annotations, which are treated more like metadata than actual logic).
It's worth noting that while Kotlin is a very concise language, defining JPA entities requires you to give up some of its syntactic sugar. JPA enforces a few constraints, such as:
- 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.
While these are part of the JPA spec, Hibernate is strict only about the first one. If you omit a no-arg constructor, Hibernate will throw an org.hibernate.InstantiationException
when trying to load the entity from the database.
As for the remaining two rules, even though Hibernate will be able to persist and load a final class with final properties, it won't be able to subclass it, nor override getters/setters. This means Hibernate can't create proxies for lazy loading. So in practice, you still need to mark all Hibernate entities and their properties as open and avoid using Kotlin data classes (which can't be open).
For those who don’t mind a bit of bytecode magic: there are Gradle and Maven plugins that help here. The no-arg plugin (wrapped by the kotlin-jpa plugin) generates no-arg constructors for all matching classes. The all-open plugin (wrapped by kotlin-spring plugin) marks matching classes and their members as open. Both plugins modify the bytecode of your application to achieve their goals.
If you'd like to dive deeper into the topic of Hibernate entities, check out the Domain Model section in the official Hibernate documentation.
Hibernate schema generation
Once you define the entities, Hibernate can generate the database tables for you based on the annotations on the entity classes and their properties. Hibernate has a built-in mechanism that works whether it's used in a plain JVM project or with Spring. Hibernate's automatic schema generation works on several levels:
create
- drop and then recreate the schemacreate_drop
- drop the schema onSessionFactory
shutdown and recreate it onSessionFactory
startupcreate_only
- only create the schema (no dropping)drop
- only drop the schemaupdate
- alter the existing schemavalidate
- just validate the schema against the entitiesnone
- surprisingly, do nothing with the schema
The automatic schema generation can be configured either in the hibernate.config
file using the hibernate.hbm2ddl.auto
property, or in the application.properties
(or .yaml
) file of a Spring project using the jpa.hibernate.ddl-auto
property.
There is a dedicated section on schema generation in the Hibernate User Guide if you'd like to learn more about this topic.
Exposed Tables
Exposed takes a slightly different approach than Hibernate when it comes to the data abstraction layer. Whereas Hibernate relies entirely on entities, Exposed represents database tables as... actual tables.
A table in Exposed is essentially an object that extends either Table
or one of the IdTable
classes. Using various functions provided by these base classes, you can define columns, indexes, and constraints for your table.
Let's see how a table for our board game store might look in Exposed:
object BoardGamesTable: LongIdTable("board_games") {
val title = varchar("name", 256)
val minPlayers = integer("min_players")
val maxPlayers = integer("max_players")
val playTimeInMinutes = integer("playtime_minutes")
}
As you can see, the BoardGamesTable
object contains definitions for all the columns we need from the board_games database
table. The id column is not specified explicitly because it's already declared in the base LongIdTable
class.
You might have noticed that we don't have a ratings
collection here. That's because Exposed tables directly map to the actual database tables in terms of their columns. Since the board_games
- ratings
relationship is one-to-many, the owning side of the relationship is the ratings
table. Therefore, the reference column is defined there.
Let me show you the RatingsTable
definition so you can see how the relationship is specified:
object RatingsTable: LongIdTable("ratings") {
val rating = integer("rating")
val comment = varchar("comment", 256).nullable()
val ratingDate = timestamp("rating_date")
val boardGameId = reference("board_game_id", BoardGamesTable)
}
The boardGameId
property represents the board_game_id
column and references the BoardGamesTable.id
property.
You can read more about Exposed tables in the corresponding section of the Exposed official documentation.
Exposed DAO
While the default and most basic way of interacting with database tables in Exposed is through Table
objects, there's also an optional module that turns Exposed into a lightweight ORM. Let's now take a look at the Exposed DAO module.
Thanks to the DAO module, you can define entities that map database table records to Kotlin objects. Entities provide an additional abstraction layer over Exposed IdTables
. This means you still need the Tables you saw in the previous section, and on top of them, you define corresponding entities.
Let's see how our BoardGamesTable
can be further abstracted with an entity:
class BoardGame(id: EntityID<Long>): LongEntity(id) {
companion object: LongEntityClass<BoardGame>(BoardGamesTable)
var title by BoardGamesTable.title
var minPlayers by BoardGamesTable.minPlayers
var maxPlayers by BoardGamesTable.maxPlayers
var playTimeInMinutes by BoardGamesTable.playTimeInMinutes
val ratings by Rating referrersOn RatingsTable.boardGameId
}
Okay, so what's happening in the code above? Let's break it down.
We have a BoardGame
class that extends the org.jetbrains.exposed.dao.LongEntity
base class.
Its companion object, of type LongEntityClass
, is responsible for providing convenient functions for CRUD operations on entities of this type (which I'll cover in the next article in the series). It also binds the entity class to the corresponding Exposed Table object.
The properties of the entity class represent the columns of the underlying table, implemented using the by
keyword. This means that the BoardGame
properties delegate to the BoardGamesTable
properties.
This time, however, we're not limited to just the columns of the underlying table. As you might have noticed, the BoardGame
entity includes the ratings
property, which represents the one-to-many relationship between the BoardGame
and Rating
entities. Keep in mind that such a property, wrapped with the referrersOn
function, must be immutable - so it should be declared as val
, not var
. Other properties can be declared as either val
or var
, depending on whether you want them to be immutable or mutable.
The Exposed official documentation has a full deep-dive section dedicated to DAOs and entities, which you should explore.
Exposed schema generation
As befits a solid persistence framework, Exposed allows you not only to model an existing database schema in your code, but also to generate the schema based on your classes.
To do that, you can use the SchemaUtils
object, which contains a bunch of useful functions for manipulating the database schema.
The most common use case is creating specific tables in the underlying database:
SchemaUtils.create(BoardGamesTable, RatingsTable)
In addition to that, there are also other functions for various purposes - such as dropping tables, creating indexes, adding constraints, generating the entire schema, validating it, searching for cycles in table relationships, and many more.
If you're interested in more details, check out the Exposed documentation on working with schemas. You can also explore the SchemaUtils
object directly to see what functions it provides.
Summary: Hibernate or Exposed?
I believe that it has been quite a deep dive. I hope you’ve learned many interesting and useful things from this read. Let's briefly recap what you've read.
Philosophy & Design Goals
Hibernate is a mature, full-blown ORM with deep JPA roots and broad database compatibility. Its philosophy is about hiding SQL behind Java/Kotlin objects.
Exposed, on the other hand, is Kotlin-native, focused on being lightweight and transparent. It gives you more control and visibility into the actual SQL being executed.
Setup & Ecosystem Fit
Hibernate shines in JPA-heavy ecosystems - like Spring - with robust tooling and widespread adoption. But it feels a bit awkward in Ktor or plain JVM setups.
Exposed, in contrast, integrates naturally with Ktor and works well in minimalistic or Kotlin-first stacks. Spring support is there too, though arguably a bit more niche.
Mapping & Schema Definition
Hibernate's JPA model doesn’t feel very natural when combined with Kotlin (e.g., no-arg constructors, open classes), which may clash with idiomatic Kotlin code.
Exposed's DSL and DAO modules stay close to Kotlin's strengths - though DAO entities can become tightly coupled to persistence logic.
The verdict? It is still far too early for that! More parts of the series are coming soon to our blog. Look forward to them.
Reviewed by: Rafał Maciak, Emil Bartnik, Rafał Wokacz