Skip to content

Build a plugin registry

How-to. Collect contributions from modules that do not know about each other into one list. For the ports and providers involved, precisely, see Ports and Providers.

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.

Declare a set port

Port.many fixes the member shape — what one contribution looks like. The port's own service, what Context.get returns, is the 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)({
      inject: {},
      value: { ping: () => OkAsync("healthy") },
    }),
    Provider.member(HealthCheck)({
      inject: { db: Database },
      sync: ({ db }) => ({ name: "database", run: db.ping }),
    }),
  ],
  exports: [Database, HealthCheck],
});

const CacheModule = Module("Cache")({
  provides: [
    Provider(Cache)({ inject: {}, value: cacheService }),
    Provider.member(HealthCheck)({
      inject: { cache: 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, plus the hooks — 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
});

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

ctx.get on a set port returns every contribution, accumulated across module boundaries. Members land in the order construction places them — a dependency-free member is ready before a sibling that depends on something else — so treat the list as a set, not a sequence. A set port with no contributors is not an error: the list is empty.

Note the whole-module re-export: exports: [DatabaseModule, CacheModule] forwards 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. Folding keeps a failing check as data rather than a reason to stop asking the others:

ts
const runHealthChecks = (checks: ServiceOf<typeof HealthCheck>) =>
  allAsync(
    checks.map((check) =>
      check
        .run()
        .map(() => ({ name: check.name, status: "healthy" as const }))
        .recoverErrCases((m) =>
          m.with(P.tag("HealthCheckFailed"), (e) => ({
            name: check.name,
            status: "unhealthy" as const,
            reason: e.reason,
          })),
        ),
    ),
  );

recoverErrCases empties each check's error channel, so allAsync can only short-circuit on a defect — a check that threw rather than answered, which is a bug worth surfacing.

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 types steer you towards Provider.member — on an ordinary port its member shape is never, so no arm can be satisfied and the call does not compile — and the runtime check backs that up against widened types.

WARNING

Provider(SetPort)(…) — the ordinary constructor on a set port — is not rejected by the types: it qualifies against the whole readonly Member[], and at runtime the port is still a set port, so the array you supplied lands as one member. Contribute to a set port with Provider.member only.

See also

  • PortsPort.many beside ordinary Port.
  • ProvidersProvider.member and the construction family.
  • Wiring defects — every pre-construction check.

Released under the MIT License.