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
Repository<Post>, Emit<PostPublished> and Facade<PostHandler> resolve. Providers were
the one case where the type named the realization instead.
A port under a port
A class that something extends is a port, however deep it sits. With
StripePayment extends CardPayment extends Payment, a handler asking for Payment and one
asking for CardPayment both receive StripePayment — and CardPayment does not even need
its own file entry to count.
What answers is a class you can build. An abstract class in between is never picked: the
scan reads the keyword TypeScript erases, so it stays a port and nothing else. A concrete
class in between is a second candidate for Payment, and the boot refuses the pair the same
way it refuses two implementations — ports: picks one.
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/RedactingLogger.ts
export default class RedactingLogger extends Logger {
constructor(private inner: Logger) { super(); }
info(message: string, ...args: unknown[]) {
this.inner.info(message.replace(/token=\S+/g, 'token=***'), ...args);
}
}
// any handler, unchanged
constructor(private logger: Logger) {} // receives RedactingLogger
Asking for inner is what keeps an overridden method working: it is the logger the app
built, so the line still reaches the terminal, your log destinations
and the name of the operation it was written in. A method the wrapper does not override runs
on its own base instead — built by super() with nothing — so debug here reaches no
destination, and child() hands back a logger that skips the wrapper. Override every level
you use.
// looks like it adds something, and silently drops every destination
export default class AuditLogger extends Logger {
info(message: string, ...args: unknown[]) {
audit(message);
super.info(message, ...args); // terminal only
}
}
super.info() does not bring that back. A subclass that asks for nothing is a
second logger, built by the container with no arguments: super runs on that one, which
still prints to the terminal but sends nothing to your destinations and no longer knows
the operation. Nothing fails — the lines simply stop arriving elsewhere.Ask for inner and call it instead.The default stays for every Frond that declares no subclass — the override is per Frond,
because a Frond's services are its own. With the default, each class gets a logger named after
it: a line written in PricingService reads app:blog:PricingService.
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:
[claim] 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({
fronds: { blog: 'http://127.0.0.1:4100' },
ports: { Payment: process.env.PSP === 'ogone' ? 'OgonePayment' : 'StripePayment' },
});
ports: sits beside fronds: for the same reason sources: does. fronds: 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.
A chain, not just a choice
The same key says what stands IN FRONT of the realization. A link is recognized by its form — it extends the port and asks for it:
// services/RetryingPayment.ts
export default class RetryingPayment extends Payment {
constructor(private inner: Payment) { super(); }
charge(amountCents: number) {
try { return this.inner.charge(amountCents); }
catch { return this.inner.charge(amountCents); }
}
}
ports: { Payment: ['RetryingPayment', 'StripePayment'] } // outside in
this.payment is now a RetryingPayment holding a StripePayment. The handler is
unchanged, and so is the port. Add a third and a fourth — the order is the list:
ports: { Payment: ['AuditPayment', 'TimingPayment', 'RetryingPayment', 'StripePayment'] }
// → audit(timing(retrying(stripe)))
inner is not super. Both classes extend Payment, so they are siblings: super.charge()
would reach the abstract base, which has no body. The inheritance says only which key the
class answers under; the work goes through this.inner, which is why the chain can be any
length.
One link needs no declaration — its own signature says it. Two refuse, the same way two realizations do: which one stands in front is an order, and scan order is not one.
Extending Fougere itself
The framework declares a few ports of its own, and they are extended with the same two
lines. They differ from yours in one way, which decides the rest: their realization is
handed in, not scanned — storageFactory builds one storage per entity, and no class
declares it. So a class extending one can only stand in front of it.
| what it is | what a link sees | |
|---|---|---|
Storage | where an entity's rows live | every read and write, per entity |
Storage is the only one today. Transport and HttpRouter are not extendable this
way, and the reason is the same for both: a link is recognized by its prototype chain, so a
host port has to be a class. Transport is a function type, and HttpRouter is never held by
the boot — the host builds it and hands it to registerRoutes. Both already have their own
way in, one section down.
// services/Auditing.ts — in front of EVERY entity's storage
import { Storage } from '@fougere/core';
export default class Auditing extends Storage {
constructor(private inner: Storage) { super(inner); }
override async create(row: Record<string, unknown>) {
await ship(row);
return this.inner.create(row);
}
}
Every gesture is the port's, and this one writes one. What a link does not override reaches the next one through the base — so a link is as small as what it changes.
It names no entity: Storage is the host port, so one class is in front of every entity of its
own Frond. ports: { Storage: ['Auditing'] } states the chain when there are two, exactly
as above.
The scope is the Frond and no key widens it, because the scope is the location: a link
sits beside the code it changes, and goes where that code goes. That is what keeps the
gradient true — a Frond moved behind fronds: would otherwise
silently leave the reach of a link its own code never mentions. A link that must be
everywhere is a Frond that is everywhere, which an extension already brings.
Reaching a transport, and an HTTP router
Neither is a host port, and neither leaves you without a way in.
A transport is swapped whole, by the host that boots the app — the same place fronds:
is read, because how a call travels is a deployment fact:
boot({ root, createContainer, remoteTransport: (url) => mySocketTransport(url) });
And an outgoing call to a Frond behind fronds: already runs through the app's
middlewares before it reaches the wire, so retrying, timing or logging one is a
middleware, not a transport:
app.use(async (ctx, next) => { /* ctx.address, ctx.operation */ return next(); });
What that does not reach is the envelope itself — the encoding, the headers, the connection. For those you are the transport, and you swap it.
An HTTP router is the host's object from start to finish: it builds one
(createHonoRouter(hono)), hands the generated routes to it, and can take a middleware on
it directly — router.use(middleware) is part of the interface. The routes themselves are
an array before they are registered, so filtering or rewriting them is an ordinary map:
registerRoutes(router, generateRoutes(app).filter((route) => route.method !== 'DELETE'));
Where an op answers is the Frond's to say, in its frond.config.ts — see
surfaces for the three parts of a URL and who sets each one.
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
fronds: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. Of the
two, Storage is the one you can stand in front of today — see above.
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.