Repositories
Persistence is the flagship port of the hexagonal architecture. The domain defines a repository interface in terms of aggregates and value objects; infrastructure provides a technology-specific adapter that implements it, like MongoDB or PostgreSQL. The domain never imports a database driver, and swapping the datastore means writing a new adapter, changing nothing in the domain logic.
The contract
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 bounded 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,
) {}
async handle(command: DeactivateUser_Command) {
const user = await this.userRepository.findById(new Id(command.userId));
user.deactivate();
await this.userRepository.save(user);
}The adapter: User_MongodbRepository
The concrete adapter extends Base_MongoRepository, which already implements
the whole generic Repository contract: CRUD, criteria queries, index
management, transaction participation, and translation of Mongo errors into
domain exceptions. The subclass only supplies what is specific to its
aggregate: the collection name, the DTO mapping, the indexes, and the custom
finders.
// …/auth/infrastructure/repositories/mongodb/user.mongodb-repository.ts
@Injectable()
export class User_MongodbRepository
extends Base_MongoRepository<User, UserDTO>
implements User_Repository
{
static readonly CollectionName = 'auth_users';
constructor(@Inject(MONGO_CLIENT_TOKEN) mongoClient: MongoClient) {
super(mongoClient, User_MongodbRepository.CollectionName);
}
protected toEntity(dto: UserDTO): User {
return User.fromValue(dto);
}
// Custom finders beyond the generic contract
async findByEmail(email: Email, context?: RepositoryContext) {
try {
const session = this.getTransactionSession(context);
const document = await this.collection.findOne({ email: email.toValue() }, { session });
return document ? User.fromValue(document) : null;
} catch (error: unknown) {
this.handleDatabaseError('findByEmail', email.toValue(), error);
}
}
protected defineIndexes(): IndexSpec[] {
return [
{ fields: { id: 1 }, options: { unique: true, name: 'idx_user_id' } },
{ fields: { email: 1 }, options: { unique: true, name: 'idx_user_email' } },
];
}
}Everything else (save, findById, findByCriteria, exists, remove)
comes from the base class for free, and the base keeps the declared indexes in
sync with the collection. The finder above uses two of the base's hooks:
getTransactionSession joins the ambient transaction when the caller passes a
context, and handleDatabaseError turns driver errors into domain
exceptions.
Criteria queries
Rather than leaking Mongo query syntax into handlers, the framework uses a
Criteria object (composable Filters, operators and pagination) which are
translated into a Mongo query. The same Criteria could be translated for a
different datastore, keeping queries database-agnostic.
This is how the ListUsers_QueryHandler pages through users whose email
contains a search term, with no Mongo syntax in sight:
// …/auth/application/queries/list-users/list-users.query-handler.ts
const criteria = new Criteria({
filters: new Filters([
new Filter(
new FilterField('email'),
new FilterOperator(Operator.CONTAINS),
new FilterValue(query.filterValue),
),
]),
order: Order.fromValues('email', OrderTypes.ASC),
pagination: new PaginationOffset(query.limit, offset, true),
});
const { data, total } = await this.userRepository.findByCriteria(criteria);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. 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 adapters
Because the port is defined in the domain and the adapter is the only
technology-aware code, you can add a Postgres or in-memory adapter (every
context ships an in-memory repository for tests) without touching a single
handler.
Next: Event-Driven Architecture.