Multi-Tenant - 01 - PK simples ou composta

prompt

Estou com uma dúvida técnica para a minha aplicação multi-tentant, alguns autores dizem que não é interessante usar chave composta para as tabelas, ou seja, tenant_id, id (serial), dizem que isso pode atrapalhar o bom funcionamento de alguns ORMs que não conseguem trabalhar bem com chaves compostas. Outros já dizem que é interessante sim usar sempre o tenant_id na chave primária, ou seja, manter o uso da chave composta para as primary keys porque melhora o desempenho e índices. Sabendo que estarei usando o posgresql nessa situação, o que você me recomenda?

Vou direto ao ponto: no seu cenário (PostgreSQL + multi-tenant + sqlx, sem ORM pesado) eu recomendo:

PK simples (id bigint/uuid) + coluna tenant_id obrigatória, indexada e usada em TODAS as consultas e constraints.
Não precisa usar tenant_id na primary key para ter boa performance nem boa segurança.

Depois a gente “afina” isso, mas a ideia central é essa.


1. O que está em jogo aqui de verdade?

Tem 3 preocupações diferentes que a galera costuma misturar:

  1. Modelo de dados / segurança

    • Garantir que nada “vaze” entre tenants.
    • Garantir que as uniqueness sejam sempre por tenant.
  2. Performance / índices

    • Filtros por tenant_id.
    • Joins entre tabelas multi-tenant.
  3. Ferramentas / ORMs

    • Vários ORMs ainda são chatos com chave composta.
    • Ferramentas legadas (Delphi, etc.) geralmente assumem PK simples.

A escolha PK composta x PK simples impacta mais o item 3 do que 1 e 2.


2. Duas abordagens lado a lado

A) PK composta: PRIMARY KEY (tenant_id, id)

CREATE TABLE contas_pagar (
  tenant_id  BIGINT      NOT NULL,
  id         BIGINT      NOT NULL,
  fornecedor_id BIGINT   NOT NULL,
  numero_doc VARCHAR(50) NOT NULL,
  valor      NUMERIC(15,2) NOT NULL,
  vencimento DATE        NOT NULL,
  -- ...

  CONSTRAINT contas_pagar_pk PRIMARY KEY (tenant_id, id)
);

Vantagens:

Desvantagens:

Performance:


B) PK simples: PRIMARY KEY (id) + tenant_id fora da PK

CREATE TABLE contas_pagar (
  id           BIGSERIAL   PRIMARY KEY,
  tenant_id    BIGINT      NOT NULL,
  fornecedor_id BIGINT     NOT NULL,
  numero_doc   VARCHAR(50) NOT NULL,
  valor        NUMERIC(15,2) NOT NULL,
  vencimento   DATE        NOT NULL,

  -- Unicidade por tenant (exemplo: não repetir documento dentro do tenant)
  CONSTRAINT contas_pagar_uq_tenant_doc
    UNIQUE (tenant_id, numero_doc)
);

-- Índice para acesso padrão por tenant + id
CREATE INDEX contas_pagar_tenant_id_idx
    ON contas_pagar (tenant_id, id);

Vantagens:

Desempenho:

Diferença real de performance entre as duas abordagens, no mundo real, é mínima se seus índices estiverem bem pensados.

Segurança / isolamento:


3. E o que o PostgreSQL “prefere”?

PostgreSQL em si não “gosta mais” de uma ou outra do ponto de vista de engine.

O que pega é:


4. Minha recomendação prática pro SEU contexto

Considerando seu cenário:

