Soft deletes em produção: armadilhas e como evitá-las corretamente

1. O que são soft deletes e por que são tão populares?

Soft delete é uma técnica onde registros não são fisicamente removidos do banco de dados, mas marcados como "deletados" através de uma coluna especial. A implementação mais comum utiliza um campo deleted_at (timestamp nulo quando ativo, preenchido quando deletado) ou uma flag booleana is_deleted.

-- Exemplo de tabela com soft delete
CREATE TABLE usuarios (
    id SERIAL PRIMARY KEY,
    email VARCHAR(255) NOT NULL,
    nome VARCHAR(100) NOT NULL,
    deleted_at TIMESTAMP DEFAULT NULL,
    created_at TIMESTAMP DEFAULT NOW()
);

-- Query típica para buscar apenas registros ativos
SELECT * FROM usuarios WHERE deleted_at IS NULL;

A popularidade dos soft deletes vem de promessas atraentes: recuperação de dados após exclusão acidental, auditoria de histórico, e a possibilidade de "desfazer" ações do usuário. Muitos desenvolvedores acreditam que estão implementando uma rede de segurança contra erros operacionais.

2. Armadilha #1: Violação de unicidade e integridade referencial

O problema mais comum surge com restrições de unicidade. Quando um usuário é "deletado" via soft delete, seu email permanece na tabela, impedindo que um novo usuário se cadastre com o mesmo email.

-- Tentativa frustrada de recadastro
INSERT INTO usuarios (email, nome) 
VALUES ('joao@email.com', 'João Silva');
-- ERRO: violação de chave única, email já existe (mesmo que deletado)

Solução: Índices únicos parciais (partial unique indexes) resolvem esse problema:

-- PostgreSQL: índice único apenas para registros ativos
CREATE UNIQUE INDEX idx_usuarios_email_ativo 
ON usuarios (email) 
WHERE deleted_at IS NULL;

Para chaves estrangeiras, o problema é ainda mais grave. Um pedido pode referenciar um produto que foi "deletado", criando inconsistências em relatórios e cálculos.

-- Produto deletado, mas ainda referenciado em pedidos
SELECT p.nome, COUNT(pp.id) as total_pedidos
FROM produtos p
JOIN pedidos_produtos pp ON pp.produto_id = p.id
WHERE p.deleted_at IS NULL  -- Produto deletado não aparece
GROUP BY p.nome;
-- Resultado: pedidos com produto "fantasma"

3. Armadilha #2: Degradação de performance e explosão de dados

Com o tempo, tabelas com soft delete acumulam milhões de registros "mortos". Cada query precisa filtrar WHERE deleted_at IS NULL, e índices ficam inchados com dados irrelevantes.

-- Tabela com 10 milhões de registros, 60% deletados
EXPLAIN ANALYZE
SELECT * FROM transacoes 
WHERE deleted_at IS NULL 
  AND data BETWEEN '2024-01-01' AND '2024-12-31';
-- Tempo: 12.3 segundos (scan em 10M registros)

O problema se agrava em JOINs com múltiplas tabelas que também usam soft delete:

-- Query complexa com múltiplos filtros de soft delete
SELECT u.nome, t.valor, p.descricao
FROM usuarios u
JOIN transacoes t ON t.usuario_id = u.id AND t.deleted_at IS NULL
JOIN produtos p ON p.id = t.produto_id AND p.deleted_at IS NULL
WHERE u.deleted_at IS NULL
  AND t.data > '2024-06-01';

Estratégias de mitigação:

  1. Tabelas de arquivo morto: Mover registros deletados para tabelas separadas periodicamente
  2. TTL (Time-To-Live): Definir prazo máximo para registros deletados antes da exclusão física
  3. Particionamento: Particionar por data e descartar partições antigas
-- Job de migração para archive
INSERT INTO transacoes_archive (id, usuario_id, valor, data, deleted_at)
SELECT id, usuario_id, valor, data, deleted_at
FROM transacoes
WHERE deleted_at IS NOT NULL 
  AND deleted_at < NOW() - INTERVAL '90 days';

DELETE FROM transacoes
WHERE deleted_at IS NOT NULL 
  AND deleted_at < NOW() - INTERVAL '90 days';

4. Armadilha #3: Vazamento de dados e inconsistências em soft deletes

