Resposta rápida: o Exposed é o framework SQL oficial da JetBrains para Kotlin e funciona como um meio-termo entre um ORM completo e SQL puro. Ele oferece duas APIs: a DSL, type-safe e parecida com SQL (melhor para queries, joins e relatórios), e o DAO, orientado a objetos (melhor para CRUD e domínios simples). Use Exposed quando o projeto é Kotlin-first, especialmente com Ktor, quando você quer queries verificadas em tempo de compilação e integração natural com coroutines. Continue no Hibernate/JPA se o projeto já é Spring Data com repositórios, cache de segundo nível e mapeamentos legados — nesse cenário a migração raramente compensa.

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. Para uma visão mais aplicada com PostgreSQL, pool de conexões e migrations, leia também Kotlin com PostgreSQL no backend.

Exposed em 30 segundos

PerguntaResposta curta
Quem mantém?JetBrains, os mesmos criadores do Kotlin
É um ORM?Parcialmente: DSL é um query builder type-safe; DAO é a camada ORM
Roda com Ktor?Sim, é a combinação mais comum na comunidade
Roda com Spring Boot?Sim, mas você perde Spring Data Repositories
Suporta coroutines?Sim, via newSuspendedTransaction
Faz migrations sozinho?SchemaUtils.create serve para desenvolvimento; em produção use Flyway ou Liquibase
Vale migrar do Hibernate?Só em projeto novo ou módulo isolado

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] = "[email protected]"
        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] = "[email protected]"
        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 = "[email protected]"
        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.

Exposed com Spring Boot: o que muda

Dá para usar Exposed dentro de uma aplicação Spring Boot, mas com uma troca clara: você abre mão dos JpaRepository e do gerenciamento transacional declarativo do Spring Data. Duas abordagens funcionam bem:

  1. Exposed isolado na camada de repositório — o Spring cuida de injeção, configuração e web; o acesso a dados fica em classes que abrem transaction { } explicitamente. É o caminho mais previsível.
  2. Exposed integrado ao PlatformTransactionManager — possível com o módulo exposed-spring-boot-starter, que conecta o Exposed ao @Transactional.

Misturar Hibernate e Exposed no mesmo DataSource é tecnicamente possível, mas evite: dois mecanismos de transação sobre a mesma conexão criam bugs difíceis de reproduzir. Se a decisão de framework ainda está aberta, leia a comparação entre Ktor e Spring Boot.

Migrations: por que SchemaUtils.create não basta

SchemaUtils.create(Usuarios, Pedidos) é ótimo em desenvolvimento e em testes, mas insuficiente em produção. Ele cria tabelas que não existem, porém não versiona alterações: renomear coluna, alterar tipo, criar índice ou popular dados exige histórico.

O padrão mais seguro é:

  • Flyway ou Liquibase como fonte da verdade do schema, com SQL versionado;
  • Exposed como camada de acesso, refletindo o schema já migrado;
  • SchemaUtils.createMissingTablesAndColumns restrito a ambientes locais e testes.
fun main() {
    val dataSource = HikariDataSource(hikariConfig())

    Flyway.configure()
        .dataSource(dataSource)
        .locations("classpath:db/migration")
        .load()
        .migrate()

    Database.connect(dataSource)
}

Uma dica prática é manter um teste que compare as definições do Exposed com o banco migrado. Assim você descobre divergências entre código e migration antes do deploy.

Testando código Exposed

Existem três estratégias, da mais rápida à mais fiel:

EstratégiaVelocidadeFidelidadeQuando usar
H2 em memóriaMuito altaBaixaCRUD básico
PostgreSQL via TestcontainersMédiaAltaQueries reais, índices e tipos nativos
Banco compartilhado de stagingBaixaAltaÚltimo recurso; evite em CI

O risco do H2 é sutil: ele diverge do PostgreSQL em tipos jsonb, array, funções de data e comportamento de upsert. Para repositórios com queries não triviais, prefira Testcontainers.

