Aggregate Persistence, Collection or Persistence Style
A repository is the place where aggregates live when they are not in memory. @Repository from @frenchexdev/ddd-repository reifies Vernon's chapter-12 primitive in two operational styles — collection, where the repository pretends to be an in-memory list and the persistence is invisible, and persistence, where the API names the storage operations explicitly. Both styles share the same discipline: repositories return only aggregate roots, never internal entities.
What @Repository Reifies
Evans's repository (Domain-Driven Design, chapter 6) is the single entry point for retrieving an aggregate. The application service does not reach for SQL, does not call an ORM, does not know whether the aggregate is cached — it asks the repository, the repository returns the aggregate (or null on miss), the application proceeds. The infrastructure choices live behind the repository; the domain stays clean.
Vernon (Implementing Domain-Driven Design, chapter 12) refines the pattern by distinguishing two styles. Collection style: the repository's API mirrors an in-memory Set<Aggregate> — add(agg), find(spec), remove(agg). The team thinks in terms of adding the aggregate; the framework decides when to persist. Persistence style: the API is explicit — save(agg), load(id), delete(id). The team is explicit about when persistence happens.
Both styles are valid; the choice is operational. Collection style favours teams that come from in-memory-domain backgrounds and want the persistence to disappear. Persistence style favours teams that want the database operations visible in the code (which often correlates with how the team reasons about transactions). The decorator's style field locks the choice per-repository.
The Runtime: ddd-repository
decorator.ts declares the surface. RepositoryOptions requires style: 'collection' | 'persistence' and aggregate: string (the class name), accepts optional queries: readonly string[] (the names of additional query methods beyond id-based retrieval).
import { Repository } from '@frenchexdev/ddd-repository';
import type { Subscription, SubscriptionId } from '../aggregates/subscription.js';
import type { Spec } from '@frenchexdev/ddd-specification';
@Repository({
style: 'persistence',
aggregate: 'Subscription',
queries: ['findActiveByCustomer', 'findExpiringBetween'],
})
export abstract class SubscriptionRepositoryPort {
abstract save(subscription: Subscription): Promise<void>;
abstract load(id: SubscriptionId): Promise<Subscription | null>;
abstract delete(id: SubscriptionId): Promise<void>;
// Named queries declared in the decorator's queries field
abstract findActiveByCustomer(customerId: CustomerId): Promise<readonly Subscription[]>;
abstract findExpiringBetween(from: string, to: string): Promise<readonly Subscription[]>;
// Specification-driven query — the substrate-specific repository translates spec to its native query
abstract find(spec: Spec<Subscription>): Promise<readonly Subscription[]>;
}import { Repository } from '@frenchexdev/ddd-repository';
import type { Subscription, SubscriptionId } from '../aggregates/subscription.js';
import type { Spec } from '@frenchexdev/ddd-specification';
@Repository({
style: 'persistence',
aggregate: 'Subscription',
queries: ['findActiveByCustomer', 'findExpiringBetween'],
})
export abstract class SubscriptionRepositoryPort {
abstract save(subscription: Subscription): Promise<void>;
abstract load(id: SubscriptionId): Promise<Subscription | null>;
abstract delete(id: SubscriptionId): Promise<void>;
// Named queries declared in the decorator's queries field
abstract findActiveByCustomer(customerId: CustomerId): Promise<readonly Subscription[]>;
abstract findExpiringBetween(from: string, to: string): Promise<readonly Subscription[]>;
// Specification-driven query — the substrate-specific repository translates spec to its native query
abstract find(spec: Spec<Subscription>): Promise<readonly Subscription[]>;
}The shape is abstract — the port shape (@Port) and the repository shape compose. Concrete implementations (a Postgres adapter, an in-memory test fake) are @Adapter decorations against this port. Both shapes — repository style and the hexagonal port — coexist; the repository's contract is the domain-facing shape, the port/adapter discipline is the infrastructure-facing one.
The queries field in the decorator declares the named methods. The codegen will validate that every name in queries corresponds to an actual method on the class (and emit a registry of named queries for documentation), so the decorator and the abstract class cannot drift.
The Analyzer: ddd-repository-analyzer
The analyzer for this pattern is hand-written, like Factory and Domain Event. codes.ts exports two diagnostic factories in the DDD0NNN namespace, both at error severity — the rules they enforce are structural.
DDD0170_REPOSITORY_RETURNS_NON_ROOT is the cluster-encapsulation discipline from @AggregateRoot extended into the repository surface. A repository that returns an internal Entity (rather than the AggregateRoot that owns it) hands a fragment of the cluster to the caller, who can now mutate the entity outside the root's consistency boundary:
export const DDD0170_REPOSITORY_RETURNS_NON_ROOT = 'DDD0170';
export function repositoryReturnsNonRoot(className: string, returnType: string, file: string, line: number): Diagnostic {
return {
code: DDD0170_REPOSITORY_RETURNS_NON_ROOT,
severity: 'error',
message: `@Repository "${className}" returns "${returnType}" which is not an @AggregateRoot. Repositories must return AggregateRoots only.`,
file,
line,
};
}export const DDD0170_REPOSITORY_RETURNS_NON_ROOT = 'DDD0170';
export function repositoryReturnsNonRoot(className: string, returnType: string, file: string, line: number): Diagnostic {
return {
code: DDD0170_REPOSITORY_RETURNS_NON_ROOT,
severity: 'error',
message: `@Repository "${className}" returns "${returnType}" which is not an @AggregateRoot. Repositories must return AggregateRoots only.`,
file,
line,
};
}DDD0173_REPOSITORY_MIXED_STYLE is Vernon's two-styles invariant. RepositoryStyle is a closed union 'collection' | 'persistence' — mixing add() / remove() (collection) with save() / load() / delete() (persistence) creates an ambiguous mental model that defeats the point of the style declaration:
repositoryMixedStyle('SubscriptionRepository', 'repositories/subscription.ts', 14);
// DDD0173 [error] @Repository "SubscriptionRepository" mixes collection-style
// methods (add/find/remove) with persistence-style (save/load/delete).
// Choose one consistent style.repositoryMixedStyle('SubscriptionRepository', 'repositories/subscription.ts', 14);
// DDD0173 [error] @Repository "SubscriptionRepository" mixes collection-style
// methods (add/find/remove) with persistence-style (save/load/delete).
// Choose one consistent style.The hand-written shape places this analyzer on the same migration list as the other DDD0NNN cohorts. Future work tracked via PROP-REPOSITORY-001 (code namespace migration to DDD-REPOSITORY-NNN) and PROP-REPOSITORY-002 (spec-first analyzer + codegen with workspace-wide aggregate name resolution) will rewrite both on top of defineAnalyzerSpec / defineCodegenSpec and finally enforce the cross-reference the named aggregate exists as a known @AggregateRoot in the workspace.
The Codegen: ddd-repository-codegen
The codegen is also hand-written. It emits a repository registry and a typed contract over the queries: readonly string[] field so a CI gate can refuse a workspace where a declared named query has no matching method on the abstract class. The deeper cross-pattern rules — aggregate resolution, query-method shape validation — await the spec-first migration above.
Cross-Links
- Serves exactly one
@AggregateRoot; never returns an internal entity. - Accepts
@Specificationfor typed queries — the substrate translatesSpec<T>into its native query language. - Lives behind
@Portand is implemented by@Adapter— Postgres, in-memory, document store all satisfy the same shape. - Called by
@ApplicationService,@CommandHandler, and@Factory. - Loads aggregates that may carry
@EventSourcingstate — the repository becomes a thin wrapper over the event store in that case.
Back to the series index.