Usar Kotlin com PostgreSQL é uma das combinações mais fortes para quem quer trabalhar com backend JVM em 2026. Kotlin entrega uma linguagem concisa, segura contra nulos e muito produtiva; PostgreSQL entrega um banco relacional maduro, confiável, com ótimo suporte a índices, JSONB, transações e extensões. Juntos, eles aparecem com frequência em APIs internas, fintechs, e-commerce, plataformas SaaS e vagas de backend Kotlin no Brasil.

Este tutorial mostra um caminho prático para sair do “consigo conectar no banco” e chegar em uma estrutura que dá para evoluir: schema organizado, conexão com pool, acesso via Exposed, migrations, transações, testes e decisões de produção. Se você está estudando backend, leia também o roadmap para desenvolvedor backend Kotlin, o guia de Ktor e o tutorial de Spring Boot com Kotlin.

Quando escolher PostgreSQL em um backend Kotlin?

PostgreSQL é uma boa escolha quando seus dados têm relacionamento claro, precisam de consistência e serão consultados por filtros variados. Usuários, pedidos, pagamentos, permissões, auditoria, assinaturas e catálogos são exemplos clássicos. Mesmo quando o produto cresce, o PostgreSQL continua competitivo porque oferece transações ACID, constraints, índices parciais, views, funções, JSONB e extensões como pg_trgm para busca textual.

Em Kotlin, ele combina bem com três estilos comuns:

  • Ktor + Exposed para APIs leves e idiomáticas;
  • Spring Boot + Spring Data JPA para sistemas corporativos com ecossistema amplo;
  • jOOQ quando o time quer SQL explícito e geração de código type-safe.

Não existe uma única resposta certa. Para aprender e criar APIs enxutas, Ktor com Exposed é direto. Para times que já usam Spring, Spring Boot reduz atrito. Para domínios com SQL complexo, jOOQ pode ser mais transparente que um ORM tradicional.

Dependências básicas com Gradle Kotlin DSL

O exemplo abaixo usa Exposed com JDBC e HikariCP. É um ponto de partida simples para uma API Ktor ou um serviço Kotlin puro:

plugins {
    kotlin("jvm") version "2.3.20"
    application
}

repositories {
    mavenCentral()
}

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

    implementation("org.postgresql:postgresql:42.7.4")
    implementation("com.zaxxer:HikariCP:6.2.1")

    testImplementation(kotlin("test"))
}

Se você está usando Ktor, combine isso com ContentNegotiation, kotlinx.serialization e rotas HTTP. Se está usando Spring Boot, talvez prefira spring-boot-starter-data-jpa ou spring-jdbc, mas os conceitos de schema, migrations, transações e testes continuam os mesmos.

Configurando o pool de conexões

Nunca abra uma nova conexão manualmente a cada request. Em produção, use um pool. O HikariCP é o padrão de fato no ecossistema JVM e funciona bem com Kotlin:

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

fun conectarPostgres() {
    val config = HikariConfig().apply {
        jdbcUrl = System.getenv("DATABASE_URL")
            ?: "jdbc:postgresql://localhost:5432/app"
        username = System.getenv("DATABASE_USER") ?: "app"
        password = System.getenv("DATABASE_PASSWORD") ?: "app"
        maximumPoolSize = 10
        minimumIdle = 2
        isAutoCommit = false
        transactionIsolation = "TRANSACTION_REPEATABLE_READ"
        validate()
    }

    Database.connect(HikariDataSource(config))
}

O detalhe importante é tratar configuração como ambiente, não como código. DATABASE_URL, usuário e senha devem vir do ambiente de deploy ou do gerenciador de secrets. Em desenvolvimento local, um docker-compose.yml com PostgreSQL resolve. Em produção, prefira um serviço gerenciado quando o time não quer operar backup, replicação e atualizações manualmente.

Modelando tabelas com Exposed

Exposed representa tabelas como objetos Kotlin. Isso dá autocompletar, tipos explícitos e menos strings soltas espalhadas pelo código:

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

object Clientes : Table("clientes") {
    val id = uuid("id")
    val nome = varchar("nome", 160)
    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 = uuid("id")
    val clienteId = uuid("cliente_id").references(Clientes.id)
    val status = varchar("status", 40)
    val totalCentavos = long("total_centavos")
    val criadoEm = datetime("criado_em")

    override val primaryKey = PrimaryKey(id)
}

Mesmo usando DSL, pense primeiro no banco. Defina chaves primárias, chaves estrangeiras, constraints, índices e nomes estáveis. Kotlin ajuda na camada de aplicação, mas a integridade dos dados deve continuar protegida no PostgreSQL. Um bug em uma rota não pode ser capaz de gravar pedido sem cliente ou e-mail duplicado se o banco estiver modelado corretamente.

CRUD simples com transações

Toda operação que lê e escreve dados relacionados deve estar dentro de uma transação. Com Exposed, o bloco transaction deixa isso explícito:

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

data class NovoCliente(val nome: String, val email: String)

