Die meisten Dokumentationsplattformen sprechen über "Erweiterbarkeit" wie Fluggesellschaften über "Beinfreiheit". Technisch vorhanden, praktisch enttäuschend. Ich wollte, dass die Architektur von Rasepi wirklich erweiterbar ist, ohne unvorhersehbar zu werden, also haben wir drei ineinander greifende Systeme entwickelt: Plugins für Fähigkeiten, Action Guards für die Kontrolle und Pipelines für die deterministische Ausführung.
In diesem Beitrag erfährst du, wie jedes dieser Systeme in unserer aktuellen Codebasis funktioniert.
Das Plugin-System: modular aufgebaut
Jedes Plugin in Rasepi implementiert IPluginModule, eine einzelne Schnittstelle, die erklärt, was das Plugin ist, welche Dienste es braucht und welche Routen es offenlegt:
public interface IPluginModule
{
PluginManifest Manifest { get; }
void RegisterServices(IServiceCollection services);
void MapRoutes(IEndpointRouteBuilder routes);
}
Der PluginManifest ist ein reiner Datenblock. Er beschreibt das Plugin, ohne etwas auszuführen:
public sealed class PluginManifest
{
public required string Id { get; init; }
public required string Name { get; init; }
public required string Version { get; init; }
public string Description { get; init; }
public string Category { get; init; }
public IReadOnlyDictionary<string, string> UiContributions { get; init; }
public bool HasSettings { get; init; }
public bool HasEndpoints { get; init; }
public IReadOnlyList<string> Dependencies { get; init; }
}
Beachte UiContributions. Dieses Wörterbuch ordnet Frontend-Erweiterungspunkte den Komponentennamen zu, damit das Vue-Frontend weiß, welche UI-Komponenten jedes Plugin beisteuert (eine Schaltfläche in der Symbolleiste, ein Seitenleisten-Panel, eine Einstellungsseite).
Die Registrierung erfolgt in einer Zeile pro Plugin.
Beim Start registrieren wir Plugins über eine fließende API:
var pluginRegistry = new PluginRegistry();
pluginRegistry
.AddPlugin<WorkflowPluginModule>(builder.Services)
.AddPlugin<RulesPluginModule>(builder.Services)
.AddPlugin<RetentionPluginModule>(builder.Services)
.AddPlugin<ClassificationPluginModule>(builder.Services);
Jeder Aufruf instanziiert das Modul, speichert es in der Registry und ruft RegisterServices() auf, um seine Abhängigkeiten zu verdrahten. Nachdem die App gebaut wurde, werden in einer einzigen Zeile alle Plugin-Routen zugeordnet:
app.MapPluginRoutes(pluginRegistry);
Unter der Haube erhält jedes Plugin unter /api/plugins/{pluginId}/ eine skalierte Routengruppe, auf die automatisch eine Autorisierung angewendet wird.
Reales Beispiel: das Workflow-Plugin
Hier siehst du, wie ein echtes Plugin aussieht, das Modul Workflow & Genehmigungen:
public sealed class WorkflowPluginModule : IPluginModule
{
public const string PluginId = "workflow";
public PluginManifest Manifest { get; } = new()
{
Id = PluginId,
Name = "Workflow & Approvals",
Version = "1.0.0",
Description = "Adds approval workflows to entry publishing.",
Category = "Workflow",
HasSettings = true,
HasEndpoints = true,
UiContributions = new Dictionary<string, string>
{
["entry.toolbar.publish"] = "WorkflowPublishButton",
["entry.sidebar.status"] = "WorkflowStatusPanel",
["hub.admin.settings"] = "WorkflowHubSettings",
}
};
public void RegisterServices(IServiceCollection services)
{
services.AddScoped<IWorkflowService, WorkflowService>();
services.AddScoped<IActionGuard, WorkflowPublishGuard>();
}
public void MapRoutes(IEndpointRouteBuilder routes)
{
WorkflowEndpoints.Map(routes);
}
}
Die Kernplattform verweist niemals direkt auf WorkflowService oder WorkflowPublishGuard. Sie findet sie über den DI-Container. Das ist der Schlüssel zur Null-Kopplung. Die Kernapplikation berührt nie den Plugin-Code.
Action Guards: die Kontrollschicht
Plugins fügen Fähigkeiten hinzu. Action Guards entscheiden, ob diese Fähigkeit oder eine Kernaktion ausgeführt werden darf. Sie sind synchrone Prüfer, die Operationen vor der Ausführung abfangen.
Aktionswächter-Bewertungsablauf
Die Schnittstelle ist absichtlich minimal:
public interface IActionGuard
{
string PluginId { get; }
string? ActionName { get; } // null means guard ALL actions
Task<ActionGuardResult> EvaluateAsync(
ActionGuardContext context,
IServiceProvider services,
CancellationToken ct = default);
}
Wenn ActionName auf null steht, läuft die Wache für jede Aktion. Wenn sie auf etwas wie "Entry.Publish" gesetzt ist, fängt sie nur diese spezielle Aktion ab.
Die Kontext- und Ergebnisverträge
Jeder Guard erhält einen typisierten Kontext mit dem Aktionsnamen, dem Tenant, dem User, der Entität und einer Property Bag:
public sealed record ActionGuardContext(
string ActionName,
Guid TenantId,
Guid UserId,
Guid EntityId,
IReadOnlyDictionary<string, object?> Properties)
{
public T? Get<T>(string key) =>
Properties.TryGetValue(key, out var v) && v is T typed
? typed : default;
}
Und jeder Guard liefert ein vorhersehbares Ergebnis: allow, deny oder allow-with-modifications:
public sealed record ActionGuardResult
{
public bool IsAllowed { get; init; }
public string? ReasonCode { get; init; }
public string? Message { get; init; }
public IReadOnlyDictionary<string, object?>? Modifications { get; init; }
public static ActionGuardResult Allow() =>
new() { IsAllowed = true };
public static ActionGuardResult Deny(
string reasonCode, string message) =>
new() { IsAllowed = false, ReasonCode = reasonCode, Message = message };
}
Das Feld Modifications ist wichtig. Ein Guard kann eine Aktion genehmigen, aber einen Teil des Inhalts umschreiben (zum Beispiel Geheimnisse vor der Veröffentlichung löschen).
Kanonische Aktionsnamen
Wir definieren alle abfangbaren Aktionen als String-Konstanten, damit es keine Unklarheiten darüber gibt, worauf ein Guard abzielen kann:
public static class ActionNames
{
public static class Entry
{
public const string Create = "Entry.Create";
public const string Save = "Entry.Save";
public const string Publish = "Entry.Publish";
public const string Delete = "Entry.Delete";
public const string Archive = "Entry.Archive";
public const string Renew = "Entry.Renew";
}
public static class Hub
{
public const string Create = "Hub.Create";
public const string Delete = "Hub.Delete";
public const string TransferOwnership = "Hub.TransferOwnership";
}
public static class Translation
{
public const string Create = "Translation.Create";
public const string Publish = "Translation.Publish";
}
}
Reales Beispiel: Veröffentlichen ohne Genehmigung blockieren
Das Workflow-Plugin registriert einen Guard, der Entry.Publish abfängt:
public sealed class WorkflowPublishGuard : IActionGuard
{
public string PluginId => WorkflowPluginModule.PluginId;
public string? ActionName => ActionNames.Entry.Publish;
public async Task<ActionGuardResult> EvaluateAsync(
ActionGuardContext context,
IServiceProvider services,
CancellationToken ct = default)
{
var db = services.GetRequiredService<RasepiDbContext>();
var entry = await db.Entries
.AsNoTracking()
.FirstOrDefaultAsync(e => e.Id == context.EntityId, ct);
if (entry is null)
return ActionGuardResult.Allow();
var workflowService = services.GetRequiredService<IWorkflowService>();
var check = await workflowService
.CheckPublishAllowedAsync(entry.Id, entry.HubId);
if (check.IsAllowed)
return ActionGuardResult.Allow();
return ActionGuardResult.Deny(
"workflow.approval_required",
check.Message ?? "Approval required before publishing.");
}
}
Die Kernplattform weiß nichts über Genehmigungsworkflows. Sie ruft lediglich Entry.Publish über die Pipeline auf, und der Guard blockiert ihn, wenn der Workflow noch nicht abgeschlossen ist.
Die Aktionspipeline: wo alles zusammenläuft
Der ActionPipeline ist der einzige Ausführungspfad für alle bewachten Vorgänge. Sie entscheidet, welche Guards gelten, wertet sie aus und führt die Aktion entweder aus oder blockiert sie.
public sealed class ActionPipeline : IActionPipeline
{
public async Task<ActionPipelineResult> ExecuteAsync(
string actionName,
ActionGuardContext context,
Func<Task> action,
CancellationToken ct = default)
{
var result = await EvaluateAsync(actionName, context, ct);
if (!result.IsAllowed) return result;
await action(); // All guards passed — execute
return result; // Return modifications for caller
}
}
Die EvaluateAsync-Methode erledigt die schwere Arbeit:
public async Task<ActionPipelineResult> EvaluateAsync(
string actionName,
ActionGuardContext context,
CancellationToken ct = default)
{
// 1. Which plugins are enabled for this tenant?
var enabledPlugins = await _resolver.GetEnabledPluginIdsAsync();
// 2. Which guards match this action?
var applicable = _guards
.Where(g => enabledPlugins.Contains(g.PluginId))
.Where(g => g.ActionName == null || g.ActionName == actionName)
.ToList();
// 3. Evaluate each guard
var denials = new List<ActionGuardResult>();
var modifications = new List<ActionGuardResult>();
foreach (var guard in applicable)
{
try
{
var guardResult = await guard.EvaluateAsync(context, _services, ct);
if (!guardResult.IsAllowed)
denials.Add(guardResult);
else if (guardResult.Modifications?.Count > 0)
modifications.Add(guardResult);
}
catch (Exception ex)
{
_logger.LogError(ex, "Guard threw. Treating as Allow.");
}
}
// 4. Any denial blocks the whole action
if (denials.Count > 0)
return ActionPipelineResult.Blocked(denials);
return modifications.Count > 0
? ActionPipelineResult.Allowed(modifications)
: ActionPipelineResult.Allowed();
}
Hier gibt es drei wichtige Designentscheidungen:
- Per-Tenant-Auflösung. Der
TenantPluginResolverprüft, welche Plugins jeder Tenant installiert und aktiviert hat. Ein Guard für ein deaktiviertes Plugin wird nie ausgeführt. - Alle müssen passieren. Wenn ein Guard verweigert, wird die Aktion blockiert. Dies ist eine bewusste Sicherheitsmaßnahme.
- Wächterfehler scheitern offen. Wenn ein Wächter eine Ausnahme auslöst, wird sie protokolliert und als
Allow()behandelt. Dadurch wird verhindert, dass ein fehlerhaftes Plugin die gesamte Plattform sperrt.
Pro-Tenant-Plugin-Auflösung
Der Resolver fragt die Tabelle TenantPluginInstallations ab (die durch die globalen Abfragefilter von EF automatisch auf den aktuellen Tenant beschränkt wird):
public sealed class TenantPluginResolver : ITenantPluginResolver
{
public async Task<IReadOnlySet<string>> GetEnabledPluginIdsAsync(
CancellationToken ct = default)
{
if (_cache is not null) return _cache;
var ids = await _db.TenantPluginInstallations
.Where(i => i.IsEnabled)
.Select(i => i.PluginId)
.ToListAsync(ct);
_cache = ids.ToHashSet();
return _cache;
}
}
Ereignisgesteuerte Seiteneffekte
Aktionen sind synchron. Seiteneffekte sind es nicht. Nachdem eine Aktion abgeschlossen ist, veröffentlicht der Dienst ein Domänenereignis:
await _eventPublisher.PublishAsync(
EventNames.Entry.Created, entry.Id, new { entry.OriginalLanguage });
Die Ereignisse werden in einen In-Memory-Kanal eingereiht und von einem EventConsumerWorker im Hintergrund verarbeitet. Der Worker leitet die Ereignisse an mehrere Systeme weiter:
- Aktivitätsverfolgung. Protokolliert, wer was wann getan hat.
- Übersetzungsabrechnung. Verfolgt die Kosten pro Anbieter
- Plugin-Event-Handler. Jedes Plugin kann Domain-Events abonnieren
Plugin-Ereignishandler implementieren IPluginEventHandler:
public interface IPluginEventHandler
{
string PluginId { get; }
IReadOnlyList<string> SubscribedEvents { get; }
Task HandleAsync(
string eventName, Guid entityId,
Guid? tenantId, Guid? userId,
string payloadJson, IServiceProvider services,
CancellationToken ct = default);
}
Der Worker ruft nur Handler auf, deren Plugin für den Tenant aktiviert ist. Das bedeutet, dass die Nebeneffekte von Plugin A niemals in einen Tenant gelangen, in dem nur Plugin B installiert ist.
Die Übersetzungsmaschine auf Blockebene
Hier macht sich die Architektur am deutlichsten bemerkbar.
Herkömmliche Plattformen übersetzen ganze Dokumente. Wir übersetzen einzelne Blöcke: Absätze, Überschriften, Listenelemente. Wenn ein Benutzer einen Absatz in einem Dokument mit 50 Blöcken bearbeitet, muss nur dieser Absatz neu übersetzt werden. Das ist der Grund für unsere 94%ige Kostenersparnis.
Wie Blöcke aus TipTap JSON erstellt werden
Wenn ein Benutzer ein Dokument speichert, sendet der TipTap-Editor JSON wie folgt:
{
"type": "doc",
"content": [
{
"type": "paragraph",
"attrs": { "blockId": "a1b2c3d4-..." },
"content": [{ "type": "text", "text": "Hello world" }]
}
]
}
Der BlockTranslationService parst dieses JSON und erstellt einzelne EntryBlock Datensätze:
public async Task<List<EntryBlock>> CreateBlocksFromDocumentAsync(
Guid entryId, string language, string contentJson,
int version, Guid userId)
{
var doc = JsonDocument.Parse(contentJson);
var content = doc.RootElement.GetProperty("content");
int position = 0;
foreach (var node in content.EnumerateArray())
{
var blockType = node.GetProperty("type").GetString();
var blockJson = JsonSerializer.Serialize(node);
// Strip metadata attrs before hashing
var hashInput = StripBlockMetaAttrs(blockJson);
var block = new EntryBlock
{
Id = ExtractOrGenerateBlockId(node),
EntryId = entryId,
Language = language,
Position = position++,
BlockType = blockType,
ContentJson = blockJson,
ContentHash = CalculateContentHash(hashInput),
IsNoTranslate = ExtractNoTranslateFlag(node),
Version = version,
};
_context.EntryBlocks.Add(block);
}
await _context.SaveChangesAsync();
return blocks;
}
SHA256 Hashing für Stale-Erkennung
Der Inhalts-Hash ist das Herzstück der Stale Detection. Wir hashen den Blockinhalt (nachdem wir Metadatenattribute wie blockId und deleted entfernt haben) mit SHA256:
private string CalculateContentHash(string content)
{
using var sha256 = SHA256.Create();
var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(content));
return Convert.ToHexString(hashBytes);
}
Wenn sich ein Quellblock ändert, ändert sich auch sein Hash. Das System vergleicht dann den SourceContentHash jedes Übersetzungsblocks mit dem aktuellen Quell-Hash und markiert Unstimmigkeiten als Stale:
public async Task MarkTranslationsAsStaleAsync(List<Guid> changedBlockIds)
{
var affected = await _context.TranslationBlocks
.Where(t => changedBlockIds.Contains(t.SourceBlockId))
.ToListAsync();
foreach (var translation in affected)
{
translation.Status = TranslationStatus.Stale;
translation.UpdatedAt = DateTime.UtcNow;
}
await _context.SaveChangesAsync();
}
Strukturanpassung
Übersetzer können die Blocktypen in verschiedenen Sprachen ändern. Eine englische Aufzählungsliste kann zu einer deutschen nummerierten Liste werden, eine kulturelle Präferenz. Das System verfolgt dies:
var translation = new TranslationBlock
{
SourceBlockId = sourceBlockId,
Language = targetLanguage,
BlockType = translatedBlockType,
SourceBlockType = sourceBlock.BlockType,
IsStructureAdapted = translatedBlockType != sourceBlock.BlockType,
SourceContentHash = sourceBlock.ContentHash,
Status = TranslationStatus.UpToDate,
};
Übersetzungsanbieter als Plugins
Externe Übersetzungsdienste (DeepL, Google Translate, etc.) werden über ITranslationProviderPlugin eingebunden:
public interface ITranslationProviderPlugin : IRasepiPlugin
{
string[] GetSupportedLanguages();
Task<string> TranslateAsync(
string text, string sourceLanguage, string targetLanguage);
Task<TranslationBatchResult> TranslateBatchAsync(
Dictionary<string, string> texts,
string sourceLanguage, string targetLanguage);
}
Die Batch-Methode erhält ein Wörterbuch mit den Block-IDs zum Inhalt, übersetzt sie alle und gibt die Übersetzungen mit einer abgerechneten Zeichenanzahl zurück. Da wir nur veraltete Blöcke und nicht das gesamte Dokument versenden, bleiben die Kosten minimal.
Mieterisolierung: das unsichtbare Sicherheitsnetz
Jedes der oben beschriebenen Systeme läuft innerhalb einer strengen Tenant Isolation.
Der TenantContextMiddleware löst bei jeder Anfrage den Tenant aus dem JWT auf und verifiziert die Mitgliedschaft:
public async Task InvokeAsync(
HttpContext context, TenantContext tenantContext, RasepiDbContext db)
{
var tenantIdClaim = context.User.FindFirstValue("tenant_id");
var userIdClaim = context.User.FindFirstValue(ClaimTypes.NameIdentifier);
// Populate scoped context
tenantContext.TenantId = Guid.Parse(tenantIdClaim);
tenantContext.UserId = Guid.Parse(userIdClaim);
// Verify membership — fail closed
var membership = await db.TenantMemberships
.Where(m => m.TenantId == tenantContext.TenantId
&& m.UserId == tenantContext.UserId)
.FirstOrDefaultAsync();
if (membership == null)
{
context.Response.StatusCode = 401;
return; // No membership = no access
}
}
Die globalen Abfragefilter von Entity Framework stellen sicher, dass die Datenbankschicht die Filterung nach Tenant automatisch vornimmt, selbst wenn ein Entwickler sie vergisst:
modelBuilder.Entity<Hub>()
.HasQueryFilter(h => h.TenantId == _tenantContext.TenantId);
Das Ergebnis: db.Hubs.ToListAsync() gibt immer nur die Hubs des aktuellen Tenants zurück. Für Datenlecks muss der Abfragefilter aktiv umgangen werden, was in unserer Codebasis verboten ist.
Das vollständige Bild
Wenn ein Nutzer bei einem Eintrag auf "Veröffentlichen" klickt, passiert folgendes:
- Die Anfrage wird eingegeben Die Authentifizierung validiert den JWT,
TenantContextMiddlewarelöst auf und verifiziert den Tenant. - Controller ruft die Pipeline auf.
IActionPipeline.ExecuteAsync("Entry.Publish", context, action) - Pipeline löst Wachen auf. Fragt ab, welche Plugins der Tenant aktiviert hat, und wählt die entsprechenden Wachen aus.
- Guards bewerten. Der Workflow-Guard prüft auf Genehmigungen, der Retention-Guard auf Richtlinien, der Rules-Guard validiert den Inhalt. Alle bestanden? Der Eintrag wird veröffentlicht.
- Ereignisse werden ausgelöst. Das Ereignis
Entry.Publishedwird in die Warteschlange gestellt. Ein Hintergrundworker protokolliert die Aktivitäten, aktualisiert die Übersetzungsabrechnung und ruft die Plugin-Event-Handler auf. - Übersetzungen von Blöcken werden überprüft. Veraltete Blöcke werden zur Neuübersetzung identifiziert.
Jede Ebene erledigt ihre Arbeit. Keine Schicht greift in eine andere ein. Das ist die Architektur.
Wir haben sie nicht gebaut, weil Erweiterbarkeit in Mode ist. Wir haben sie gebaut, weil eine Dokumentationsplattform, die sich nicht an die Arbeitsabläufe der einzelnen Teams anpassen kann, irgendwann durch eine ersetzt wird, die das kann. Und eine Plattform, die sich ohne Leitplanken anpasst, wird irgendwann etwas kaputt machen, das wichtig ist.