Exclusão de Workspaces e Multi-Tenancy em DDD: Soft Delete e Desacoplamento

O ciclo de vida de tenants, vazamento de dados e bloqueios de banco

No produto que estou trabalhando, deletar o tenant principal afeta faturamento, integridade de índices no banco, conexões em tempo real e privacidade de dados de terceiros. Um cascade delete descuidado ou um soft delete superficial trariam problemas graves em produção. Decidi estruturar essas regras diretamente no domínio.


1. O Risco de Colisão de Índices e Vazamento de Dados

A minha aplicação integra lojas virtuaistexternas (como a Nuvemshop). No banco de dados, a tabela de conexões possui um índice de unicidade composto:

UNIQUE (platform,texternal_id)

Se eu apenas marcasse o workspace e a loja como deletados via soft delete, o registro de conexão continuaria existindo na tabela. Caso o lojista tentasse conectar essa mesma loja em outro workspace no futuro (ou se a loja fosse vendida para outro comerciante), a tentativa de conexão falharia por violação de chave única.

Pior ainda: se eu permitisse reaproveitar o registro antigo da loja, o novo proprietário herdaria o histórico de conversas, métricas e mensagens de clientes do dono anterior.

Para resolver isso, fui diretamente no aggregate root Store:

public class Store : AggregateRoot
{
    public StoreConnection? StoreConnection { get; private set; }

    public void Disconnect()
    {
        StoreConnection = null;
        UpdatedAtUtc = DateTime.UtcNow;
    }

    public void SoftDelete()
    {
        IsDeleted = true;
        DeletedAtUtc = DateTime.UtcNow;
        Disconnect();
    }
}

Ao chamar store.Disconnect(), removemos o registro de StoreConnection, liberando a chave (platform,texternal_id). Ao mesmo tempo, a entidade Store e suas mensagens históricas permanecem arquivadas no workspace excluído para fins de auditoria, garantindo que qualquer conexão futura crie uma loja totalmente limpa.


2. A Proteção Contra o Estado de Zero Workspaces

O frontend da aplicação espera que todo usuário autenticado possua pelo menos um workspace pra trabalhar. Inicialmente, a verificação parecia simples: checar se o usuário possui outros workspaces antes de permitir a exclusão. Porém, surgiu uma falha: um usuário pode ser proprietário de 1 workspace e ser convidado como Agente em outros 3 workspaces.

Se a contagem considerasse todos os workspaces, ele conseguiria deletar o seu único workspace próprio. Ao perder o acesso aos workspaces de terceiros no futuro, ele ficaria com zero workspaces e a interface quebraria.

Ajustei a regra no domínio e no repositório para validar exclusivamente os workspaces próprios:

var ownedWorkspacesCount = await _workspaceRepository
    .CountOwnedWorkspacesByUserIdAsync(currentUserId.Value, ct);

if (ownedWorkspacesCount <= 1)
{
    return Result.Failure(WorkspaceErrors.CannotDeleteLastWorkspace);
}

Dessa forma, o usuário é impedido de deletar o seu último workspace onde tem papel de Owner.


3. Bloqueio por Assinatura Ativa no Stripe

Outra decisão foi como lidar com cobranças recorrentes. Se o usuário deletar o workspace mantendo uma assinatura ativa no Stripe, o cartão continuaria sendo faturado nos meses seguintes, gerando contestações e estornos.

A abordagem adotada foi exigir o pré-requisito explícito:

if (workspace.Subscription?.IsActive() == true)
{
    return Result.Failure(WorkspaceErrors.ActiveSubscriptionPreventsDeletion);
}

Se houver assinatura ativa, a API recusa a exclusão retornando 409 Conflict. O proprietário precisa cancelar a renovação no portal de faturamento antes de encerrar o workspace.


4. Desconexão de Streams (SSE) via Domain Event

Quando o workspace é deletado, qualquer conexão aberta de Server-Sent Events (SSE) precisa ser fechada imediatamente para evitar que sessões zumbis continuem escutando eventos.

Em vez de chamar o gerenciador de conexões dentro do command handler, desacoplei essa responsabilidade utilizando Domain Events:

// No agregado Workspace
public void SoftDelete()
{
    IsDeleted = true;
    DeletedAtUtc = DateTime.UtcNow;
    
    AddDomainEvent(new WorkspaceDeletedDomainEvent(Id));
}

O evento é capturado pelo handler na camada de aplicação:

public class WorkspaceDeletedDomainEventHandler 
    : INotificationHandler<WorkspaceDeletedDomainEvent>
{
    private readonly ISseConnectionManager _sseConnectionManager;

    public WorkspaceDeletedDomainEventHandler(ISseConnectionManager sseConnectionManager)
    {
        _sseConnectionManager = sseConnectionManager;
    }

    public Task Handle(WorkspaceDeletedDomainEvent notification, CancellationToken ct)
    {
        _sseConnectionManager.RemoveAllWorkspaceConnections(notification.WorkspaceId);
        return Task.CompletedTask;
    }
}

Isso mantém o handler focado apenas na persistência e regras de negócio, delegando os efeitos colaterais para a infra.


5. Confirmação de Nome DELETE

Para evitar exclusões acidentais, exijo que o usuário confirme o nome exato do workspace.

DELETE /api/workspaces/{workspaceId}?confirmName=Meu%20Workspace

Muitos proxies reversos e CDNs (como Cloudflare, que uso na frente da minha VPS, conforme detalhei no post sobre minha estratégia de CI/CD e NGINX) descartam o body dos DELETE por padrão. O query parameter garante que a confirmação vai ser recebida.