Skip to content

Build a plugin registry

Goal: several modules, none knowing about the others, each contributing an entry — a health check, an event handler, a plugin — to one list the composition root collects whole.

The compiled, spec-covered version of this page is the plugin-registry example.

Declare a set port

Port.many fixes the member shape — what one contribution looks like. The port's own service, what Context.get actually returns, is the whole accumulated list, readonly Member[]:

ts
class HealthCheck extends Port.many("HealthCheck")<{
  readonly name: string;
  readonly run: () => AsyncResult<"healthy", HealthCheckFailed>;
}> {}

Contribute from independent modules

Provider.member contributes one member. Several providers targeting one set port is the point, not a collision — the duplicate-provider defect that guards ordinary ports does not apply here:

ts
const DatabaseModule = Module("Database")({
  provides: [
    Provider(Database)({ value: { ping: () => OkAsync("healthy") } }),
    Provider.member(HealthCheck)([Database], {
      sync: (db) => ({ name: "database", run: db.ping }),
    }),
  ],
  exports: [Database, HealthCheck],
});

const CacheModule = Module("Cache")({
  provides: [
    Provider(Cache)({ value: cacheService }),
    Provider.member(HealthCheck)([Cache], {
      sync: (cache) => ({ name: "cache", run: cache.ping }),
    }),
  ],
  exports: [Cache, HealthCheck],
});

Neither module imports the other, and neither knows how many other contributors exist. Provider.member takes the same construction family an ordinary provider does — value, sync, make, class, acquire/release — so a member may have dependencies, fail to construct, or own a resource, exactly like any other service.

Collect at the composition root

ts
const AppModule = Module("App")({
  imports: [DatabaseModule, CacheModule],
  exports: [DatabaseModule, CacheModule], // whole-module re-export
});

await Module.build(AppModule).flatMap((ctx) => {
  const checks = ctx.get(HealthCheck); // readonly Member[] — BOTH contributions
  return runAll(checks);
});

ctx.get on a set port returns every contribution, accumulated across module boundaries, in a stable order (declaration order within each level of the build). A set port with no contributors is not an error — the list is empty.

Note the whole-module re-export: exports: [DatabaseModule, CacheModule] re-exports everything those imports export, which is how HealthCheck (and Database, and Cache) stay nameable on the built context without App listing each port again.

Run the contributions your way

What "run them all" means — fail fast, fold failures into a report, race them — is the composition root's decision, not the library's. The example folds:

ts
const runHealthChecks = (checks: ServiceOf<typeof HealthCheck>) =>
  Promise.all(
    checks.map((check) =>
      check.run().match({
        ok: () => ({ name: check.name, status: "healthy" as const }),
        errCases: (m) =>
          m.with(P.tag("HealthCheckFailed"), (e) => ({
            name: check.name,
            status: "unhealthy" as const,
            reason: e.reason,
          })),
        defect: () => ({
          name: check.name,
          status: "unhealthy" as const,
          reason: "unexpected",
        }),
      }),
    ),
  );

A failing check is data the caller wants, not a reason to stop asking the others.

One port id, one kind

A port id must be one thing or the other everywhere: registering the same id through Provider(...) in one place and Provider.member(...) in another is a wiring defect, caught before any factory runs. The type system already steers you right — Provider on a set port and Provider.member on an ordinary one both fail to compile — and the runtime check backs it up against widened types.

Released under the MIT License.