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:
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:
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:
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.
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:
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:
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:
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:
- Every plugin's
registerruns, in registration order. - Every plugin's
setupruns, in registration order — this is wherebind,runtime.add, and hook observers attach. AppRegisteringthenAppBootingfire (either can veto the boot).- For each plugin in order:
bootcallbacks run, thenPluginBooted. - Container health checks run; a failed check aborts the boot.
AppBootedandAppReadyfire — 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 asctx.resolve(name)in handlers.opts.disposeandopts.checkadd teardown and a boot-time health check.config— the app's resolved config (a shortcut forcontainer.resolve("config")), available inregisterandsetupbecause the boot channel binds it first. This is how a battery self-configures: read your conventional slice (config.mail), let an explicitoptionsoverride it. Surface an optional adapter by importing it statically and passing it as an option — never load it with a dynamicimport(), which would make a dependency appear at runtime that's in neitherpackage.jsonnor 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'sContainerfor 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
definePluginreference — every builder method and lifecycle ordering.defineCapabilityreference — the ctx-contribution primitive in full.- Observability — the tracing plugin and the live trace view.
- Middleware + plugins guide — HTTP middleware, per-route middleware, and reusable handler chains.