Quando se fala em acesso a banco de dados no ecossistema Kotlin, o Hibernate costuma ser a primeira opção por causa da integração com Spring Boot. Mas existe uma alternativa 100% Kotlin, criada pela própria JetBrains: o Exposed. Neste tutorial, vamos explorar como ele funciona, suas duas abordagens (DSL e DAO), e por que ele pode ser a melhor escolha para o seu próximo projeto.

O que é o Exposed?

O Exposed é um framework SQL leve para Kotlin, mantido pela JetBrains. Ele oferece duas formas de trabalhar com bancos de dados:

  • DSL (Domain Specific Language): escrita de queries com sintaxe type-safe, parecida com SQL
  • DAO (Data Access Object): mapeamento objeto-relacional no estilo ORM tradicional

A grande vantagem? Tudo é escrito em Kotlin puro, com inferência de tipos, null safety e suporte nativo a coroutines.

Configuração do projeto

Adicione as dependências no build.gradle.kts:

dependencies {
    // Exposed core
    implementation("org.jetbrains.exposed:exposed-core:0.56.0")
    implementation("org.jetbrains.exposed:exposed-dao:0.56.0")
    implementation("org.jetbrains.exposed:exposed-jdbc:0.56.0")
    implementation("org.jetbrains.exposed:exposed-java-time:0.56.0")

    // Driver do banco (exemplo com PostgreSQL)
    implementation("org.postgresql:postgresql:42.7.4")

    // Connection pool
    implementation("com.zaxxer:HikariCP:6.2.1")
}

Definindo tabelas com DSL

No Exposed, tabelas são objetos Kotlin que herdam de Table:

import org.jetbrains.exposed.sql.Table
import org.jetbrains.exposed.sql.javatime.datetime

object Usuarios : Table("usuarios") {
    val id = integer("id").autoIncrement()
    val nome = varchar("nome", 100)
    val email = varchar("email", 255).uniqueIndex()
    val ativo = bool("ativo").default(true)
    val criadoEm = datetime("criado_em")

    override val primaryKey = PrimaryKey(id)
}

object Pedidos : Table("pedidos") {
    val id = integer("id").autoIncrement()
    val usuarioId = integer("usuario_id").references(Usuarios.id)
    val total = decimal("total", 10, 2)
    val status = varchar("status", 50)
    val criadoEm = datetime("criado_em")

    override val primaryKey = PrimaryKey(id)
}

Note como as colunas são type-safe — o compilador garante que você não vai comparar um integer com um varchar por engano. Isso é algo que frameworks ORM em linguagens como Go também buscam alcançar, mas o sistema de tipos do Kotlin torna a experiência muito mais fluida.

Conectando ao banco

import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.transactions.transaction

fun inicializarBanco() {
    val config = HikariConfig().apply {
        jdbcUrl = "jdbc:postgresql://localhost:5432/meu_app"
        driverClassName = "org.postgresql.Driver"
        username = "postgres"
        password = "senha_segura"
        maximumPoolSize = 10
    }

    Database.connect(HikariDataSource(config))

    // Criar tabelas automaticamente
    transaction {
        SchemaUtils.create(Usuarios, Pedidos)
    }
}

Operações CRUD com DSL

Create — Inserindo registros

import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.transactions.transaction
import java.time.LocalDateTime

transaction {
    Usuarios.insert {
        it[nome] = "João Silva"
        it[email] = "joao@email.com"
        it[ativo] = true
        it[criadoEm] = LocalDateTime.now()
    }
}

Para inserir e obter o ID gerado:

val novoId = transaction {
    Usuarios.insertAndGetId {
        it[nome] = "Maria Santos"
        it[email] = "maria@email.com"
        it[criadoEm] = LocalDateTime.now()
    }
}

Read — Consultando dados

import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.select

// Todos os usuários ativos
val usuariosAtivos = transaction {
    Usuarios.selectAll()
        .where { Usuarios.ativo eq true }
        .map { row ->
            UsuarioDTO(
                id = row[Usuarios.id],
                nome = row[Usuarios.nome],
                email = row[Usuarios.email]
            )
        }
}

// Busca com join
val pedidosComUsuario = transaction {
    (Pedidos innerJoin Usuarios)
        .selectAll()
        .where { Pedidos.status eq "PENDENTE" }
        .map { row ->
            PedidoResumo(
                pedidoId = row[Pedidos.id],
                nomeUsuario = row[Usuarios.nome],
                total = row[Pedidos.total]
            )
        }
}

Update — Atualizando registros

import org.jetbrains.exposed.sql.update

transaction {
    Usuarios.update({ Usuarios.id eq 1 }) {
        it[nome] = "João Silva Junior"
        it[ativo] = false
    }
}

Delete — Removendo registros

import org.jetbrains.exposed.sql.deleteWhere

transaction {
    Pedidos.deleteWhere { Pedidos.status eq "CANCELADO" }
}

Abordagem DAO — Estilo ORM

Se você prefere trabalhar com objetos no estilo ORM tradicional, o Exposed oferece a camada DAO:

import org.jetbrains.exposed.dao.IntEntity
import org.jetbrains.exposed.dao.IntEntityClass
import org.jetbrains.exposed.dao.id.EntityID
import org.jetbrains.exposed.dao.id.IntIdTable

