Opaque Blob Persistence
The domain occasionally needs to store something that is not state — a generated PDF invoice, an uploaded attachment, an exported CSV. Those payloads are opaque from the domain's perspective: bytes, with an id, retrievable later. @frenchexdev/ddd-storage reifies that boundary with one port and two adapters (filesystem, S3) so the substrate is replaceable without rewriting the aggregates that produced the blobs.
What Storage Reifies
The storage pattern is the unrich version of repository: there is no model to load, no invariants to enforce, no domain logic to execute on read. Just bytes in, bytes out, keyed by an id. The discipline the corpus enforces is that storage does not become a hidden state store. If a payload has structure that the domain cares about, it goes into an aggregate with a real type; storage holds the opaque byproducts (the rendered PDF the aggregate produced, the attachment the user uploaded), not the source.
The four operations cover the contract: put(blob) returns an id, get(id) returns the bytes, delete(id) removes them, exists(id) returns a boolean for cheap lookup without pulling the bytes.
The Runtime: ddd-storage and adapters
Three packages — ddd-storage, ddd-storage-fs-adapter, ddd-storage-s3-adapter — ship as parallel M4/M5 stubs pinned to StorageOpaqueBlobRequirement. The filesystem adapter is for tests and single-host deployments; the S3 adapter is for distributed deployments where the same blob must be visible to every replica.
The Analyzer: ddd-storage-analyzer
Spec-first (spec.ts). Pattern STORAGE under requirement StorageOpaqueBlobRequirement, priority Medium. Two info-severity naming-suffix invariants: DDD-STORAGE-001 recommends the Storage suffix on the port class, DDD-STORAGE-002 recommends the Blob suffix on the payload types. Both at info severity — the analyzer guides, the team decides.
rules: [
{ kind: 'require-name-suffix', code: 'DDD-STORAGE-001', severity: 'info',
suffix: 'Storage',
message: 'Storage type should end with "Storage" suffix by convention' },
{ kind: 'require-name-suffix', code: 'DDD-STORAGE-002', severity: 'info',
suffix: 'Blob',
message: 'Storage type should end with "Blob" suffix by convention' },
],rules: [
{ kind: 'require-name-suffix', code: 'DDD-STORAGE-001', severity: 'info',
suffix: 'Storage',
message: 'Storage type should end with "Storage" suffix by convention' },
{ kind: 'require-name-suffix', code: 'DDD-STORAGE-002', severity: 'info',
suffix: 'Blob',
message: 'Storage type should end with "Blob" suffix by convention' },
],The Codegen: ddd-storage-codegen
Two templates following the now-familiar pattern: a per-storage stub and a registry. The stub emits a class with the four operations throwing NotImplemented; the registry lists every storage with the blob types it accepts.
The interesting cross-pattern role for the codegen lands when storage is consumed by an aggregate: the aggregate's @AggregateRoot-emitted blob references should appear in the storage registry, and a CI gate can refuse a workspace where an aggregate references a blob type no storage handles.
Cross-Links
- Lives behind the
@Port/@Adapterdiscipline — filesystem and S3 adapters satisfy the same conformance suite. - Called by
@AggregateRoots that produce binary artefacts. - Distinct from
@EventStore(append-only events) and@SnapshotStore(per-aggregate state) — three persistence boundaries with three different semantics.
Back to the series index.