Soft deletes criam uma falsa sensação de segurança. Dados "deletados" podem vazar em APIs, exports e relatórios se o filtro não for aplicado consistentemente.

-- Controller que esquece o filtro de soft delete
GET /api/usuarios
-- Retorna: [{"id": 5, "email": "excluido@email.com", "deleted_at": "2024-01-15"}]
-- Vazamento de dados de usuário deletado!

ORMs como Rails com default_scope podem criar problemas ainda maiores:

# Rails: default_scope perigoso
class Usuario < ApplicationRecord
  default_scope { where(deleted_at: nil) }
end

# Problema: queries sem escopo podem retornar dados deletados
Usuario.unscoped.where(email: 'excluido@email.com').first
# Retorna o registro deletado!

Boas práticas:

  1. Camada de repositório: Centralizar toda lógica de acesso a dados
  2. Testes de integração: Verificar que dados deletados nunca vazam
  3. Validação em APIs: Middleware que garante filtros de soft delete
-- Middleware de segurança para APIs
CREATE OR REPLACE FUNCTION check_soft_delete_filter()
RETURNS TRIGGER AS $$
BEGIN
    IF TG_OP = 'SELECT' AND NEW.deleted_at IS NOT NULL THEN
        RAISE EXCEPTION 'Acesso a registro deletado bloqueado';
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

5. Quando soft deletes realmente fazem sentido (e quando não)

Casos legítimos:
- Sistemas de auditoria financeira (retenção legal de 5 anos)
- Compliance com LGPD/GDPR (direito ao esquecimento com retenção controlada)
- Funcionalidades de "lixeira" com prazo para recuperação

Quando evitar:
- Sistemas com alto throughput (milhares de exclusões por minuto)
- Tabelas que crescem mais de 100GB/mês
- Requisitos de performance abaixo de 10ms por query

Alternativas recomendadas:
1. Hard delete + log de eventos: Excluir fisicamente e registrar em tabela de auditoria
2. Tabela separada de deletados: Mover para usuarios_deleted com estrutura idêntica
3. Exclusão lógica com TTL: Soft delete com expiração automática

6. Implementação correta: padrões e ferramentas que funcionam

PostgreSQL Row-Level Security (RLS):

-- Política RLS para soft delete automático
CREATE POLICY soft_delete_policy ON usuarios
    FOR ALL
    USING (deleted_at IS NULL)
    WITH CHECK (true);

ALTER TABLE usuarios ENABLE ROW LEVEL SECURITY;

ORM com escopo global (Eloquent/Prisma):

// Prisma: middleware global para soft delete
prisma.$use(async (params, next) => {
  if (params.model === 'Usuario' && params.action === 'findMany') {
    params.args.where = { ...params.args.where, deleted_at: null };
  }
  return next(params);
});

Auditoria completa com deleted_by:

-- Tabela com auditoria de soft delete
CREATE TABLE usuarios (
    id SERIAL PRIMARY KEY,
    email VARCHAR(255) NOT NULL,
    deleted_at TIMESTAMP DEFAULT NULL,
    deleted_by INTEGER REFERENCES usuarios(id),
    deleted_reason VARCHAR(500),
    restored_at TIMESTAMP DEFAULT NULL,
    restored_by INTEGER REFERENCES usuarios(id)
);

7. Monitoramento e manutenção contínua

Dashboard de métricas:

-- Query para monitoramento
SELECT 
    relname as tabela,
    n_live_tup as registros_ativos,
    n_dead_tup as registros_deletados,
    round(n_dead_tup::numeric / NULLIF(n_live_tup + n_dead_tup, 0) * 100, 2) as percentual_morto
FROM pg_stat_user_tables
WHERE n_dead_tup > 1000
ORDER BY percentual_morto DESC;

Jobs de manutenção:

-- Job semanal de vacuum e purge
VACUUM ANALYZE usuarios;
VACUUM ANALYZE transacoes;

-- Hard delete em lote (fora do horário comercial)
DELETE FROM usuarios 
WHERE deleted_at IS NOT NULL 
  AND deleted_at < NOW() - INTERVAL '180 days'
LIMIT 10000;

Política de retenção: Defina claramente:
- 30 dias: soft delete com possibilidade de restore
- 90 dias: soft delete sem restore (apenas auditoria)
- 180 dias: hard delete definitivo


Referências