Skip to content

Decorator Pattern — Cross-Cutting Concerns

The Decorator pattern dynamically adds responsibilities to an object without modifying its class. Each decorator wraps the original object and enriches its behavior (serialization, encryption, caching, anti-stampede protection).

classDiagram
    class IFusionCacheSerializer {
        +SerializeAsync()
        +DeserializeAsync()
    }

    class EncryptingFusionCacheSerializer {
        -inner : IFusionCacheSerializer
        -encryptor : ICacheValueEncryptor
        +SerializeAsync()
        +DeserializeAsync()
    }

    class ILocalizationOverrideStore {
        +GetOverridesAsync()
        +SetOverrideAsync()
    }

    class CachedLocalizationOverrideStore {
        -inner : ILocalizationOverrideStore
        -cache : IFusionCache
        +GetOverridesAsync()
        +SetOverrideAsync()
    }

    EncryptingFusionCacheSerializer --> IFusionCacheSerializer : decorates
    CachedLocalizationOverrideStore --> ILocalizationOverrideStore : decorates
DecoratorFileTargetAdded responsibilities
EncryptingFusionCacheSerializersrc/Granit.Caching/Internal/EncryptingFusionCacheSerializer.csIFusionCacheSerializerAES-256-GCM authenticated encryption of L2 (Redis) cache values
CachedLocalizationOverrideStoresrc/Granit.Localization/Internal/CachedLocalizationOverrideStore.csILocalizationOverrideStoreFusionCache with per-tenant invalidation

Custom variant — Conditional encryption: EncryptingFusionCacheSerializer wraps the inner serializer and applies AES-256-GCM authenticated encryption to all values written to L2 (Redis). L1 (in-process) stores unencrypted objects. Per-type encryption is resolved via CacheEncryptionResolver using the [CacheEncrypted] attribute.

Separating concerns (encryption, caching) from business logic allows testing and configuring them independently. The localization decorator avoids hitting the database on every translation resolution.

// The consumer uses IFusionCache -- the decorator is transparent
IFusionCache cache = serviceProvider
.GetRequiredService<IFusionCache>();
PatientDto patient = await cache.GetOrSetAsync(
$"patient:{patientId}",
async (ctx, ct) => await db.Patients.FindAsync([patientId], ct),
cancellationToken: cancellationToken);
// Behind the scenes:
// 1. Check L1 (in-process memory)
// 2. If miss -> check L2 (Redis, decrypted via EncryptingFusionCacheSerializer)
// 3. If miss -> native stampede protection (only one factory call)
// 4. Execute the factory
// 5. Store in L1 + serialize -> encrypt -> store in L2 (Redis)