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.