Les repositories

Storage est un port : des gestes génériques, aucun parfum de domaine. « Les relevés bruyants » n'en est pas un, donc cette requête finit épelée sur place, au milieu du calcul qu'elle alimente. Une requête sans domicile squatte la réponse.

Repository(Entity) lui en donne un, et est le stockage de cette entité : tous les gestes du port, plus ce que vous nommez par-dessus.

// repositories/ReadingRepository.ts
export default class ReadingRepository extends Repository(Reading) {
  loud(): Promise<Reading[]> {
    return this.findAllBy({ loud: true });
  }
}

Le handler pose la question, et n'épelle jamais le stockage :

// handlers/ReadingHandler.ts
export default class ReadingHandler {
  constructor(private readings: ReadingRepository) {}

  async loud() { return this.readings.loud(); }
  async all()  { return this.readings.list(); }   // les gestes du port, transmis
}

repositories/ est scanné comme services/ : la classe s'enregistre comme provider, et les dépendances de son constructeur se résolvent par type comme partout ailleurs.

N'en écrivez aucun et vous ne perdez rien

Chaque entité a un repository, que vous ayez écrit le fichier ou non : le boot enregistre le port gardé sous <Entité>Repository. Un handler peut donc en demander un avant que quiconque ait écrit la classe, et en déclarer une plus tard l'emporte sous cette clé — exactement comme une opération Crud redéfinie dans une sous-classe l'emporte sur celle du prefab.

Les deux formes répondent aux mêmes noms, et c'est ce qui rend la convention vraie : list(), create() et les autres sont là dans les deux cas, une requête nommée est la seule différence.

Sans fichier écrit il n'y a pas de classe à désigner, alors nommez la forme :

constructor(private nodes: RepositoryOf<Node>) {}

RepositoryOf<Node> est à NodeRepository ce que Storage<Node> est à NodeStorage : une orthographe d'une seule clé de conteneur.

L'injection se fait sur le nom du type écrit sur le paramètre, et la clé du conteneur est <Entité>Repository. Une classe déclarée doit donc s'appeler ReadingRepository — pas Readings, pas ReadingQueries — sinon rien ne la résout.

Le port n'est pas un mot de votre vocabulaire

Un handler, un presenter et un collector ne peuvent pas demander Storage<E>. Le boot le refuse par son nom et désigne le repository :

PostHandler asks for PostStorage. Storage is reached through a repository, never through
the port:
    constructor(private post: PostRepository) {}

Un seul chemin, et un seul mot pour le dire. Ce que ça achète au-delà de la propreté est le paragraphe ci-dessous : le jour où une entité appartient à un agrégat, aucun appel ne bouge, parce qu'aucun n'avait jamais épelé le stockage.

Un détenteur est l'exception, et c'est la même règle lue dans l'autre sens : une classe qu'un prefab a bâtie sur son entité peut nommer le port de cette entité, ce qui est la façon dont un Mirror écrit la copie qu'il possède. Une porte juge et projette ; un détenteur garde le stockage.

À partir de deux entités, il les possède

Le solde d'un compte et la somme des lignes de son journal sont un seul fait. Rien ne pouvait le dire : le juge lit une ligne, et un cadre rend deux écritures atomiques sans dire lesquelles ont le droit d'arriver. Débitez 100 et journalisez 50, la base accepte.

La moitié qui manquait n'a jamais été un vérificateur. C'était une porte : tant que Storage<Ledger> est servi à qui le demande, aucun fichier ne peut être le seul chemin. Nommez les deux entités et il en devient un :

// repositories/AccountRepository.ts
export default class AccountRepository extends Repository(Account, Ledger) {
  async withdraw(id: string, amount: number) {
    const [accounts, ledger] = this.storages;
    const account = await accounts.findById(id);
    if (account!.balance < amount) throw new Error('solde insuffisant');

    await accounts.update(id, { balance: account!.balance - amount });
    await ledger.create({ account: id, amount: -amount });
  }
}

La règle est du TypeScript ordinaire, dans la méthode qui écrit. Rien n'a été ajouté pour l'exprimer — ce qui a été ajouté, c'est que nulle part ailleurs ne peut écrire ces deux tables.

L'arité est la déclaration. Une entité est un endroit où nommer des questions ; deux ou plus est une frontière. Il n'y a aucun drapeau, et un repository d'une seule entité ne possède rien — une frontière entre une chose et rien n'est pas une frontière, et le juge siège déjà sur les lignes de cette entité.

Trois choses en découlent, et chacune est la forme qui refuse plutôt qu'une règle écrite :

  • Aucun repository par défaut n'est enregistré pour un membre. LedgerRepository n'existe pas, donc rien ne peut le demander.
  • Aucun geste n'est transmis. Dans quelle entité create écrirait-il ? La seule surface est donc ce que vous nommez. Une entité possédée n'a donc pas de CRUD automatique — Crud(Ledger) n'a plus de stockage sur quoi tourner — et le boot le dit par son nom, au lieu de laisser l'application démarrer et échouer à sa première requête.
  • Deux agrégats ne peuvent pas revendiquer une entité. Refusé au boot en nommant les deux, pour la raison qui fait ports: refuser deux implémentations : celui qui gagnerait dépendrait de l'ordre du scan, et l'une des deux frontières ne serait tenue par personne.

La frontière n'est pas l'unité de travail

Un agrégat dit que les règles de ces entités tiennent ensemble. Un cadre dit que ces écritures atterrissent ensemble. Les deux listes coïncident souvent, et ce ne sont pas le même énoncé — donc un cadre se demande ici comme partout ailleurs :

export default class AccountRepository extends Repository(Account, Ledger) {
  constructor(
    accounts: AccountStorage,
    ledger: LedgerStorage,
    private frame: Together<[Account, Ledger]>,
  ) { super(accounts, ledger); }

  async withdraw(id: string, amount: number) {
    return this.frame.run(async ([accounts, ledger]) => { /* les deux, ou aucune */ });
  }
}

Dériver le cadre de la composition aurait fait porter un cadre à un agrégat en lecture seule — ses refus compris — et mis Together<[Account, Ledger], [RateMirror]> hors d'atteinte pour toujours.

Les noms du port reviennent dans ce constructeur, et là seulement : cette classe est le détenteur, ce qui est le seul cas que la règle ci-dessus autorise.

Ce n'est pas une porte

Un repository n'a pas de façade : rien de ce qu'il porte n'est atteignable depuis le fil. C'est ce qui le laisse libre de tenir tout ce que le domaine demande — mais cela veut dire aussi qu'un juge n'a rien à faire ici. Les règles sur qui a le droit d'agir restent dans le handler, le seul endroit qu'un refus ne peut pas contourner. Ce qui appartient ici est l'autre sorte : une règle sur ce que la donnée peut être.

Les écritures restent jugées, parce qu'elles passent par le port : une valeur que l'entité interdit est refusée, que ce soit un repository ou un handler qui l'émette.

Suite : Les ports.

Construit avec Fougere — ce site tourne sur le framework qu'il documente.