# Uma chave de API, muitos inquilinos: Como isolamos as traduções de DeepL entre os clientes

> O Rasepi usa uma única chave de API DeepL para todos os locatários. Veja como lidamos com glossários por cliente, regras de estilo, traduções em cache e isolamento em nível de bloco sem vazamento de nada.

2026-04-18 · Tim Cadenbach · https://www.tcdev.de/pt/blog/one-api-key-many-tenants-deepl-isolation/

---

Há uma pergunta que surge sempre que explico a arquitetura de tradução do Rasepi a outro programador: "Espera, então todos os teus inquilinos partilham uma chave de API DeepL? Como é que evita que os seus glossários e regras de estilo se infiltrem uns nos outros?"

É uma pergunta justa. E a resposta envolve mais trabalho de design do que seria de esperar.

Cobrimos a [linha de tradução completa](https://www.tcdev.de/pt/blog/inside-the-translation-engine-glossaries-style-rules-and-smart-retranslation/) num post anterior, o hashing ao nível do bloco, o orquestrador, todo o fluxo desde a gravação do documento até à saída traduzida. Este post aborda o problema específico de multi-tenancy. Como se pega numa API de terceiros que não tem qualquer conceito de inquilinos e se constrói o isolamento de inquilinos em cima dela.

## O problema: DeepL não sabe sobre seus clientes

A API do DeepL é autenticada com uma única chave de API. Tudo o que é criado com essa chave, glossários, listas de regras de estilo, histórico de tradução, pertence à mesma conta. Não há nenhum conceito de "este glossário pertence ao inquilino A" no lado do DeepL.

Quando se chama `GET /v2/glossaries`, obtém-se *todos* os glossários de *todos* os inquilinos. Quando se cria uma lista de regras de estilo, ela vive no mesmo espaço de nomes que as regras de estilo de todos os outros locatários. A API é plana.

Para um produto auto-hospedado em que cada cliente executa a sua própria instância com a sua própria chave DeepL, isto é ótimo. Para um SaaS multi-tenant em que gerimos a infraestrutura? É necessária uma camada de isolamento.

## A base de dados é a fonte da verdade

A nossa principal decisão de conceção: **a base de dados possui todo o conteúdo do glossário e a configuração das regras de estilo. DeepL é um alvo de execução em tempo de execução, nada mais.

Todas as entidades `TenantGlossary` e `TenantStyleRuleList` implementam `ITenantScoped`, o que significa que os filtros de consulta global do EF Core automaticamente abrangem todas as leituras para o locatário atual. Uma consulta de glossários no contexto de solicitação do locatário A nunca retornará as entradas do locatário B. Este é o mesmo padrão de isolamento que usamos em todo o Rasepi, aplicado ao nível do ORM.

O que torna isto interessante é o seguinte. Quando um locatário edita um termo do glossário, não chamamos imediatamente DeepL. Actualizamos a linha da base de dados e definimos `IsDirty = true`. É só isso. O glossário DeepL real é criado (ou recriado) preguiçosamente, mesmo antes de a próxima tradução precisar dele.

```csharp
public async Task<string?> GetOrSyncDeepLGlossaryIdAsync(
    string sourceLanguage, string targetLanguage)
{
    var glossary = await _db.TenantGlossaries
        .Include(g => g.Entries)
        .FirstOrDefaultAsync(g =>
            g.SourceLanguage == sourceLanguage &&
            g.TargetLanguage == targetLanguage);

    if (glossary?.Entries.Count == 0) return null;

    if (!glossary.IsDirty && glossary.DeepLGlossaryId is not null)
        return glossary.DeepLGlossaryId;

    // Dirty: delete old, create new
    if (glossary.DeepLGlossaryId is not null)
        await _deepL.DeleteGlossaryAsync(glossary.DeepLGlossaryId);

    var entries = glossary.Entries
        .ToDictionary(e => e.SourceTerm, e => e.TargetTerm);

    var created = await _deepL.CreateGlossaryAsync(
        $"rasepi-{glossary.Id}",
        glossary.SourceLanguage,
        glossary.TargetLanguage,
        entries);

    glossary.DeepLGlossaryId = created.GlossaryId;
    glossary.IsDirty = false;
    glossary.LastSyncedAt = DateTime.UtcNow;
    await _db.SaveChangesAsync();

    return glossary.DeepLGlossaryId;
}
```

O filtro de consulta em `TenantGlossaries` faz o isolamento. O sinalizador `IsDirty` faz a sincronização preguiçosa. E a convenção de nomenclatura (`rasepi-{glossary.Id}`) é apenas para depuração no painel DeepL, não tem qualquer objetivo funcional.

Porquê preguiçosa? Porque [os glossários de DeepL v2 são imutáveis](https://developers.deepl.com/docs/api-reference/glossaries). Não é possível editá-los. Qualquer alteração significa apagar e recriar. Se uma equipa importar um CSV com 200 termos e depois corrigir um erro de digitação numa entrada, não queremos apagar e recriar o glossário DeepL duas vezes. Basta definir `IsDirty` em ambas as vezes e a única recriação acontece quando a próxima tradução for executada.

## Regras de estilo: mesmo padrão, API diferente

As regras de estilo do [DeepL](https://developers.deepl.com/docs/api-reference/translate/openapi-spec-for-text-translation) são mais novas (API v3) e realmente mutáveis, o que é melhor. Você pode atualizar regras configuradas no lugar com `PUT /v3/style_rules/{style_id}/configured_rules`, e instruções personalizadas podem ser adicionadas ou removidas individualmente.

Nós ainda usamos o mesmo padrão `IsDirty`. Um `TenantStyleRuleList` tem um `DeepLStyleId` que mapeia para o identificador de tempo de execução de DeepL, mais `ConfiguredRulesJson` para as regras de formatação e uma coleção de entradas `TenantCustomInstruction` para diretivas de tradução de texto livre.

O verdadeiro poder está nessas instruções personalizadas. Cada uma é uma diretiva de linguagem simples, até 300 caracteres, que molda a forma como DeepL traduz. Exemplos reais dos nossos inquilinos:

- _"Utilize sempre a forma 'Sie', nunca 'du'"_ para uma firma de advogados alemã
- _"Traduzir 'deployment' como 'Bereitstellung', nunca 'Deployment'"_ para termos dependentes do contexto que vão para além de simples mapeamentos de glossários
- _"Utilizar a ortografia do inglês britânico (cor, organização, licença)"_ para uma empresa do Reino Unido que esteja a traduzir entre variantes do inglês
- _"Colocar símbolos de moeda após o valor numérico"_ para convenções europeias

Cada locatário pode ter instruções completamente diferentes por língua de destino, todas elas com a mesma chave API. O isolamento resulta do facto de cada chamada de tradução incluir apenas o `glossary_id` e o `style_id` pertencentes ao locatário requerente. Os recursos DeepL de outros locatários nunca são referenciados.

## A chamada de tradução: tudo compõe

Quando o orquestrador traduz um bloco, ele reúne todas as configurações específicas do locatário em uma única solicitação:

```csharp
var glossaryId = await _glossaryService
    .GetOrSyncDeepLGlossaryIdAsync(sourceLang, targetLang);
var styleId = await _styleRuleService
    .GetOrSyncStyleIdAsync(targetLang);
var formality = langConfig.Formality ?? "default";

var options = new TranslationOptions
{
    GlossaryId = glossaryId,
    StyleId = styleId,
    Formality = formality,
    Context = documentContext,
    ModelType = styleId != null ? "quality_optimized" : null
};
```

Todos os parâmetros aqui são específicos do locatário. O `glossaryId` foi resolvido por meio de uma consulta filtrada por locatário. O `styleId` foi resolvido da mesma forma. O `formality` provém do `TenantLanguageConfig`, também com escopo de locatário. Mesmo o `context` (parágrafos envolventes enviados para melhorar a qualidade da tradução, não facturados) provém do próprio documento do locatário.

Um aspeto que quero realçar: quando `style_id` é definido, DeepL utiliza automaticamente o seu modelo `quality_optimized`. Não é possível combinar regras de estilo com `latency_optimized`. Essa é uma restrição do DeepL, mas honestamente uma restrição razoável. Se está a investir em regras de estilo personalizadas, provavelmente quer a melhor qualidade de saída.

## Caching a nível de bloco: a base de dados como memória de tradução

Nós não chamamos DeepL para blocos que não foram alterados. O mecanismo de cache é a própria tabela `TranslationBlock`.

Cada `EntryBlock` de origem tem um `ContentHash`, um SHA256 do seu conteúdo semântico (com atributos de metadados como `blockId` e `deleted` eliminados). Cada `TranslationBlock` armazena o `SourceContentHash` que estava atual quando a tradução foi feita. Quando o bloco de origem muda, seu hash muda. O orquestrador compara os hashes e só coloca em fila os blocos com incompatibilidades.

A árvore de decisão para cada bloco se parece com isso:

1. **Hash corresponde, existe tradução** = ignorar (em cache, atualizado)
2. **Hash alterado, tradução automática, não bloqueado** = retraduzir automaticamente
3. **Hash alterado, editado por humanos ou bloqueado** = marcar como obsoleto, não sobrescrever

Este terceiro caso é crucial. Se o seu tradutor de alemão tiver refinado manualmente um parágrafo, não o apagamos só porque a fonte inglesa mudou. Marcamo-lo como obsoleto para que ele saiba que precisa de ser revisto, mas o texto traduzido permanece intacto.

O resultado prático: a edição de um parágrafo num documento de 30 parágrafos desencadeia exatamente uma chamada à API DeepL (bem, um lote que inclui um bloco). Os outros 29 parágrafos, em todas as línguas, já estão armazenados em cache e não custam nada.

## Porque não usar uma chave separada por inquilino?

Pensei seriamente nisso. Dar a cada inquilino a sua própria chave API DeepL, eliminando completamente o problema de isolamento.

Três razões para não o fazermos:

1. **Complexidade de faturação** Cada inquilino precisaria da sua própria subscrição de DeepL ou de uma forma de fornecer subcontas. O DeepL não oferece gestão de chaves multi-tenant nativamente.
2. **Eficiência de custos: Infraestrutura partilhada significa limites de taxas e descontos por volume partilhados. A nossa utilização agregada obtém melhores preços.
3. **Simplicidade operacional: uma chave para rodar, uma quota para monitorizar, uma integração para manter.

A desvantagem é que precisamos da camada de isolamento que descrevi. Mas como já temos consultas EF Core com escopo de locatário para todo o resto do sistema, adicioná-las a glossários e regras de estilo foi simples. O padrão já estava lá.

## O que realmente o protege

Para resumir as garantias de isolamento:

- **Entradas do glossário** são armazenadas em `TenantGlossary` (implementa `ITenantScoped`), filtradas pelos filtros de consulta global do EF Core. DeepL IDs de glossário são referências opacas que só são resolvidas dentro do contexto do locatário.
- As regras de estilo e as instruções personalizadas** seguem o mesmo padrão através do `TenantStyleRuleList`.
- O conteúdo traduzido** reside em `TranslationBlock`, com escopo através da cadeia `Entry` → `Hub`, que também tem escopo de locatário.
- O `SaveChanges` guard** define `TenantId` automaticamente em novas entidades e lança em escritas entre inquilinos.
- Não há `IgnoreQueryFilters()`** em código de produção. Sempre.

> O princípio de design é simples: DeepL vê IDs de recursos. O Rasepi vê entidades com escopo de locatário. O mapeamento entre eles nunca ultrapassa os limites do locatário porque a consulta que resolve o mapeamento é fisicamente incapaz de devolver os dados de outro locatário.

Se estiver a construir um SaaS multilocatário que se integra com APIs de terceiros sem suporte nativo do locatário, este padrão funciona bem. Trate a API externa como um mecanismo de execução sem estado, mantenha toda a configuração em seu próprio banco de dados com escopo de locatário, sincronize preguiçosamente e nunca confie em listagens de recursos externos para isolamento.
