Skip to content

Composing Decorators ​

Decorator disks extend DiskDecorator and wrap an inner disk to add behavior (caching, encryption, retries, etc.). You can stack multiple decorators to build sophisticated storage pipelines. The order matters — each decorator processes operations before delegating to the next.

How Stacking Works ​

Decorators form a chain. The outermost decorator receives the call first, and the innermost decorator delegates to the actual storage driver:

Client Request
     │
     ▼
┌─────────────┐
│  OtelDisk   │  ← Outermost: traces everything
├─────────────┤
│  QuotaDisk  │  ← Checks quota before write
├─────────────┤
│ RetryDisk   │  ← Retries on transient failures
├─────────────┤
│ CachedDisk  │  ← Returns cached data if available
├─────────────┤
│EncryptedDisk│  ← Encrypts/decrypts content
├─────────────┤
│  ScopedDisk │  ← Prefixes path with tenant ID
├─────────────┤
│   S3Disk    │  ← Actual storage driver
└─────────────┘

Read flow (top → bottom): OtelDisk starts a span → QuotaDisk passes through (no quota for reads) → RetryDisk wraps with retry → CachedDisk checks cache → EncryptedDisk decrypts → ScopedDisk prefixes path → S3Disk fetches from S3.

Write flow (top → bottom): OtelDisk starts a span → QuotaDisk checks remaining quota → RetryDisk wraps with retry → CachedDisk invalidates cache → EncryptedDisk encrypts content → ScopedDisk prefixes path → S3Disk writes to S3.

Stacking in Configuration ​

ts
import {
  StorageModule,
  CachedDisk,
  RetryDisk,
  EncryptedDisk,
  OtelDisk,
  ScopedDisk,
  QuotaDisk,
} from '@fozooni/nestjs-storage';

// Programmatic stacking
const s3Disk = storage.disk('s3');
const scoped = new ScopedDisk(s3Disk, 'tenants/acme');
const encrypted = new EncryptedDisk(scoped, { algorithm: 'aes-256-gcm', key: SECRET });
const cached = new CachedDisk(encrypted, { ttl: 300 });
const retried = new RetryDisk(cached, { maxRetries: 3 });
const quota = new QuotaDisk(retried, { maxBytes: 10_737_418_240 });
const traced = new OtelDisk(quota, { serviceName: 'my-app' });

// `traced` is the entry point — it delegates through the full chain

Stacking Rules ​

Rule 1: OtelDisk at the Top ​

Place OtelDisk as the outermost decorator for complete trace coverage of all operations, including retries, cache hits, and encryption.

ts
// CORRECT — OtelDisk sees everything
const disk = new OtelDisk(
  new RetryDisk(
    new CachedDisk(baseDisk, cacheOpts),
    retryOpts,
  ),
  otelOpts,
);

// WRONG — OtelDisk misses retry and cache operations
const disk = new RetryDisk(
  new OtelDisk(
    new CachedDisk(baseDisk, cacheOpts),
    otelOpts,
  ),
  retryOpts,
);

Rule 2: QuotaDisk at the Top (Below OtelDisk) ​

QuotaDisk should reject oversized writes before any other processing. Placing it below encryption would check encrypted sizes (larger than original).

ts
// CORRECT — Quota checks original content size
new OtelDisk(
  new QuotaDisk(
    new EncryptedDisk(baseDisk, encOpts),
    { maxBytes: 10_737_418_240 },
  ),
);

// WRONG — Quota checks encrypted size (inflated by IV + auth tag)
new OtelDisk(
  new EncryptedDisk(
    new QuotaDisk(baseDisk, { maxBytes: 10_737_418_240 }),
    encOpts,
  ),
);

Rule 3: CachedDisk Above RetryDisk ​

Cache should return data without triggering retries. If cache is below retry, a cache hit still gets wrapped in retry logic unnecessarily.

ts
// CORRECT — Cache hit returns immediately, no retry needed
new CachedDisk(
  new RetryDisk(baseDisk, retryOpts),
  cacheOpts,
);

// SUBOPTIMAL — Cache hit still goes through retry wrapper
new RetryDisk(
  new CachedDisk(baseDisk, cacheOpts),
  retryOpts,
);

Rule 4: EncryptedDisk Below CachedDisk ​

Encrypted content is cached as-is. This means the cache stores encrypted data, which is both secure (cache contains only ciphertext) and correct (encrypted sizes match what CachedDisk reports).

ts
// CORRECT — Cache stores encrypted content
new CachedDisk(
  new EncryptedDisk(baseDisk, encOpts),
  cacheOpts,
);

// WRONG — Cache stores plaintext (security issue)
new EncryptedDisk(
  new CachedDisk(baseDisk, cacheOpts),
  encOpts,
);

Rule 5: ScopedDisk Near the Bottom ​

ScopedDisk prefixes paths. Place it close to the actual disk so all operations (including cache keys) use the full path.

