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 thatextends 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.

Built with Fougere — this site runs on the framework it documents.