class UsuarioRepositoryTest : FunSpec({
    val postgres = PostgreSQLContainer("postgres:16-alpine")

    beforeSpec {
        postgres.start()
        Database.connect(
            url = postgres.jdbcUrl,
            user = postgres.username,
            password = postgres.password,
        )
        transaction { SchemaUtils.create(Usuarios) }
    }

    afterSpec { postgres.stop() }

    test("insere e recupera usuário ativo") {
        transaction {
            Usuarios.insert {
                it[nome] = "Ana"
                it[email] = "[email protected]"
                it[criadoEm] = LocalDateTime.now()
            }
            Usuarios.selectAll().count() shouldBe 1
        }
    }
})

O exemplo usa Kotest, mas funciona com JUnit 5. Não mocke o Exposed: teste o repositório contra um banco efêmero e mocke apenas a camada acima.

Erros comuns com Exposed

No transaction in context

Acontece quando a query é executada fora de transaction { } ou o resultado sai do bloco de forma preguiçosa:

// Errado: Query é lazy e a transação fecha antes do consumo
fun listar() = transaction { Usuarios.selectAll() }

// Certo: materializa o resultado dentro da transação
fun listar(): List<UsuarioDTO> = transaction {
    Usuarios.selectAll().map { it.toUsuarioDTO() }
}

Entidades DAO acessadas fora da transação

O DAO faz lazy loading. Converta a entidade para uma data class de resposta dentro do bloco transacional, antes de devolvê-la à camada web.

Bloqueio de thread com coroutines

transaction { } é bloqueante. Em um handler suspend do Ktor, use newSuspendedTransaction(Dispatchers.IO) para não ocupar uma thread do event loop.

N+1 silencioso no DAO

Iterar entidades e acessar relacionamentos pode gerar uma query por item. Use eager loading ou reescreva a consulta em DSL com innerJoin.

Column 'x' not found

Quase sempre indica divergência entre a definição Kotlin e o schema depois de uma migration. Um teste de schema no CI previne esse problema.

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. Nunca devolva entidades DAO para fora da transação — converta para DTO no mesmo bloco
  6. Versione o schema com Flyway ou Liquibase, deixando SchemaUtils para desenvolvimento
  7. Teste com Testcontainers quando usar recursos específicos do banco
  8. Fixe a versão do Exposed, porque a API evolui entre releases

Perguntas frequentes sobre Exposed ORM

Exposed é um ORM de verdade?

Depende da API. A DSL é um query builder type-safe, mais próxima de SQL; o DAO é uma camada ORM com entidades e lazy loading. Por isso a JetBrains o chama de framework SQL leve.

Exposed é mais rápido que Hibernate?

Em operações simples ele pode ter menos overhead, pois não mantém um contexto de persistência com dirty checking. Porém índices, N+1 e pool de conexões pesam mais que a escolha do framework. Decida pela manutenção, não por microbenchmark.

Exposed funciona com R2DBC?

O Exposed usa JDBC, que é bloqueante. Com coroutines, use newSuspendedTransaction em um dispatcher de I/O. Se o requisito for uma stack totalmente non-blocking, avalie R2DBC com outra biblioteca.

Exposed serve para Android?

Não é a escolha natural. Em Android, Room integra melhor com Jetpack, Flow e o ciclo de vida. Exposed é voltado principalmente a backend na JVM.

Exposed suporta PostgreSQL, MySQL e SQLite?

Sim. O framework suporta os principais bancos com driver JDBC, incluindo PostgreSQL, MySQL, MariaDB, SQLite, H2, Oracle e SQL Server. Recursos específicos, como jsonb, podem exigir tipos customizados.

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ê está usando Exposed dentro de uma API web, veja também o tutorial de Ktor com Exposed para conectar routing, serialização e persistência no mesmo projeto. 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.