fun criarCliente(input: NovoCliente): UUID = transaction {
    val id = UUID.randomUUID()

    Clientes.insert {
        it[Clientes.id] = id
        it[nome] = input.nome.trim()
        it[email] = input.email.lowercase().trim()
        it[ativo] = true
        it[criadoEm] = LocalDateTime.now()
    }

    id
}

fun listarClientesAtivos(): List<NovoCliente> = transaction {
    Clientes
        .selectAll()
        .where { Clientes.ativo eq true }
        .map { row ->
            NovoCliente(
                nome = row[Clientes.nome],
                email = row[Clientes.email]
            )
        }
}

Para APIs Ktor, chame funções de repositório a partir das rotas e mantenha validação de entrada antes da transação. Para Spring Boot, encapsule regras em services e use @Transactional quando fizer sentido. Em qualquer stack, evite misturar regra de negócio complexa diretamente no handler HTTP.

Migrations: não crie schema no startup

É tentador usar SchemaUtils.create() em todo boot. Isso serve para protótipos, mas não para produção. Em um sistema real, mudanças no banco precisam ser versionadas. Use Flyway ou Liquibase para manter um histórico claro:

-- V1__criar_clientes_e_pedidos.sql
CREATE TABLE clientes (
    id UUID PRIMARY KEY,
    nome VARCHAR(160) NOT NULL,
    email VARCHAR(255) NOT NULL UNIQUE,
    ativo BOOLEAN NOT NULL DEFAULT true,
    criado_em TIMESTAMP NOT NULL
);

CREATE TABLE pedidos (
    id UUID PRIMARY KEY,
    cliente_id UUID NOT NULL REFERENCES clientes(id),
    status VARCHAR(40) NOT NULL,
    total_centavos BIGINT NOT NULL,
    criado_em TIMESTAMP NOT NULL
);

CREATE INDEX idx_pedidos_cliente_id ON pedidos(cliente_id);
CREATE INDEX idx_pedidos_status ON pedidos(status);

Migrations são especialmente importantes quando há mais de uma instância da aplicação rodando, quando o deploy é automatizado ou quando você precisa auditar por que uma coluna mudou. Elas também ajudam entrevistas e portfólio: demonstram que você entende ciclo de vida de banco, não apenas código Kotlin.

Índices e consultas que importam

Um erro comum é criar índice em toda coluna. Índice acelera leitura, mas custa escrita e espaço. Comece pelos filtros reais da aplicação:

  • email único para login ou identificação;
  • chaves estrangeiras usadas em joins;
  • status quando filas operacionais filtram pedidos pendentes;
  • criado_em quando telas listam eventos recentes;
  • índices compostos quando a consulta filtra por mais de uma coluna.

Use EXPLAIN ANALYZE quando uma query ficar lenta. Em Kotlin, a abstração não elimina a necessidade de entender SQL. Se uma tela lista os últimos pedidos de um cliente, um índice em (cliente_id, criado_em DESC) pode ser mais útil que dois índices separados.

Testando com PostgreSQL real

Para lógica de domínio pura, testes unitários bastam. Para repositórios, teste contra PostgreSQL real sempre que possível. SQLite em memória não se comporta igual: tipos, constraints, JSONB, locks e funções diferem. Com Testcontainers, você sobe um PostgreSQL descartável no teste e valida migrations, queries e transações.

Mesmo que você não use Testcontainers no começo, pelo menos rode uma suíte local contra Docker antes de publicar mudanças importantes. Bugs de banco costumam aparecer tarde: timezone errado, coluna nullable sem querer, índice ausente, transação longa demais, deadlock por ordem inconsistente de atualização.

Checklist para produção

Antes de colocar um backend Kotlin com PostgreSQL no ar, confira:

  • migrations versionadas e revisadas;
  • pool de conexões com tamanho compatível com o banco;
  • timeouts configurados para queries e requests;
  • logs sem senha, token ou dados sensíveis;
  • backup e restore testados;
  • índices para consultas críticas;
  • transações curtas;
  • validação de entrada antes de gravar;
  • métricas de latência do banco e erros SQL;
  • secrets fora do repositório.

Esse checklist parece operacional, mas impacta diretamente a carreira. Muitas vagas de backend Kotlin pedem Spring Boot, Ktor, PostgreSQL, mensageria, Docker e cloud no mesmo pacote. Saber conectar esses pontos torna seu perfil mais forte do que apenas decorar sintaxe da linguagem.

Próximos passos

Se você quer evoluir este tema, construa uma API pequena com cadastro de clientes, pedidos e autenticação. Comece com Ktor e APIs REST, adicione PostgreSQL com Exposed, documente o contrato com OpenAPI e Swagger e depois escreva testes de integração. Para comparar caminhos, leia também Exposed 1.0 com R2DBC e o guia de microsserviços com Kotlin.

Kotlin com PostgreSQL não é uma combinação exótica: é uma base sólida para backend profissional. O diferencial está em usar a linguagem para escrever código mais claro sem abrir mão dos fundamentos de banco relacional: schema bem desenhado, transações corretas, SQL entendido e deploy seguro.