ts
// CORRECT — All layers see the full scoped path
new CachedDisk(
  new ScopedDisk(baseDisk, 'tenants/acme'),
  cacheOpts,
);

Rule 6: VersionedDisk Below CachedDisk ​

VersionedDisk creates snapshots on write. It should sit below the cache so version snapshots happen on actual writes, not cache invalidations.

ts
// CORRECT
new CachedDisk(
  new VersionedDisk(baseDisk),
  cacheOpts,
);
PositionDecoratorReason
1 (top)OtelDiskFull observability of all operations
2QuotaDiskReject oversized writes early
3CachedDiskShort-circuit reads from cache
4RetryDiskRetry transient failures to storage
5ReplicatedDiskWrite to multiple destinations
6EncryptedDiskEncrypt before storage
7VersionedDiskSnapshot on actual writes
8ScopedDiskPrefix paths for multi-tenancy
9 (bottom)Actual DiskS3Disk, GcsDisk, LocalDisk, etc.

Common Patterns ​

Pattern 1: Security-First ​

Encrypt data at rest with caching for performance and retries for reliability.

ts
import {
  EncryptedDisk,
  CachedDisk,
  RetryDisk,
} from '@fozooni/nestjs-storage';

function createSecureDisk(baseDisk: FilesystemContract) {
  const encrypted = new EncryptedDisk(baseDisk, {
    algorithm: 'aes-256-gcm',
    key: process.env.ENCRYPTION_KEY!,
  });

  const cached = new CachedDisk(encrypted, {
    ttl: 600, // 10 minutes
    ttlByMethod: {
      exists: 120,
      getMetadata: 300,
    },
  });

  return new RetryDisk(cached, {
    maxRetries: 3,
    baseDelay: 200,
    maxDelay: 5000,
  });
}

Pattern 2: High Availability ​

Observability, retries, and cross-region replication.

ts
import {
  OtelDisk,
  RetryDisk,
  ReplicatedDisk,
} from '@fozooni/nestjs-storage';

function createHADisk(
  primaryDisk: FilesystemContract,
  replicaDisk: FilesystemContract,
) {
  const replicated = new ReplicatedDisk(
    [primaryDisk, replicaDisk],
    { strategy: 'all' }, // Write to both, read from primary
  );

  const retried = new RetryDisk(replicated, {
    maxRetries: 5,
    baseDelay: 500,
    maxDelay: 10_000,
    factor: 2,
    jitter: true,
  });

  return new OtelDisk(retried, {
    serviceName: 'storage-ha',
  });
}

Pattern 3: Multi-Tenant SaaS ​

Quota enforcement, path scoping, and encryption per tenant.

ts
import {
  QuotaDisk,
  ScopedDisk,
  EncryptedDisk,
} from '@fozooni/nestjs-storage';

function createTenantDisk(
  baseDisk: FilesystemContract,
  tenantId: string,
  tenantKey: string,
  quotaStore: QuotaStore,
) {
  const scoped = new ScopedDisk(baseDisk, `tenants/${tenantId}`);

  const encrypted = new EncryptedDisk(scoped, {
    algorithm: 'aes-256-gcm',
    key: tenantKey, // Per-tenant encryption key
  });

  return new QuotaDisk(encrypted, {
    maxBytes: 10 * 1024 * 1024 * 1024, // 10 GB per tenant
    prefix: tenantId,
    store: quotaStore,
  });
}

Pattern 4: Content Platform ​

Versioned content with caching.

ts
import { VersionedDisk, CachedDisk } from '@fozooni/nestjs-storage';

function createContentDisk(baseDisk: FilesystemContract) {
  const versioned = new VersionedDisk(baseDisk);

  return new CachedDisk(versioned, {
    ttl: 3600, // 1 hour — content changes infrequently
    ttlByMethod: {
      exists: 1800,
      url: 7200,
    },
  });
}

Pattern 5: Media Routing ​

Route files to different disks based on type, with a default fallback.

ts
import {
  RouterDisk,
  byExtension,
  byMimeType,
} from '@fozooni/nestjs-storage';

function createMediaRouter(
  imageDisk: FilesystemContract,
  videoDisk: FilesystemContract,
  documentDisk: FilesystemContract,
  defaultDisk: FilesystemContract,
) {
  return new RouterDisk([
    byExtension(['.jpg', '.jpeg', '.png', '.gif', '.webp'], imageDisk),
    byMimeType(['video/mp4', 'video/webm', 'video/quicktime'], videoDisk),
    byExtension(['.pdf', '.doc', '.docx', '.xls', '.xlsx'], documentDisk),
    // Default — everything else
    {
      match: () => true,
      disk: defaultDisk,
    },
  ]);
}

Full Enterprise Example ​

Combining 6+ decorators for a production multi-tenant storage system:

