Repositories
Persistence follows the ports-and-adapters pattern. The domain defines a repository interface (the port) in terms of aggregates and value objects; infrastructure provides a MongoDB adapter that implements it. The domain never imports MongoDB, and swapping the datastore means writing a new adapter — nothing in the domain changes.
The port: a domain interface
A generic Repository<T, ID> contract lives in the common lib:
// libs/common/src/general/domain/base.repository.ts
export interface Repository<T, ID = Id> {
findById(id: ID): Promise<T | null>;
findByCriteria(criteria: Criteria): Promise<PaginatedRepoResult<T>>;
countByCriteria(criteria: Criteria): Promise<number>;
exists(id: ID): Promise<boolean>;
remove(id: ID, context?: RepositoryContext): Promise<void>;
save(entity: T, context?: RepositoryContext): Promise<void>;
clear(context?: RepositoryContext): Promise<void>;
}
A context extends it with aggregate-specific finders, and exposes an injection token:
// …/auth/domain/aggregates/user/user.repository.ts
export const USER_REPOSITORY = Symbol('UserRepository');
export interface User_Repository extends Repository<User, Id> {
findByEmail(email: Email): Promise<User | null>;
findByGoogleId(googleId: string): Promise<User | null>;
existsByEmail(email: Email): Promise<boolean>;
searchByEmail(emailPattern: string, limit: number): Promise<User[]>;
}
Handlers depend on the interface, injected by token — they have no idea there's a Mongo behind it:
constructor(
@Inject(USER_REPOSITORY) private readonly userRepository: User_Repository,
) {}
The adapter: Base_MongoRepository
Concrete repositories extend Base_MongoRepository, which implements the generic
CRUD, criteria queries, index management, transaction participation, and Mongo
error translation. Subclasses provide just the mapping and any custom finders:
// libs/mongodb/src/infrastructure/base.mongo-repository.ts
export abstract class Base_MongoRepository<
TEnt extends SharedAggregate,
TDto extends SharedAggregateDTO & { id: string },
> implements Repository<TEnt, Id> {
protected abstract toEntity(dto: TDto): TEnt; // DTO → aggregate
protected toValue(entity: TEnt): TDto { // aggregate → DTO
return entity.toValue() as TDto;
}
protected abstract defineIndexes(): IndexSpec[];
async save(entity: TEnt, context?: RepositoryContext): Promise<void> {
this.registerTransactionParticipant(context);
const session = this.getTransactionSession(context);
await this.ensureIndexes();
await this.collection.updateOne(
{ id: entity.id.toValue() } as Filter<TDto>,
{ $set: this.toValue(entity) },
{ upsert: true, session },
);
}
async findById(id: Id): Promise<TEnt | null> {
const dto = await this.collection.findOne({ id: id.toValue() } as Filter<TDto>);
return dto ? this.toEntity(dto as TDto) : null;
}
}
A concrete repository is small — map the DTO, declare indexes, add finders:
// libs/mongodb/src/example/example.mongo-repository.ts
export class Example_MongoRepository
extends Base_MongoRepository<EntityExample, EntityExampleDTO>
implements ExampleRepository {
static CollectionName = 'transaction-test';
constructor(mongoClient: MongoClient) {
super(mongoClient, Example_MongoRepository.CollectionName);
}
protected toEntity(dto: EntityExampleDTO): EntityExample {
return EntityExample.fromValue(dto);
}
protected defineIndexes(): IndexSpec[] { return []; }
async findByValue(value: string): Promise<EntityExample | null> {
const criteria = new Criteria({
filters: new Filters([
new Filter(new FilterField('value'), FilterOperator.equal(), new FilterValue(value)),
]),
});
return (await this.findByCriteria(criteria)).data[0] ?? null;
}
}
The repository-base lint rule ensures concrete repos extend
Base_MongoRepository (or implement a Repository interface).
Criteria queries
Rather than leaking Mongo query syntax into handlers, the framework uses a
Criteria object — composable Filters, operators and pagination — which the
MongoCriteriaConverter translates into a Mongo query. The same Criteria could
be translated for a different datastore, keeping queries database-agnostic.
Transactions
Writes that must be atomic run inside Transaction.run. The repository
participates in the ambient transaction by registering a Mongo participant and
using its session — so the state change and the outbox write commit together:
await Transaction.run(async (context) => {
await this.userRepository.save(user, context);
// any outbox write using `context` commits in the same transaction
});
Under the hood the base repository registers a TransactionParticipant_Mongodb
on first use and threads its ClientSession into every operation. MongoDB
transactions require a replica set, which is why dev and prod run Mongo as a
single-member rs0 (see Databases).
Error translation
The base repo turns infrastructure errors into domain-meaningful ones — e.g. a
Mongo duplicate-key error (code 11000) becomes an AlreadyExistsException
naming the offending field — so handlers and controllers deal in domain
exceptions, not driver internals.
Swapping datastores
Because the port is defined in the domain and the adapter is the only Mongo-aware
code, you can add a Postgres or in-memory adapter (the auth context already
ships an in-memory repository for tests) without touching a single handler.
Next: Authentication.