Back to the blog
C:\BLOG\MODELAGEM-POSTGRESQL-DRI.TXT

Do modelo relacional ao app: regras de negócio no PostgreSQL com Drizzle

Como modelei o Ducktix para Banco de Dados II: convenções, CHECK constraints como regra de negócio e o SELECT FOR UPDATE que impede vender mais ingressos do que existem.

Related project: Ducktix

This article is only available in Portuguese.

O Ducktix é uma plataforma de venda de ingressos que construí para a disciplina de Banco de Dados II, no curso de Análise e Desenvolvimento de Sistemas da UDESC. A entrega pedia o caminho completo: modelo conceitual, modelo lógico, DDL e uma aplicação usando o banco de verdade. A aplicação é Next.js com Drizzle ORM sobre PostgreSQL.

O que eu quero mostrar aqui não é o app, e sim uma ideia que o trabalho deixou clara para mim: a regra de negócio que não pode ser quebrada deve morar no banco, não só no código.

Convenções antes da primeira tabela

Antes de desenhar entidades, fixei algumas regras que valem para o esquema inteiro. Elas evitam discussão tabela a tabela e eliminam classes inteiras de bug.

  • Chave primária UUID com gen_random_uuid(): ids que não vazam volume e podem ser gerados em qualquer lugar.
  • Dinheiro sempre em centavos, como INTEGER. Nunca FLOAT: 0,1 + 0,2 não é 0,3 em ponto flutuante, e isso não é aceitável numa cobrança.
  • Datas em TIMESTAMPTZ. Um evento em Joinville comprado por alguém em outro fuso precisa começar no mesmo instante para os dois.
  • Toda chave estrangeira com índice, porque o PostgreSQL não cria esse índice sozinho.
  • Nomes de tabela no singular e em snake_case, em português, como o resto do domínio.

CHECK constraints são regras de negócio

Uma validação no formulário protege o formulário. Uma constraint protege o dado, venha ele da tela, de um script, de uma migração ou de um bug. O exemplo que eu mais gosto é o do evento, que tem três modalidades: presencial, online e híbrido.

schema.sql
-- Evento online não tem local físico; presencial e híbrido exigem um.
CONSTRAINT ck_evento_local_por_modalidade CHECK (
  (modalidade = 'online'  AND local IS NULL)
  OR (modalidade <> 'online' AND local IS NOT NULL)
),
-- Online e híbrido precisam dizer como a parte online acontece.
CONSTRAINT ck_evento_formato_por_modalidade CHECK (
  (modalidade = 'presencial' AND formato_online IS NULL)
  OR (modalidade <> 'presencial' AND formato_online IS NOT NULL)
)

Com essas duas linhas, não existe no banco um evento online com endereço ou um evento híbrido sem formato. O código da aplicação pode errar; o dado não fica inconsistente.

O Drizzle permite declarar as mesmas constraints no schema TypeScript, então o banco e o código descrevem as mesmas regras, e as migrações saem do mesmo lugar:

schema.ts
export const lote = pgTable(
  'lote',
  {
    id: uuid('id').primaryKey().defaultRandom(),
    eventoId: uuid('evento_id').notNull().references(() => evento.id, { onDelete: 'cascade' }),
    nome: varchar('nome', { length: 60 }).notNull(),
    precoCentavos: integer('preco_centavos').notNull(),
    vagas: integer('vagas').notNull(),
    vendidos: integer('vendidos').notNull().default(0),
  },
  (t) => [
    index('idx_lote_evento').on(t.eventoId),
    unique('uk_lote_nome').on(t.eventoId, t.nome),
    check('ck_lote_preco', sql`${t.precoCentavos} >= 0`),
    // invariante central da venda: nunca vender mais do que existe
    check('ck_lote_estoque', sql`${t.vendidos} <= ${t.vagas}`),
  ],
)

Simplificar o modelo também é uma decisão

O primeiro rascunho tinha duas tabelas em cascata: lotes, e dentro de cada lote, tipos de ingresso (inteira, meia, VIP), cada um com preço e estoque. Quando fui implementar, percebi que o produto nunca usava os dois níveis. Um evento vende "Pista" e "Mezanino", ou "Lote 1" e "Lote 2", mas não as duas coisas ao mesmo tempo.

Manter os dois níveis obrigaria toda venda a passar por uma tabela que quase sempre teria uma linha só, e pior, deixaria ambíguo onde o estoque mora. Juntei tudo numa tabela lote. Um nível só deixa claro onde fica o contador de vendidos, e esse é exatamente o ponto em que a concorrência acontece.

Dois compradores, uma vaga

O cenário que todo sistema de ingressos precisa resolver: resta uma vaga e duas pessoas confirmam o pedido no mesmo instante. Se as duas leem vendidos = 99 com vagas = 100, as duas acham que podem comprar.

A constraint ck_lote_estoque já impede que o banco aceite o estado errado, mas a aplicação precisa de uma resposta melhor que um erro de constraint. A solução é travar a linha do lote dentro da transação, com SELECT ... FOR UPDATE:

drizzle-catalogo.ts
async registrarVenda(loteId: string, quantidade: number): Promise<void> {
  await db.transaction(async (tx) => {
    const [linha] = await tx
      .select({ vagas: loteTabela.vagas, vendidos: loteTabela.vendidos })
      .from(loteTabela)
      .where(eq(loteTabela.id, loteId))
      .for('update') // a segunda transação espera aqui

    if (!linha || linha.vendidos + quantidade > linha.vagas) {
      throw new Error('Estoque insuficiente para registrar a venda.')
    }

    await tx
      .update(loteTabela)
      .set({ vendidos: sql`${loteTabela.vendidos} + ${quantidade}` })
      .where(eq(loteTabela.id, loteId))
  })
}

A segunda transação fica parada no FOR UPDATE até a primeira terminar. Quando ela finalmente lê a linha, já vê o valor atualizado e recebe a mensagem de estoque esgotado. Repare também no vendidos + quantidade calculado pelo banco, e não pelo JavaScript: o incremento usa o valor que está na linha travada.

O detalhe do banco serverless

Em produção o Ducktix usa o Neon, um PostgreSQL serverless. O driver HTTP do Neon é ótimo para consultas soltas, mas cada consulta é uma requisição independente, sem sessão. Sem sessão não existe transação interativa, e sem transação o FOR UPDATE não serve para nada.

A saída foi usar o driver do Neon que abre conexão por WebSocket com um pool, que mantém a sessão durante a transação. É o tipo de detalhe que não aparece no tutorial e quebra exatamente a parte mais importante do sistema.

O que fica

  • Convenções de esquema decididas antes das tabelas evitam bugs de dinheiro, fuso e performance.
  • CHECK constraints transformam regra de negócio em garantia, não em sugestão.
  • Simplificar o modelo é válido quando o motivo está escrito.
  • Concorrência se resolve travando o ponto exato onde o estado muda.
  • Confira se o driver do seu banco suporta as transações que o seu código assume.