// Tabela precisa usar IntIdTable para DAO
object UsuariosTable : IntIdTable("usuarios") {
    val nome = varchar("nome", 100)
    val email = varchar("email", 255).uniqueIndex()
    val ativo = bool("ativo").default(true)
    val criadoEm = datetime("criado_em")
}

class Usuario(id: EntityID<Int>) : IntEntity(id) {
    companion object : IntEntityClass<Usuario>(UsuariosTable)

    var nome by UsuariosTable.nome
    var email by UsuariosTable.email
    var ativo by UsuariosTable.ativo
    var criadoEm by UsuariosTable.criadoEm
}

Com DAO, as operações são mais orientadas a objetos:

transaction {
    // Criar
    val usuario = Usuario.new {
        nome = "Pedro Costa"
        email = "pedro@email.com"
        ativo = true
        criadoEm = LocalDateTime.now()
    }

    // Ler
    val encontrado = Usuario.findById(1)
    val ativos = Usuario.find { UsuariosTable.ativo eq true }

    // Atualizar
    encontrado?.nome = "Pedro Costa Junior"

    // Deletar
    encontrado?.delete()
}

DSL vs DAO: Quando usar cada um?

AspectoDSLDAO
EstiloFuncional, parecido com SQLOrientado a objetos
PerformanceLigeiramente mais rápidoOverhead do mapeamento
Queries complexasExcelenteLimitado
CRUD simplesVerbosoConciso
Lazy loadingNãoSim
Melhor paraRelatórios, queries complexasCRUDs, domínios ricos

Na prática, muitos projetos combinam as duas abordagens — DAO para operações simples e DSL para queries complexas.

Integração com Ktor

O Exposed combina perfeitamente com Ktor, o framework web da JetBrains:

import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import org.jetbrains.exposed.sql.transactions.transaction

fun Application.configurarRotas() {
    routing {
        get("/usuarios") {
            val usuarios = transaction {
                Usuarios.selectAll()
                    .where { Usuarios.ativo eq true }
                    .map { row ->
                        mapOf(
                            "id" to row[Usuarios.id],
                            "nome" to row[Usuarios.nome],
                            "email" to row[Usuarios.email]
                        )
                    }
            }
            call.respond(usuarios)
        }
    }
}

Para projetos maiores com Spring Boot, confira nosso tutorial completo de Kotlin com Spring Boot.

Transações e tratamento de erros

O Exposed exige que todas as operações de banco sejam executadas dentro de um bloco transaction. Isso garante atomicidade:

import org.jetbrains.exposed.sql.transactions.transaction

try {
    transaction {
        // Todas as operações aqui são atômicas
        val pedidoId = Pedidos.insertAndGetId {
            it[usuarioId] = 1
            it[total] = 299.90.toBigDecimal()
            it[status] = "CRIADO"
            it[criadoEm] = LocalDateTime.now()
        }

        Usuarios.update({ Usuarios.id eq 1 }) {
            it[ativo] = true
        }

        // Se algo falhar aqui, tudo acima é revertido
    }
} catch (e: Exception) {
    println("Erro na transação: ${e.message}")
}

Para operações assíncronas com coroutines e Flow, o Exposed oferece newSuspendedTransaction:

import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction

suspend fun buscarUsuarioAsync(id: Int): UsuarioDTO? {
    return newSuspendedTransaction {
        Usuarios.selectAll()
            .where { Usuarios.id eq id }
            .firstOrNull()
            ?.let {
                UsuarioDTO(it[Usuarios.id], it[Usuarios.nome], it[Usuarios.email])
            }
    }
}

Exposed vs Hibernate/JPA

Se você vem do mundo Java, provavelmente já usou Hibernate. Veja como o Exposed se compara:

AspectoExposedHibernate/JPA
LinguagemKotlin nativoJava (funciona em Kotlin)
ConfiguraçãoMínima, tudo em códigoXML ou anotações extensas
Type safetyTotal (verificado em compilação)Parcial (JPQL é string)
Curva de aprendizadoBaixa para devs KotlinModerada a alta
EcossistemaCrescenteMaduro e vasto
Null safetyIntegradoRequer cuidado extra
CoroutinesSuporte nativoRequer adaptação

Boas práticas

  1. Use HikariCP para connection pooling — nunca conecte diretamente ao banco em produção
  2. Prefira DSL para queries complexas — a type safety evita erros em runtime
  3. Separe definições de tabelas em um pacote database.tables
  4. Use newSuspendedTransaction em projetos com coroutines para não bloquear threads
  5. Teste com banco em memória (H2) para testes unitários rápidos — veja nosso guia de testes em Kotlin

Conclusão

O Exposed é uma excelente alternativa ao Hibernate para projetos Kotlin. Com sua DSL type-safe, suporte a coroutines e manutenção pela JetBrains, ele oferece uma experiência de desenvolvimento muito mais natural para quem trabalha com Kotlin.

Se você está começando um novo projeto backend com Ktor ou quer uma alternativa mais leve para o Spring Boot, o Exposed merece um lugar na sua lista de ferramentas.

Para continuar aprendendo, explore nosso Guia de Kotlin para Backend e o Glossário de Kotlin.

Se você trabalha com múltiplas linguagens no backend, vale comparar abordagens de acesso a banco: Python tem o SQLAlchemy como ORM de referência, enquanto Rust aposta no Diesel e SQLx para acesso type-safe a bancos.