Les ports
Un handler qui écrit constructor(private payment: StripePayment) a nommé son
fournisseur. Tout ce qui suit cette ligne — les tests, le bac à sable, le jour où le PSP
change — le paie.
Déclarer la capacité, et l'étendre :
// services/Payment.ts — le port. Il ne nomme aucun fournisseur.
export default abstract class Payment {
abstract charge(amountCents: number): Charge;
}
// services/StripePayment.ts — la réalisation.
export default class StripePayment extends Payment {
charge(amountCents: number) { return { provider: 'stripe', amountCents }; }
}
// handlers/CheckoutHandler.ts — demande le port.
export default class CheckoutHandler {
constructor(private payment: Payment) {}
/** Débiter le panier. */
async pay() { return this.payment.charge(4990); }
}
this.payment est un StripePayment. Rien ne le déclare — extends est
l'enregistrement tout entier, et le boot lit la chaîne de prototypes.
Un port n'est pas un type de fichier
Il n'y a pas de répertoire ports/ ni de fabrique Port(...). Un port est une classe de
provider qu'une autre étend : services/ et repositories/ sont scannés comme avant, et
c'est la relation entre deux de leurs classes qui fait d'une un port. Écrivez la base
seule, c'est un service ordinaire ; écrivez une classe qui l'étend, et la base devient la
clé sous laquelle l'implémentation répond.
C'est la lecture que le reste du framework applique déjà — le type sur le paramètre nomme
le sujet, le conteneur détient la réalisation. C'est ainsi que se résolvent
RepositoryOf<Post>, Emit<PostPublished> et Facade<PostHandler>. Les providers étaient le
seul cas où le type nommait la réalisation.
abstract est un mot-clé TypeScript, effacé au runtime. Il achète la garantie à la
compilation qu'une implémentation existe — une base sans sous-classe n'est qu'un service,
et le boot ne peut pas vous dire qu'elle se voulait abstraite.Une classe du framework est un port aussi
La condition tient en une ligne : quelque chose répond déjà sous ce nom. Les providers
sont enregistrés dans le scope de la Frond, et les builtins de Fougere vivent dans son
parent — donc Logger est un port comme Payment, et l'étendre reprend la clé :
// services/AuditLogger.ts
export default class AuditLogger extends Logger {
info(msg: string) { /* l'envoyer ailleurs */ }
}
// n'importe quel handler, inchangé
constructor(private logger: Logger) {} // reçoit AuditLogger
Le défaut reste en place pour toute Frond qui ne déclare pas de sous-classe — la surcharge est par Frond, parce que les services d'une Frond sont les siens.
Un logger construit avant un changement de niveau l'applique quand même, et un enfant aussi : le niveau est consulté plutôt que recopié dans chaque instance, donc rien n'est reconstruit quand il bouge. Ça appartient au cycle de vie, pas ici.
C'est la même condition qui exclut un prefab : un repository étend la classe rendue par
Repository(Post), et rien ne répond jamais sous ce nom.
Deux implémentations
Quelle réalisation un déploiement utilise n'est pas un fait du code — ça change entre le dev et la production. Donc le boot refuse de deviner :
[ports] StripePayment and OgonePayment both extend Payment, and nothing says which one
answers it. Which realization a deployment uses is not a fact about the code — state it:
ports: { Payment: 'StripePayment' } in fougere.config.ts.
Refuser plutôt que d'en garder une est délibéré : celle qui gagnerait dépendrait de l'ordre du scan, et le handler débiterait le mauvais fournisseur sans un mot.
// fougere.config.ts
export default defineFougere({
db: 'sqlite',
remotes: { blog: 'http://127.0.0.1:4100' },
ports: { Payment: process.env.PSP === 'ogone' ? 'OgonePayment' : 'StripePayment' },
});
ports: se pose à côté de remotes: pour la même raison que sources:. remotes: dit où
part un appel, sources: où vit une ligne, ports: qui exécute une action.
Aucun des trois n'appartient à la Frond, qui se décrit elle-même et non son déploiement —
une Frond billing qui nommerait Stripe serait la seule chose que Fougere refuse.
Supprimez OgonePayment.ts et la ligne ports: disparaît avec lui : une seule
implémentation est résolue par convention. La clé n'existe que pour l'exception. Une
clé qui ne correspond à aucun port est signalée au boot, plutôt qu'obéie en silence.
Port ou Frond ?
Une Frond distante permet aussi de remplacer ce qui répond, et elle remplace le process avec. La ligne entre les deux se vérifie :
- Le collaborateur possède de la donnée — des entités, des tables, des seeds → c'est
une Frond, et
remotes:est sa déclaration. - Il ne fait qu'agir sur le dehors — un PSP, un envoyeur d'e-mails, un stockage de
fichiers → c'est un port, et
ports:est sa déclaration.
Les ports de Fougere eux-mêmes suivent la règle par l'autre bout : Storage et
HttpRouter sont déclarés par le framework parce qu'il dérive des choses de leur forme
(Crud, le DDL, la carte d'identité). Rien n'est dérivé de Payment, donc Payment est à
vous.
Ce que ça ne fait pas
Un port permet d'exprimer un joint. Il ne dit pas où sont les joints — découper un domaine en collaborateurs reste votre décision, et rien ici ne la prend à votre place.
Suite : Requêtes et commandes.