Eu seguiria este padrão como “default”:

  1. PK simples em todas as tabelas

    id BIGSERIAL PRIMARY KEY
    -- ou UUIDv7 se quiser distribuído
    
  2. tenant_id BIGINT NOT NULL em todas as tabelas

  3. Índice padrão para multi-tenant:

    CREATE INDEX nome_tabela_tenant_id_idx
        ON nome_tabela (tenant_id, id);
    
  4. Toda unicidade de negócio, sempre incluindo tenant_id

    UNIQUE (tenant_id, codigo),
    UNIQUE (tenant_id, cnpj),
    UNIQUE (tenant_id, email)
    
  5. RLS por tenant_id em tabelas sensíveis

    ALTER TABLE nome_tabela ENABLE ROW LEVEL SECURITY;
    
    CREATE POLICY nome_tabela_tenant_policy
    ON nome_tabela
    USING (tenant_id = current_settingBIGINT;
    
  6. Sempre filtrar por tenant_id na camada de aplicação, mesmo com RLS.


5. Quando eu adotaria PK composta, então?

Eu partiria para PK composta (tenant_id, id) se:


Resumindo em uma frase

Se você quer equilíbrio entre desempenho, ergonomia de código e compatibilidade com ferramentas, use PK simples (id) + tenant_id obrigatório, bem indexado e com RLS.
PK composta (tenant_id, id) eu reservaria para cenários em que a partição por tenant e a garantia rígida via FK são mais importantes do que a compatibilidade com ORMs/ferramentas.


Perfeito, vamos cristalizar isso num template padrão que você pode reutilizar em qualquer contexto (ERP, CRM, SESMT, etc.).

Vou assumir o modelo que combinamos:

Vou usar um exemplo genérico de entidade principal clientes e uma tabela de junção clientes_grupos.


1. Convenções gerais (recomendo usar em todas as tabelas)

-- Convenções recomendadas:
-- - Todas as tabelas: tenant_id BIGINT NOT NULL
-- - PK simples: id BIGSERIAL (ou UUID se preferir)
-- - Auditoria: created_at / updated_at
-- - Índice padrão: (tenant_id, id)
-- - Unicidade por tenant: UNIQUE (tenant_id, ...)

-- Função genérica para updated_at (opcional, usar em todas)
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS trigger AS $
BEGIN
  NEW.updated_at := NOW();
  RETURN NEW;
END;
$ LANGUAGE plpgsql;

2. Template: Entidade principal (ex: clientes)

-- =========================================================
-- Tabela principal: clientes
-- =========================================================
CREATE TABLE public.clientes (
  id   BIGSERIAL PRIMARY KEY,  -- PK simples
  tenant_idBIGINT  NOT NULL,   -- identifica o tenant
  nome TEXTNOT NULL,
  emailTEXTNULL,
  documentoTEXTNULL,   -- CPF/CNPJ, etc.
  ativoBOOLEAN NOT NULL DEFAULT TRUE,

  -- Auditoria
  created_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at   TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Índice padrão multi-tenant (sempre crie isso)
CREATE INDEX clientes_tenant_id_idx
ON public.clientes (tenant_id, id);

-- Exemplo de unicidade por tenant (e-mail único dentro do tenant)
CREATE UNIQUE INDEX clientes_uq_tenant_email
ON public.clientes (tenant_id, lower(email))
WHERE email IS NOT NULL;

-- Exemplo de unicidade por tenant para documento (CPF/CNPJ)
CREATE UNIQUE INDEX clientes_uq_tenant_documento
ON public.clientes (tenant_id, documento)
WHERE documento IS NOT NULL;

-- Trigger de updated_at (padrão para todas as tabelas)
CREATE TRIGGER clientes_set_updated_at
BEFORE UPDATE ON public.clientes
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();

RLS por tenant_id na tabela principal

-- Habilita RLS
ALTER TABLE public.clientes ENABLE ROW LEVEL SECURITY;

-- Política padrão: o tenant só enxerga o que tem o seu tenant_id
CREATE POLICY clientes_tenant_policy ON public.clientes
USING (tenant_id = current_settingBIGINT;

Na aplicação (Go), para cada request você seta app.tenant_id na sessão do Postgres, e pronto.


3. Template: Tabela de junção (ex: clientes_grupos)

Vamos supor que você tenha uma tabela de grupos:

CREATE TABLE public.grupos (
  id   BIGSERIAL PRIMARY KEY,
  tenant_idBIGINT  NOT NULL,
  nome TEXTNOT NULL,

  created_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at   TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX grupos_tenant_id_idx
ON public.grupos (tenant_id, id);

CREATE UNIQUE INDEX grupos_uq_tenant_nome
ON public.grupos (tenant_id, lower(nome));

CREATE TRIGGER grupos_set_updated_at
BEFORE UPDATE ON public.grupos
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();

ALTER TABLE public.grupos ENABLE ROW LEVEL SECURITY;
CREATE POLICY grupos_tenant_policy ON public.grupos
USING (tenant_id = current_settingBIGINT;

Agora a tabela de junção clientes_grupos (cliente pertence a vários grupos):

-- =========================================================
-- Tabela de junção: clientes_grupos
-- =========================================================
CREATE TABLE public.clientes_grupos (
  id   BIGSERIAL PRIMARY KEY,  -- PK simples
  tenant_idBIGINT  NOT NULL,

  cliente_id   BIGINT  NOT NULL,
  grupo_id BIGINT  NOT NULL,

  -- Auditoria
  created_at   TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Índice padrão multi-tenant para a junção
CREATE INDEX clientes_grupos_tenant_id_idx
ON public.clientes_grupos (tenant_id, id);

-- Índices para facilitar filtros típicos
CREATE INDEX clientes_grupos_tenant_cliente_idx
ON public.clientes_grupos (tenant_id, cliente_id);

CREATE INDEX clientes_grupos_tenant_grupo_idx
ON public.clientes_grupos (tenant_id, grupo_id);

-- Garante que não exista o mesmo cliente no mesmo grupo duas vezes
CREATE UNIQUE INDEX clientes_grupos_uq_tenant_cliente_grupo
ON public.clientes_grupos (tenant_id, cliente_id, grupo_id);

-- Foreign keys (note: FK só referenciam o id; isolamento vem pelo tenant_id + RLS)
ALTER TABLE public.clientes_grupos
  ADD CONSTRAINT clientes_grupos_cliente_fk
FOREIGN KEY (cliente_id)
REFERENCES public.clientes (id)
ON DELETE CASCADE;

ALTER TABLE public.clientes_grupos
  ADD CONSTRAINT clientes_grupos_grupo_fk
FOREIGN KEY (grupo_id)
REFERENCES public.grupos (id)
ON DELETE CASCADE;

-- RLS na tabela de junção
ALTER TABLE public.clientes_grupos ENABLE ROW LEVEL SECURITY;

CREATE POLICY clientes_grupos_tenant_policy ON public.clientes_grupos
USING (tenant_id = current_settingBIGINT;

Repare:


4. Variante rápida com UUID (se quiser tirar o BIGSERIAL)

Se você quiser usar UUID (idealmente uuidv7 quando o Postgres suportar nativo / extensão), o padrão fica:

CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

CREATE TABLE public.clientes (
  id   UUIDPRIMARY KEY DEFAULT uuid_generate_v4(),
  tenant_idBIGINT  NOT NULL,
  nome TEXTNOT NULL,
  -- ...
);
CREATE INDEX clientes_tenant_id_idx
ON public.clientes (tenant_id, id);

O resto do template (RLS, índices, junção) fica idêntico, só mudando o tipo de id.


Show time 😄
Segue o snippet base multi-tenant prontinho pra você jogar no repo e só substituir placeholders.

Vou usar PLACEHOLDERS EM MAIÚSCULO pra você localizar fácil.


1. Função genérica de updated_at (usar uma vez só no schema)

-- ==================================================================
-- Função padrão para manter updated_at sempre atualizado
-- ==================================================================
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS trigger AS $
BEGIN
  NEW.updated_at := NOW();
  RETURN NEW;
END;
$ LANGUAGE plpgsql;

2. Template base: TABELA PRINCIPAL (ENTIDADE)

Copie, cole e troque os PLACEHOLDERS:

-- ==================================================================
-- TEMPLATE: TABELA PRINCIPAL MULTI-TENANT
-- ==================================================================
CREATE TABLE public.NOME_TABELA (
  id BIGSERIAL   PRIMARY KEY,
  tenant_id  BIGINT  NOT NULL,

  -- Campos de negócio (exemplos, ajuste conforme sua necessidade)
  CAMPO_NEGOCIO_1   TEXTNOT NULL,
  CAMPO_NEGOCIO_2   TEXTNULL,
  CAMPO_NEGOCIO_3   NUMERIC(15,2) NULL,
  CAMPO_NEGOCIO_4   BOOLEAN NOT NULL DEFAULT TRUE,

  -- Auditoria
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Índice padrão multi-tenant
CREATE INDEX NOME_TABELA_tenant_id_idx
ON public.NOME_TABELA (tenant_id, id);

-- Unicidade por tenant (se precisar)
-- Exemplo: CAMPO_UNICO1 deve ser único dentro do tenant
CREATE UNIQUE INDEX NOME_TABELA_uq_tenant_UNICO1
ON public.NOME_TABELA (tenant_id, lower(CAMPO_UNICO1))
WHERE CAMPO_UNICO1 IS NOT NULL;

-- Outro exemplo de unicidade por tenant
-- CREATE UNIQUE INDEX NOME_TABELA_uq_tenant_UNICO2
-- ON public.NOME_TABELA (tenant_id, CAMPO_UNICO2)
-- WHERE CAMPO_UNICO2 IS NOT NULL;

-- Trigger de updated_at
CREATE TRIGGER NOME_TABELA_set_updated_at
BEFORE UPDATE ON public.NOME_TABELA
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();

-- ==================================================================
-- RLS: um tenant só enxerga o que tem o seu tenant_id
-- ==================================================================
ALTER TABLE public.NOME_TABELA ENABLE ROW LEVEL SECURITY;

CREATE POLICY NOME_TABELA_tenant_policy ON public.NOME_TABELA
USING (tenant_id = current_settingBIGINT;

3. Template base: TABELA DE JUNÇÃO (N:N)

Use quando tiver N:N: por exemplo usuarios_grupos, produtos_categorias, etc.

Placeholders principais:

-- ==================================================================
-- TEMPLATE: TABELA DE JUNÇÃO MULTI-TENANT (N:N)
-- ==================================================================
CREATE TABLE public.NOME_TABELA_JUNCAO (
  id BIGSERIAL   PRIMARY KEY,
  tenant_id  BIGINT  NOT NULL,

  CAMPO_A_ID BIGINT  NOT NULL, -- FK para TABELA_A.id
  CAMPO_B_ID BIGINT  NOT NULL, -- FK para TABELA_B.id

  -- Campos adicionais de negócio (opcional)
  -- ex: papel, prioridade, etc.
  -- CAMPO_EXTRATEXTNULL,

  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Índice padrão multi-tenant
CREATE INDEX NOME_TABELA_JUNCAO_tenant_id_idx
ON public.NOME_TABELA_JUNCAO (tenant_id, id);

-- Índices para acesso rápido pelos lados A e B
CREATE INDEX NOME_TABELA_JUNCAO_tenant_A_idx
ON public.NOME_TABELA_JUNCAO (tenant_id, CAMPO_A_ID);

CREATE INDEX NOME_TABELA_JUNCAO_tenant_B_idx
ON public.NOME_TABELA_JUNCAO (tenant_id, CAMPO_B_ID);

-- Garante que não exista duplicidade de relação dentro do tenant
CREATE UNIQUE INDEX NOME_TABELA_JUNCAO_uq_tenant_A_B
ON public.NOME_TABELA_JUNCAO (tenant_id, CAMPO_A_ID, CAMPO_B_ID);

-- Foreign keys (isolamento final vem de RLS + tenant_id na query)
ALTER TABLE public.NOME_TABELA_JUNCAO
  ADD CONSTRAINT NOME_TABELA_JUNCAO_A_fk
FOREIGN KEY (CAMPO_A_ID)
REFERENCES public.TABELA_A (id)
ON DELETE CASCADE;

ALTER TABLE public.NOME_TABELA_JUNCAO
  ADD CONSTRAINT NOME_TABELA_JUNCAO_B_fk
FOREIGN KEY (CAMPO_B_ID)
REFERENCES public.TABELA_B (id)
ON DELETE CASCADE;

-- RLS para a tabela de junção
ALTER TABLE public.NOME_TABELA_JUNCAO ENABLE ROW LEVEL SECURITY;

CREATE POLICY NOME_TABELA_JUNCAO_tenant_policy
ON public.NOME_TABELA_JUNCAO
USING (tenant_id = current_settingBIGINT;

4. Mini-exemplo de uso (só pra ver os placeholders “resolvidos”)

Se amanhã você quiser criar clientes:

CREATE TABLE public.clientes (
  id   BIGSERIAL   PRIMARY KEY,
  tenant_idBIGINT  NOT NULL,

  nome TEXTNOT NULL,
  emailTEXTNULL,
  documentoTEXTNULL,
  ativoBOOLEAN NOT NULL DEFAULT TRUE,

  created_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at   TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX clientes_tenant_id_idx
ON public.clientes (tenant_id, id);

CREATE UNIQUE INDEX clientes_uq_tenant_email
ON public.clientes (tenant_id, lower(email))
WHERE email IS NOT NULL;

CREATE TRIGGER clientes_set_updated_at
BEFORE UPDATE ON public.clientes
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();

ALTER TABLE public.clientes ENABLE ROW LEVEL SECURITY;

CREATE POLICY clientes_tenant_policy ON public.clientes
USING (tenant_id = current_settingBIGINT;

Se quiser, no próximo passo posso montar um arquivo de migration padrão (ex: 0001_template_multitenant.sql) já comentado, pra virar “boilerplate oficial” do teu stack.


Perfeito, vamos transformar tudo isso em um arquivo de migration padrão, já comentado, para você usar como boilerplate nos projetos.

A ideia é:


-- =====================================================================
-- MIGRATION: TEMPLATE PADRÃO MULTI-TENANT
-- Descrição:
--   - Cria função genérica de updated_at
--   - Cria tabela principal de exemplo (NOME_TABELA)
--   - Cria tabela de junção de exemplo (NOME_TABELA_JUNCAO)
--
-- Observações:
--   - Substitua todos os PLACEHOLDERS EM MAIÚSCULO pelos nomes reais:
--   NOME_TABELA, NOME_TABELA_JUNCAO,
--   CAMPO_NEGOCIO_1/2/3/4, CAMPO_UNICO1,
--   TABELA_A, TABELA_B, CAMPO_A_ID, CAMPO_B_ID
--   - Remova o que não fizer sentido para a migration específica.
-- =====================================================================

-- =====================================================================
-- 1) Função genérica de updated_at (criar uma vez por schema)
--- Se já existir em outra migration, você pode remover este bloco.
-- =====================================================================
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS trigger AS $
BEGIN
  NEW.updated_at := NOW();
  RETURN NEW;
END;
$ LANGUAGE plpgsql;

-- =====================================================================
-- 2) TABELA PRINCIPAL: NOME_TABELA
--- Exemplo de entidade multi-tenant com PK simples e RLS
-- =====================================================================

CREATE TABLE public.NOME_TABELA (
  id BIGSERIAL   PRIMARY KEY,
  tenant_id  BIGINT  NOT NULL,

  -- Campos de negócio (exemplos, ajuste para a entidade real)
  CAMPO_NEGOCIO_1   TEXT  NOT NULL,
  CAMPO_NEGOCIO_2   TEXT  NULL,
  CAMPO_NEGOCIO_3   NUMERIC(15,2) NULL,
  CAMPO_NEGOCIO_4   BOOLEAN   NOT NULL DEFAULT TRUE,

  -- Auditoria
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Índice padrão multi-tenant: usado em praticamente todas as consultas
CREATE INDEX NOME_TABELA_tenant_id_idx
ON public.NOME_TABELA (tenant_id, id);

-- Exemplo de unicidade "por tenant"
-- Ajuste/remova conforme a regra de negócio:
--   - CAMPO_UNICO1 deve ser único dentro de cada tenant
CREATE UNIQUE INDEX NOME_TABELA_uq_tenant_UNICO1
ON public.NOME_TABELA (tenant_id, lower(CAMPO_UNICO1))
WHERE CAMPO_UNICO1 IS NOT NULL;

-- Trigger para manter updated_at atualizado
CREATE TRIGGER NOME_TABELA_set_updated_at
BEFORE UPDATE ON public.NOME_TABELA
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();

-- RLS: cada tenant só enxerga suas próprias linhas
ALTER TABLE public.NOME_TABELA ENABLE ROW LEVEL SECURITY;

CREATE POLICY NOME_TABELA_tenant_policy
ON public.NOME_TABELA
USING (tenant_id = current_settingBIGINT;

-- =====================================================================
-- 3) TABELA DE JUNÇÃO: NOME_TABELA_JUNCAO
--- Ex.: relacionamento N:N (usuarios_grupos, produtos_categorias etc.)
-- =====================================================================

CREATE TABLE public.NOME_TABELA_JUNCAO (
  id BIGSERIAL   PRIMARY KEY,
  tenant_id  BIGINT  NOT NULL,

  CAMPO_A_ID BIGINT  NOT NULL, -- FK para TABELA_A.id
  CAMPO_B_ID BIGINT  NOT NULL, -- FK para TABELA_B.id

  -- Campos adicionais de negócio (opcional)
  -- EXEMPLO: papel, prioridade, data de vínculo etc.
  -- CAMPO_EXTRATEXT  NULL,

  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Índice padrão multi-tenant
CREATE INDEX NOME_TABELA_JUNCAO_tenant_id_idx
ON public.NOME_TABELA_JUNCAO (tenant_id, id);

-- Índices para facilitar buscas pelos dois lados da relação
CREATE INDEX NOME_TABELA_JUNCAO_tenant_A_idx
ON public.NOME_TABELA_JUNCAO (tenant_id, CAMPO_A_ID);

CREATE INDEX NOME_TABELA_JUNCAO_tenant_B_idx
ON public.NOME_TABELA_JUNCAO (tenant_id, CAMPO_B_ID);

-- Garante que não exista duplicidade de relação dentro do tenant
CREATE UNIQUE INDEX NOME_TABELA_JUNCAO_uq_tenant_A_B
ON public.NOME_TABELA_JUNCAO (tenant_id, CAMPO_A_ID, CAMPO_B_ID);

-- Foreign keys (apontam para as tabelas principais)
-- Ajuste TABELA_A, TABELA_B e ON DELETE conforme regra de negócio
ALTER TABLE public.NOME_TABELA_JUNCAO
  ADD CONSTRAINT NOME_TABELA_JUNCAO_A_fk
FOREIGN KEY (CAMPO_A_ID)
REFERENCES public.TABELA_A (id)
ON DELETE CASCADE;

ALTER TABLE public.NOME_TABELA_JUNCAO
  ADD CONSTRAINT NOME_TABELA_JUNCAO_B_fk
FOREIGN KEY (CAMPO_B_ID)
REFERENCES public.TABELA_B (id)
ON DELETE CASCADE;

-- RLS para a tabela de junção
ALTER TABLE public.NOME_TABELA_JUNCAO ENABLE ROW LEVEL SECURITY;

CREATE POLICY NOME_TABELA_JUNCAO_tenant_policy
ON public.NOME_TABELA_JUNCAO
USING (tenant_id = current_settingBIGINT;

-- =====================================================================
-- FIM DO TEMPLATE
-- =====================================================================