Port
A port is a capability your code asks for without naming who provides it. A handler that writes
constructor(private payment: StripePayment) has named its provider, and its tests, its sandbox
and the day you change payment provider all pay for that line. Ask for the capability instead:
// services/Payment.ts — the port. It names no provider.
export default abstract class Payment {
abstract charge(amountCents: number): Charge;
}
// services/StripePayment.ts — what answers it.
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, and nothing declares that: extends is the whole
registration. There is no ports/ directory and no factory — a class in services/ becomes a
port the moment another class extends it.
Three words
abstract class Payment { … } // the PORT — what a class asks for
class StripePayment extends Payment { … } // the REALIZATION — what answers it
class Auditing implements Partial<Payment> { // a LINK — what stands in front of it
constructor(private inner: Payment) {}
}
- The port is what is asked for.
- The realization answers it: one class, written with
extends. - A link stands in front of it, and passes the call on to its
inner. There may be none, one or several.
fougere ports prints every port of the app: its links in order, then the realization they reach.
Two realizations
Which provider a deployment uses changes between development and production, so it is not a
fact about the code. When two classes extend Payment, the boot refuses to guess:
Fougere boot refused: 1 declaration(s) that do not hold:
[port-implemented-twice] Payment
OgonePayment and StripePayment both extend Payment. Pick one in fougere.config.ts: ports: { Payment: 'OgonePayment' }.
Keeping one silently would depend on the order files are read in, and the app would charge the wrong provider without a word. The choice goes in the config:
// fougere.config.ts
export default defineFougere({
ports: { Payment: process.env.PSP === 'ogone' ? 'OgonePayment' : 'StripePayment' },
});
ports: sits beside fronds: and sources:: fronds: says where a call goes, sources:
where a row is, ports: who performs an action. None belongs inside a frond, which
describes itself and not its deployment. Delete OgonePayment.ts and the line can go: with one
realization there is nothing to choose. A ports: key that matches no port is reported at boot.
A port can sit under a port. With StripePayment extends CardPayment extends Payment, a class
asking for Payment and one asking for CardPayment both receive StripePayment. An abstract
class in between is never picked; a concrete one is a second candidate, and the boot asks
ports: to choose.
Links
A link stands in front of a port to add something — an audit line, a timing, a fallback — while the handler and the realization stay as they are. Write only what you change, and pass the call on:
// services/AuditPayment.ts
export default class AuditPayment implements Partial<Payment> {
constructor(private inner: Payment, private ledger: Ledger) {}
charge(amountCents: number) {
const charge = this.inner.charge(amountCents);
this.ledger.record(`${charge.reference} ${charge.amountCents}`);
return charge;
}
// refund() is not written: it goes to the inner as it is
}
A class is a link because it says so: implements Partial<Payment> and an inner: Payment.
Without the clause, a class asking for a Payment is an ordinary service. TypeScript erases
implements, so the scan reads it; a frond() written by hand states it —
{ ctor: AuditPayment, deps: ['Payment'], implements: ['Payment'] }.
One link needs nothing more. Two need an order, outside in, and the boot refuses them without one:
ports: { Payment: ['AuditPayment', 'TimingPayment', 'StripePayment'] }
// → audit(timing(stripe))
A link may also extend the port and hand its inner to super — constructor(inner: Payment) { super(inner); }. The difference is what you leave out. Implemented, it goes to the inner
untouched. Extended, the port's own code runs through your methods: a chargeTwice() that the
port writes over this.charge would pass through your charge twice.
A retry is not a link's job. Whether a failure is worth asking again, and whether asking again
is safe, is known where the call crosses — the transport between two fronds
(transport-chain) or the HTTP
client inside StripePayment — and neither reaches charge.
The ports Fougere opens
The same mechanism changes what the framework does. Fougere answers these itself, and you stand in front of them the same way:
| Port | Where it stands |
|---|---|
Dispatcher · Dispatcher<PostHandler> | around every call of a frond · of one handler — see Around every call |
Rest · Rest<PostHandler> | around a REST request — see REST |
Collect<User> | where a parameter is filled — see Authentication |
Present<Post> | where fields are added to an answer — see A computed field |
Env<Meili> | where a value is read — see Values |
Storage | in front of the rows — below |
Logger | every line a class writes — below |
A port about something names it as it is written — Rest<PostHandler>, Repository<Post>,
Env<Meili> — so no class name can take its place. The full list is in the
reference.
In front of the rows
Storage is a class as well as an interface. A link extends it, asks for one, and writes only
the gestures it changes:
// services/Auditing.ts
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);
}
}
It covers every entity of its frond.
The logger
Logger is a port like Payment, and a class extending it takes the frond's logger over:
// services/RedactingLogger.ts
export default class RedactingLogger extends Logger {
constructor(inner: Logger) { super(inner); }
info(message: string, ...args: unknown[]) {
super.info(message.replace(/token=\S+/g, 'token=***'), ...args);
}
}
// any handler, unchanged
constructor(private logger: Logger) {} // receives RedactingLogger
super(inner) is what keeps the line whole: the inner is the logger the app built, so the
wrapper writes with its name, its destinations and the
operation it runs in, and what it leaves out — warn here — still arrives everywhere.
child() returns a child of the real logger, which skips the wrapper.
inner is a second logger built with no arguments. It still prints
to the terminal, but sends nothing to your destinations and no longer knows the operation.
Nothing fails: the lines just stop arriving elsewhere. Ask for inner and hand it to super.Without a subclass, each class gets a logger named after it: a line written in
PricingService reads app:blog:PricingService. A level change reaches every logger, children
included, without rebuilding them (lifecycle).
A repository is not a port in this sense: extending Repository<Post> seats a class on Post's
rows — see Repository.
Port or frond?
A frond placed elsewhere also changes what answers, and it moves the process too. The line is easy to check:
- the collaborator owns data — entities, tables, seeds → it is a frond, and
fronds:places it; - it only acts on the outside world — a payment provider, a mailer, a file store → it is a
port, and
ports:chooses it.
A port lets you write a seam. It does not tell you where the seams are: cutting a domain into collaborators stays your decision.