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.

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:

PortWhere 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
Storagein front of the rows — below
Loggerevery 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.

A subclass that asks for no 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.

Put it to work

Built with Fougere and Nuxt, hosted on GitHub Pages.