ts
import { Injectable, Scope, Inject } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { Request } from 'express';
import {
  InjectStorage,
  StorageService,
  FilesystemContract,
  OtelDisk,
  QuotaDisk,
  CachedDisk,
  RetryDisk,
  EncryptedDisk,
  VersionedDisk,
  ScopedDisk,
  QuotaStore,
} from '@fozooni/nestjs-storage';

@Injectable({ scope: Scope.REQUEST })
export class EnterpriseTenantDisk {
  private disk: FilesystemContract;

  constructor(
    @InjectStorage()
    private readonly storage: StorageService,
    @Inject(REQUEST)
    private readonly request: Request,
    @Inject('QUOTA_STORE')
    private readonly quotaStore: QuotaStore,
    @Inject('KEY_VAULT')
    private readonly keyVault: { getKey(tenantId: string): string },
  ) {
    const tenantId = this.request.headers['x-tenant-id'] as string;
    this.disk = this.buildDiskChain(tenantId);
  }

  private buildDiskChain(tenantId: string): FilesystemContract {
    // Layer 9 (bottom): Actual S3 disk
    const s3 = this.storage.disk('s3');

    // Layer 8: Scope to tenant directory
    const scoped = new ScopedDisk(s3, `tenants/${tenantId}`);

    // Layer 7: Version all writes for audit trail
    const versioned = new VersionedDisk(scoped);

    // Layer 6: Encrypt with tenant-specific key
    const encrypted = new EncryptedDisk(versioned, {
      algorithm: 'aes-256-gcm',
      key: this.keyVault.getKey(tenantId),
    });

    // Layer 5: Retry transient S3 failures
    const retried = new RetryDisk(encrypted, {
      maxRetries: 3,
      baseDelay: 200,
      maxDelay: 5000,
      factor: 2,
      jitter: true,
      retryOn: (error) => {
        const code = (error as any)?.code;
        return ['ECONNRESET', 'ETIMEDOUT', 'SlowDown'].includes(code);
      },
    });

    // Layer 4: Cache reads (stores encrypted content — secure)
    const cached = new CachedDisk(retried, {
      ttl: 300,
      ttlByMethod: {
        exists: 60,
        getMetadata: 120,
        url: 600,
      },
    });

    // Layer 3: Enforce per-tenant quota
    const quota = new QuotaDisk(cached, {
      maxBytes: 50 * 1024 * 1024 * 1024, // 50 GB
      prefix: tenantId,
      store: this.quotaStore,
    });

    // Layer 1 (top): Full OpenTelemetry tracing
    return new OtelDisk(quota, {
      serviceName: 'enterprise-storage',
      attributes: { 'tenant.id': tenantId },
    });
  }

  getDisk(): FilesystemContract {
    return this.disk;
  }
}

Usage in a controller:

ts
@Controller('files')
export class TenantFileController {
  constructor(
    private readonly tenantDisk: EnterpriseTenantDisk,
  ) {}

  @Post()
  async upload(@Body() body: { path: string; content: string }) {
    const disk = this.tenantDisk.getDisk();
    await disk.put(body.path, body.content);
    return { url: await disk.url(body.path) };
  }

  @Get(':path(*)')
  async read(@Param('path') path: string) {
    const disk = this.tenantDisk.getDisk();
    return disk.get(path);
  }
}

Decorator Conflicts ​

Some decorator combinations are incompatible:

CombinationIssueWorkaround
EncryptedDisk + presignedPost()Encryption is server-side; presigned POST bypasses itUpload to unencrypted staging disk, then copy
EncryptedDisk + getRange()Cannot decrypt a partial byte rangeRead full file, then slice
CachedDisk + VersionedDisk (cache above)Version listing may be staleSet short TTL or invalidate on write
ScopedDisk + ReplicatedDisk (scope above)Replica writes use unscoped pathsPlace ScopedDisk below ReplicatedDisk
QuotaDisk + ReplicatedDiskQuota counts once, but data stored N timesAccount for replication factor in quota

Performance Considerations ​

DecoratorRead OverheadWrite OverheadMemory
OtelDisk~0.1ms (span creation)~0.1msLow
QuotaDiskNone~1ms (quota check)Low
CachedDiskNegative (cache hit)~0.5ms (invalidation)Medium-High
RetryDiskNone (success)None (success)Low
ReplicatedDiskNone2-Nx (N replicas)Low
EncryptedDisk~2-5ms (decrypt)~2-5ms (encrypt)+16-32 bytes/file
VersionedDiskNone~10-50ms (snapshot)Low
ScopedDiskNoneNoneNone

Tips:

  • A 6-decorator stack adds ~5-10ms overhead on a cache miss, which is negligible compared to network I/O to cloud storage (50-200ms).
  • CachedDisk dominates performance when cache hit rate is high.
  • EncryptedDisk adds CPU overhead proportional to file size; for large files (>100MB), consider using S3 server-side encryption instead.
  • VersionedDisk doubles write latency (original write + snapshot copy).

Released under the MIT License.