Skip to content

Plugins and hooks

A plugin is how a capability joins the app: a database pool, an auth adapter, a tracer, a mailer. It registers what it provides, runs its boot and shutdown work, and gets out of the way. A hook is a point in the app's life — registering, booting, ready, shutting down, or a step inside a dispatch — that a plugin can observe or shape.

Between them they cover everything cross-cutting: anything that isn't a single handler's job and shouldn't be copy-pasted into every handler.

Using a plugin

Most cross-cutting concerns already ship as a plugin behind a clean contract. You install one by passing it to createApp:

ts
import { createApp, definePlugin } from "@nwire/app";

// A plugin binds an in-memory clock other code can resolve. A real app
// would reach for `drizzlePlugin`, `identityPlugin`, `mailPlugin`, etc.
const clockPlugin = () =>
  definePlugin("clock", ({ bind }) => {
    bind("clock", { now: () => new Date() });
  });

const app = createApp({
  appName: "orders",
  plugins: [clockPlugin()],
});

Anything a plugin binds is resolvable from a handler's ctx by the same name:

ts
const clock = ctx.resolve<{ now(): Date }>("clock");

The handler codes against the name "clock"; the plugin decides what backs it. Swap the plugin, keep the handler — that's the contract-and-adapter seam. Shipped plugins follow the same rule: drizzlePlugin binds a database handle under "db", identityPlugin seeds envelope.user, tracingPlugin emits a span per dispatch. Each is installed the same way.

.with(plugin) is the typed alternative to the plugins array. It installs identically but advances the app's type, so a plugin that contributes a ctx member narrows what handlers see:

ts
import { createApp, definePlugin } from "@nwire/app";

const clockPlugin = () =>
  definePlugin("clock", ({ bind }) => {
    bind("clock", { now: () => new Date() });
  });

const app = createApp({ appName: "orders" }).with(clockPlugin());

Writing a plugin

definePlugin(name, ctx => …) gives you a small builder. The everyday shape is: bind something into the container, set it up on boot, tear it down on dispose.

ts
import { definePlugin } from "@nwire/app";
import { createPool, type Pool } from "./pool";

export const dbPlugin = (url: string) =>
  definePlugin("db", ({ bind, boot, dispose }) => {
    let pool: Pool;
    bind("db", () => pool);
    boot(async () => {
      pool = createPool(url);
      await pool.connect();
    });
    dispose(async () => {
      await pool.end();
    });
  });

bind runs immediately when the plugin is set up; boot runs later, once all plugins are bound and just before the app serves traffic; dispose runs in reverse registration order on app.stop(). A plugin that booted after the database tears down before it. The endpoint's graceful drain relies on this ordering.

For a plugin that takes options, use the two-phase object form. The value you construct it with arrives on ctx.options, fully typed:

ts
import { definePlugin } from "@nwire/app";

interface MailOptions {
  readonly from: string;
}

export const mailPlugin = (options: MailOptions) =>
  definePlugin<void, MailOptions>("mail", {
    options,
    setup({ bind, options }) {
      bind("mailFrom", options.from);
    },
  });

The object form also takes a separate register phase that runs before any plugin's setup — use it when one plugin must declare a binding or a hook slot that another plugin's setup depends on.

Hooks: observe or shape the lifecycle

The app fires typed lifecycle hooks as it moves through its states — AppRegistering, AppBooting, AppBooted, AppReady, AppShuttingDown, AppShutdown, and the per-plugin Plugin* slots. A plugin observes one with on, and the payload is typed from the hook name:

ts
import { definePlugin } from "@nwire/app";

export const readyLog = definePlugin("ready-log", ({ on }) => {
  on("AppBooted", ({ appName, bootedAt }) => {
    console.log(`${appName} booted at ${bootedAt}`);
  });
});

Note the split: a plugin's on observes a framework hook — a slot in the lifecycle. It is not how you react to a domain event; that's when in a listener. A hook is part of the framework's machinery; an event is a fact your domain announced. on for hooks, when for events — one rule, no overlap.

A plugin can also declare its own hook slot with defineHook, so other plugins can tap a moment yours owns. The slot is observed for telemetry like the built-in ones, so it shows up in the trace view.

Contributing a capability

Binding a value covers most plugins. A capability goes one step further: it adds a member to the handler ctx on every dispatch — a typed verb like ctx.publish or ctx.enqueue — rather than something handlers have to resolve. Define one with defineCapability and install it on the runtime from a plugin's setup:

ts
import { defineCapability, definePlugin } from "@nwire/app";

const greetCapability = defineCapability<{ greet: (name: string) => string }>({
  name: "greet",
  provideCtx: () => ({
    greet: (name: string) => `hello, ${name}`,
  }),
});

export const greetPlugin = () =>
  definePlugin("greet", ({ runtime }) => {
    runtime.add(greetCapability);
  });

provideCtx runs once per dispatch; its returned object is spread into the handler ctx, so a handler can call ctx.greet("Avi") directly. The <{ greet: … }> type argument is the contribution's shape — install the plugin with .with() and that member appears on the typed ctx. Restrict a capability to certain handler kinds with kinds: ["action"] when the verb only makes sense for some dispatches.

This is exactly how the built-in publishCapability contributes ctx.publish, and how @nwire/queue contributes ctx.enqueue — your own plugins extend the ctx the same way the framework does.

Boot order, in one place

When app.start() runs, the sequence is fixed:

  1. Every plugin's register runs, in registration order.
  2. Every plugin's setup runs, in registration order — this is where bind, runtime.add, and hook observers attach.
  3. AppRegistering then AppBooting fire (either can veto the boot).
  4. For each plugin in order: boot callbacks run, then PluginBooted.
  5. Container health checks run; a failed check aborts the boot.
  6. AppBooted and AppReady fire — the app now serves traffic.

Shutdown reverses it: AppShuttingDown fires, each plugin's dispose callbacks run in reverse order, the container disposes its bindings, and AppShutdown fires last. The ordering is what lets a plugin assume its dependencies are still alive while it tears down.

What the plugin context gives you

definePlugin(name, ({ bind, config, on, boot, dispose, container, runtime, hooks, defineHook, options }) => …):

  • bind(name, factory, opts?) — register a value in the container; resolvable as ctx.resolve(name) in handlers. opts.dispose and opts.check add teardown and a boot-time health check.
  • config — the app's resolved config (a shortcut for container.resolve("config")), available in register and setup because the boot channel binds it first. This is how a battery self-configures: read your conventional slice (config.mail), let an explicit options override it. Surface an optional adapter by importing it statically and passing it as an option — never load it with a dynamic import(), which would make a dependency appear at runtime that's in neither package.json nor the file.
  • on(hook, fn) — observe a framework lifecycle hook; the payload is typed from the hook name.
  • boot(fn) / dispose(fn) — async setup before serving / teardown on stop.
  • runtime — the dispatch substrate. runtime.add(cap) installs a capability; runtime.use(mw) wraps every dispatch.
  • container — the App's Container for direct register/resolve.
  • hooks / defineHook(name) — tap existing hooks, or declare a new slot other plugins can tap.
  • options — the typed value the plugin was constructed with.

Plugins you'll reach for

Most cross-cutting concerns are already a plugin behind a clean contract: identityPlugin (auth), rbacPlugin (permissions), tracingPlugin (OpenTelemetry spans), drizzlePlugin (database), mailPlugin (email). Forge — the deeper domain battery — installs the same way, through its own forgePlugins; it's covered in its own section when you need it.

See also

MIT licensed.