Repositories
Un repository donne accès à des aggregates entiers, comme une collection. Il existe deux styles :
- le style orienté collection imite un
Set, et a besoin d’un outil qui suit les changements des objets chargés ; - le style orienté persistance exige un
saveexplicite après chaque création et chaque changement.
En Node, c’est presque toujours le second (REP-05). Les règles de transaction couvrent aussi un piège courant d’un repository écrit à la main : écrire l’aggregate root et ses parties hors de la transaction de l’application (REP-14).
En pratique
Le domaine déclare ce dont il a besoin, dans ses propres termes :
ts
export abstract class WalletRepository {
abstract get(id: WalletId): Promise<Wallet | undefined>;
abstract save(wallet: Wallet): Promise<Result<void, WalletAlreadyOpen>>;
}Un adapter driven l’implémente avec Prisma :
ts
export class PrismaWalletRepository extends WalletRepository {
readonly #unitOfWork: PrismaUnitOfWork<Prisma.TransactionClient>;
readonly #mapper = new WalletMapper();
constructor(unitOfWork: PrismaUnitOfWork<Prisma.TransactionClient>) {
super();
this.#unitOfWork = unitOfWork;
}
async get(id: WalletId): Promise<Wallet | undefined> {
const row = await this.#unitOfWork.client().wallet.findUnique({
where: { id: id.value },
include: { blocks: true },
});
if (!row) {
return undefined;
}
const wallet = this.#mapper.toDomain(row);
trackVersion(wallet, row.version);
return wallet;
}
async save(wallet: Wallet): Promise<Result<void, WalletAlreadyOpen>> {
const db = this.#unitOfWork.client();
const { id, ...data } = this.#mapper.toRow(wallet);
try {
await saveVersioned(wallet, {
insert: (version) => db.wallet.create({ data: { id, ...data, version } }),
update: (expected, next) =>
db.wallet.updateMany({ where: { id, version: expected }, data: { ...data, version: next } }),
});
await db.walletBlock.deleteMany({ where: { walletId: id } });
await db.walletBlock.createMany({ data: this.#mapper.toBlockRows(wallet) });
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
return err(new WalletAlreadyOpen(wallet.holder, wallet.balance.currency));
}
throw error;
}
return ok();
}
}- Un repository par aggregate root, pour des aggregates entiers, comme un ensemble indexé par identité (REP-01, REP-02, REP-04).
- Les blocages, des value objects rangés dans des lignes enfants, sont chargés avec le wallet et remplacés à chaque enregistrement (REP-02, VO-11).
- L’interface ne parle que le langage du domaine : aucun type Prisma, aucun SQL, et un second wallet dans la même devise revient sous forme de refus,
WalletAlreadyOpen(REP-03). - Un mapper reconstruit le wallet par
Wallet.restore, sans setter (REP-08). - Le repository n’ouvre pas de transaction :
client()lui rend celle que le use case a ouverte avecrun(), portée par AsyncLocalStorage (REP-13, REP-14). Il écrit sous optimistic concurrency, avec la version tenue hors du modèle (AGG-15, AGG-16).
Règles
Contrat
| Code | Règle | Sévérité | Détection |
|---|---|---|---|
| REP-01 | Un repository par aggregate root, pas par entity ni par table | majeure | |
| REP-02 | Le repository charge, écrit et supprime des aggregates entiers | majeure | |
| REP-03 | L’interface du repository ne laisse rien fuir du stockage | mineure | |
| REP-04 | Le repository se comporte comme un ensemble indexé par identité | majeure |
Écritures
| Code | Règle | Sévérité | Détection |
|---|---|---|---|
| REP-05 | Aucun changement ne se perd entre l’aggregate et le stockage | majeure | |
| REP-06 | Pas de suppression physique des aggregates que le métier doit garder | bloquante | |
| REP-07 | La persistance passe par le repository, pas par l’aggregate ni par l’ORM | majeure | fitness |
Stockage et reconstitution
| Code | Règle | Sévérité | Détection |
|---|---|---|---|
| REP-08 | La reconstitution n’impose ni setters ni constructeur vide au modèle | majeure | |
| REP-09 | La forme stockée d’un aggregate évolue avec un chemin de migration | majeure |
Requêtes
| Code | Règle | Sévérité | Détection |
|---|---|---|---|
| REP-10 | Pas de finder sur les parties : une lecture partielle est une query | majeure | |
| REP-11 | Une lecture sur plusieurs aggregates est une query sur une view | mineure | |
| REP-12 | La logique métier ne descend dans la base que pour une exigence non fonctionnelle | majeure |
Transactions
| Code | Règle | Sévérité | Détection |
|---|---|---|---|
| REP-13 | Le use case décide de la transaction | majeure | |
| REP-14 | Les repositories écrivent dans la transaction ouverte par l’application | bloquante |
Hiérarchies de types
| Code | Règle | Sévérité | Détection |
|---|---|---|---|
| REP-15 | Un repository partagé seulement pour des types interchangeables | mineure |
Tests
| Code | Règle | Sévérité | Détection |
|---|---|---|---|
| REP-16 | Le repository est testé contre le vrai stockage | mineure |