Every time I explain the translation architecture behind Rasepi to another developer, I get the same question: "Wait — all your tenants share one DeepL API key? How do you keep their glossaries and style rules from leaking into each other?"
It's a fair question. And the answer involves more design work than you'd expect.
I wrote about the full translation pipeline in a previous post — the block-level hashing, the orchestrator, the whole flow from document save to translated output. This post zooms into a specific sub-problem: how you take a third-party API that has no concept of tenants and build proper tenant isolation on top of it.
DeepL doesn't know about your customers
DeepL's API authenticates with a single API key. Everything created under that key — glossaries, style rule lists, translation history — belongs to the same account. There's no concept of "this glossary belongs to Customer A" on DeepL's side.
When you call GET /v2/glossaries, you get all glossaries from all tenants. When you create a style rule list, it lives in the same namespace as everything else. The API is flat.
For a self-hosted product where every customer runs their own instance with their own DeepL key, that's fine. For a multi-tenant SaaS where you manage the infrastructure? You need an isolation layer, and you need to build it yourself.
The database is the source of truth
My core design decision here: the database owns all glossary content and style rule configuration. DeepL is a runtime execution target, nothing more.
Every TenantGlossary and TenantStyleRuleList entity implements ITenantScoped, which means EF Core global query filters automatically scope all reads to the current tenant. A query for glossaries in Tenant A's request context will never return Tenant B's entries. This is the same isolation pattern I use everywhere in Rasepi, enforced at the ORM level — I didn't build anything special for translations specifically.
Here's what makes this interesting. When a tenant edits a glossary term, I do not immediately call DeepL. I update the database row and set IsDirty = true. That's it. The actual DeepL glossary gets created (or recreated) lazily, right before the next translation needs it.
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(
$"tenant-{glossary.Id}",
glossary.SourceLanguage,
glossary.TargetLanguage,
entries);
glossary.DeepLGlossaryId = created.GlossaryId;
glossary.IsDirty = false;
glossary.LastSyncedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
return glossary.DeepLGlossaryId;
}
The query filter on TenantGlossaries does the isolation. The IsDirty flag does the lazy sync. The naming convention (tenant-{glossary.Id}) exists only for debugging in the DeepL dashboard — it has no functional purpose in the code.
Why lazy? Because DeepL v2 glossaries are immutable. You cannot edit them. Any change means delete and recreate. If a team imports a CSV with 200 terms and then fixes a typo in one entry, I don't want to delete and recreate the DeepL glossary twice. I just set IsDirty both times, and the single recreate happens when the next translation runs. Batching for free.
Style rules: same pattern, different API
DeepL's style rules are newer (v3 API) and actually mutable, which is nicer. You can update configured rules in place with PUT /v3/style_rules/{style_id}/configured_rules, and custom instructions can be individually added or removed.
I still use the same IsDirty pattern though, mostly for consistency. A TenantStyleRuleList has a DeepLStyleId that maps to DeepL's runtime identifier, plus ConfiguredRulesJson for the formatting rules and a collection of TenantCustomInstruction entries for free-text translation directives.
The real power is in those custom instructions. Each one is a plain-language directive, up to 300 characters, that shapes how DeepL translates. Some real examples I've seen work well:
- "Always use 'Sie' form, never 'du'" — for formal German contexts
- "Translate 'deployment' as 'Bereitstellung', never 'Deployment'" — context-dependent terms that go beyond simple glossary mappings
- "Use British English spelling (colour, organisation, licence)" — when translating between English variants
- "Put currency symbols after the numeric amount" — European formatting conventions
Each tenant can configure completely different instructions per target language, all behind the same API key. The isolation comes from the fact that every translation call includes only the glossary_id and style_id belonging to the requesting tenant. Other tenants' DeepL resources are never referenced — they're not even queried.
The translation call: everything composes
When the orchestrator translates a block, it assembles all tenant-specific settings into a single request:
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
};
Every parameter here is tenant-scoped. The glossaryId was resolved through a tenant-filtered query. The styleId was resolved the same way. The formality comes from TenantLanguageConfig, also tenant-scoped. Even the context — surrounding paragraphs sent to improve translation quality, which DeepL doesn't bill for — comes from the tenant's own document.
One thing worth noting: when style_id is set, DeepL automatically uses their quality_optimized model. You can't combine style rules with latency_optimized. That's a DeepL constraint, but honestly a reasonable trade-off. If you're investing in custom style rules, you probably want the best quality output anyway.
Block-level caching: your database as translation memory
I don't call DeepL for blocks that haven't changed. The caching mechanism is the TranslationBlock table itself.
Every source EntryBlock has a ContentHash — a SHA256 of its semantic content, with metadata attributes like blockId and deleted stripped out. Every TranslationBlock stores the SourceContentHash that was current when the translation was made. When the source block changes, its hash changes. The orchestrator compares hashes and only queues blocks with mismatches.
The decision tree for each block:
- Hash matches, translation exists → skip (cached, up-to-date)
- Hash changed, machine-translated, not locked → retranslate automatically
- Hash changed, human-edited or locked → mark as Stale, do not overwrite
That third case matters a lot. If a translator manually refined a paragraph, I don't want to blow it away just because the English source changed. Flag it as stale so the team knows it needs review, but leave the translated text intact.
The practical result: editing one paragraph in a 30-paragraph document triggers exactly one DeepL API call (one batch, one block). The other 29 paragraphs across all languages are already cached. They don't cost anything.
Why not give each tenant their own key?
I considered it. Give each tenant their own DeepL API key, eliminate the isolation problem entirely.
Three reasons I didn't go that route:
- Billing complexity. Every tenant would need their own DeepL subscription or a way to provision sub-accounts. DeepL doesn't offer multi-tenant key management natively. Managing that onboarding flow is more overhead than building an isolation layer.
- Cost efficiency. Shared infrastructure means shared volume. Aggregate usage gets better pricing than dozens of individual small accounts.
- Operational simplicity. One key to rotate, one quota to monitor, one integration to maintain. That's genuinely valuable.
The trade-off is that you need the isolation layer I described. But if you already have tenant-scoped EF Core queries for everything else in your system — which you should — adding it to glossaries and style rules is straightforward. You're applying an existing pattern, not inventing a new one.
What actually isolates what
To summarize the guarantees I rely on:
- Glossary entries are stored in
TenantGlossary(implementsITenantScoped), filtered by EF Core global query filters. DeepL glossary IDs are opaque references that only get resolved within tenant context. - Style rules and custom instructions follow the same pattern through
TenantStyleRuleList. - Translated content lives in
TranslationBlock, scoped via its parentEntry→Hubchain, which is also tenant-scoped. - The
SaveChangesguard setsTenantIdautomatically on new entities and throws on cross-tenant writes. - No
IgnoreQueryFilters()in production code.
DeepL sees resource IDs. My application sees tenant-scoped entities. The mapping between them never crosses tenant boundaries because the query that resolves the mapping is physically incapable of returning another tenant's data.
If you're building a multi-tenant SaaS on top of third-party APIs that weren't designed for multi-tenancy — and there are a lot of them — this approach works well. Treat the external API as a stateless execution engine. Keep all configuration in your own tenant-scoped database. Sync lazily. And never trust external resource listings for isolation, because those listings are flat.