Ports
A handler that writes constructor(private payment: StripePayment) has named its provider.
Everything downstream of that line — the tests, the sandbox, the day you change PSP — pays
for it.
Declare the capability instead, and extend it:
// services/Payment.ts — the port. It names no provider.
export default abstract class Payment {
abstract charge(amountCents: number): Charge;
}
// services/StripePayment.ts — the realization.
export default class StripePayment extends Payment {
charge(amountCents: number) { return { provider: 'stripe', amountCents }; }
}
// handlers/CheckoutHandler.ts — asks for the port.
export default class CheckoutHandler {
constructor(private payment: Payment) {}
/** Charge the cart. */
async pay() { return this.payment.charge(4990); }
}
this.payment is a StripePayment. Nothing declares that — extends is the whole
registration, and the boot reads the prototype chain.
A port is not a kind of file
There is no ports/ directory and no Port(...) factory. A port is a provider class that
another provider extends: services/ and repositories/ are scanned as before, and the
relation between two of their classes is what makes one a port. Write the base alone and it
is an ordinary service; write a class extending it and the base becomes the key the
implementation answers under.
This is the same reading the rest of the framework already applies — the type on the
parameter names the subject, the container holds the realization. It is how
RepositoryOf<Post>, Emit<PostPublished> and Facade<PostHandler> resolve. Providers were
the one case where the type named the realization instead.
abstract is a TypeScript keyword, erased at runtime. It buys the compile-time guarantee
that an implementation exists — a base with no subclass is just a service, and the boot
cannot tell you it was meant to be abstract.A framework class is a port too
The condition is one line: something already answers under that name. Providers are
registered into the Frond's scope, and Fougere's own builtins sit in its parent — so
Logger is a port like Payment is, and extending it takes the key over:
// services/AuditLogger.ts
export default class AuditLogger extends Logger {
info(msg: string) { /* ship it somewhere */ }
}
// any handler, unchanged
constructor(private logger: Logger) {} // receives AuditLogger
The default stays for every Frond that declares no subclass — the override is per Frond, because a Frond's services are its own.
A logger built before a level change still obeys it, and so does a child: the level is consulted rather than copied into each instance, so nothing is rebuilt when it moves. That belongs to the lifecycle, not here.
The same condition is what keeps a prefab out: a repository extends the class
Repository(Post) returned, and nothing ever answers under that name.
Two implementations
Which realization a deployment uses is not a fact about the code — it changes between dev and production. So the boot refuses to guess:
[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.
Refusing rather than keeping one is deliberate: whichever won would depend on scan order, and the handler would charge the wrong provider without a word.
// 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: sits beside remotes: for the same reason sources: does. remotes: says where
a call goes, sources: says where a row is, ports: says who performs an
action. None of the three belongs inside the Frond, which describes itself and not its
deployment — a billing Frond naming Stripe would be the one thing Fougere refuses.
Delete OgonePayment.ts and the ports: line disappears with it: one implementation is
resolved by convention. The key only exists for the exception. A key that matches no
port anywhere is reported at boot rather than silently obeyed.
Port or Frond?
A remote Frond also lets you swap what answers, and it swaps the process too. The line between them is checkable:
- The collaborator owns data — entities, tables, seeds → it is a Frond, and
remotes:is its statement. - It only acts on the outside world — a PSP, a mailer, a file store → it is a port,
and
ports:is its statement.
Fougere's own ports follow the same rule from the other side: Storage and HttpRouter
are declared by the framework because it derives things from their shape (Crud, the
DDL, the identity card). Nothing is derived from Payment, so Payment is yours.
What it does not do
A port lets you express a seam. It does not tell you where the seams are — cutting a domain into collaborators is still your call, and nothing here decides it for you.
Next: Queries & commands.