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
  }
}
Calling 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 iswhat a link sees
Storagewhere an entity's rows liveevery 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.

The judge stays outside the chain. A row the entity refuses never reaches a link at all — it is refused at the facade, and what a link receives is the value the schema already parsed.

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.

Built with Fougere and Nuxt, hosted on GitHub Pages.