Writing plugins
This page starts from the smallest possible plugin and adds dependencies, provisions, config validation, optional dependencies and failure handling.
The smallest form
import { definePlugin } from "dougong"
const plugin = definePlugin({
name: "app.hello",
setup() {
console.log("started")
},
})name is a required stable identifier. It is used for diagnostics and installation IDs (app.hello:1); it plays no part in dependency resolution — that is what Contracts are for.
definePlugin preserves the declared Plugin shape for type inference while normalizing it at the boundary into an immutable plain data record containing only name, config, requires, provides and setup. Declarations cannot use symbols, hidden properties, accessors, class instances or unknown fields; requires and provides must likewise be plain data records containing only enumerable string own keys. Mistakes therefore surface where the Plugin is written rather than when the Host starts.
Declaration normalization copies and freezes each Contract identity, including the Service inside optional(). Later mutation of a structural input token cannot change an admitted declaration. Internal requires and provides maps always exist, including frozen empty maps; config and Service values remain opaque application-owned values.
Declaring dependencies
const DATABASE = service<Database>("app/database")
const ROUTES = extensionPoint<Route>("http/routes")
definePlugin({
name: "app.users",
requires: {
db: DATABASE,
routes: ROUTES,
},
setup(ctx) {
ctx.db.query("select 1")
ctx.routes.get()
},
})The keys in requires are aliases you choose; they need not match the Contract ID. That lets one plugin require two Services of the same type:
requires: {
primary: PRIMARY_DB,
replica: REPLICA_DB,
}Within one Plugin, a Contract ID may appear only once. If two aliases point at the same Contract, or the same Service appears in both requires and provides, definePlugin() rejects immediately; remove the duplicate alias or declare distinct Contract IDs for capabilities with distinct semantics.
Reserved names
ctx carries a set of built-in members, and aliases may not collide with them: signal, meta, log, cleanup, lifetime, spawn, on, emit, contribute. Using one throws immediately from definePlugin.
Optional dependencies
import { optional } from "dougong"
definePlugin({
name: "app.telemetry",
requires: { tracer: optional(TRACER) },
setup(ctx) {
ctx.tracer?.startSpan("boot")
},
})With no provider, ctx.tracer is undefined and the Installation activates normally. If a provider later appears or disappears, its Instance is rebuilt — so ctx.tracer never changes within one Instance lifetime.
optional() accepts only Services. ExtensionPoints do not need it: an empty map is already valid.
Providing capabilities
const USERS = service<UserService>("app/users")
definePlugin({
name: "app.users",
requires: { db: DATABASE },
provides: { users: USERS },
setup(ctx) {
return {
users: createUserService(ctx.db),
}
},
})Every key in provides must appear in the return value; a missing one is SERVICE_NOT_RETURNED. This is caught at compile time:
provides: { users: USERS },
setup() {},TypeScript rejects () => void because setup must return the declared ProvidedServices.
Two Plugin declarations providing the same Contract throw SERVICE_CONFLICT while the graph is built — before any Instance starts.
The output alias then is reserved because setup() supports Promise results. A Service ID such as app.then is valid when exposed under another alias, for example thenService.
Contributing to an ExtensionPoint
definePlugin({
name: "app.user-routes",
setup(ctx) {
ctx.contribute(ROUTES, "users.list", { path: "/users", run: listUsers })
ctx.contribute(ROUTES, "users.show", { path: "/users/:id", run: showUser })
},
})The second argument is a local key, unique only within the current Instance. Core composes the real key:
<escaped installation id>/<escaped local key>where % and / become %25 and %2F. So different plugins may reuse the same local key, and no two distinct (installation, key) pairs can ever collide.
The returned Contribution supports update and early withdrawal:
const c = ctx.contribute(ROUTES, "users.list", route)
c.update(nextRoute)
c.dispose()Not calling dispose() is fine — every contribution is withdrawn when the Instance stops.
Configuration and validation
config accepts any Standard Schema implementation (Zod, Valibot, ArkType, …):
import { z } from "zod"
const schema = z.object({
hostname: z.string(),
port: z.number().default(5432),
})
const db = definePlugin({
name: "app.db",
config: schema,
provides: { db: DATABASE },
setup(ctx, config) {
return { db: connect(config.hostname, config.port) }
},
})
host.install(db, { hostname: "localhost" })Input and output are two distinct types: host.install() accepts the input (port optional), while setup receives the output (port defaulted).
Validation failure throws ConfigValidationError (code CONFIG_INVALID) carrying an issues array:
try {
await installation.ready()
} catch (e) {
if (e instanceof ConfigValidationError) {
e.issues.forEach((i) => console.error(i.path, i.message))
}
}Every affected Installation's Plugin config is validated before any running Instance is stopped. One bad config never leaves your application halfway down.
Asynchronous setup
definePlugin({
name: "app.db",
provides: { db: DATABASE },
async setup(ctx) {
const client = await connect()
ctx.cleanup(() => client.close())
return { db: client }
},
})Installations in the same topological layer run setup concurrently. During startup, ctx.signal aborts if any setup in that layer fails, so slow work can be cancelled:
async setup(ctx) {
const client = await connect({ signal: ctx.signal })
...
}What failure does
When setup throws:
- Every resource the Instance already acquired is released (cleanups run in reverse)
- Its staged listeners, contributions and Contract kinds are never published
ctx.signalaborts for the other Instances in the same layer- The whole change rolls back to the previous execution graph
installation.ready()rejects andinstallation.statusbecomes"failed"host.statusreturns to its pre-change value — it never stops in an intermediate state
const installation = host.install(brokenPlugin)
await expect(installation.ready()).rejects.toThrow("setup failed")
expect(host.status).toBe("active")Throwing a non-Error value (throw "boom") is classified as a DougongError with code INSTALLATION_UNAVAILABLE, keeping the original value in cause — so undefined never means both "the failure value" and "no failure".
Update and remove
const installation = host.install(plugin, { hostname: "a" })
await installation.update({ config: { hostname: "b" } })
await installation.update({ plugin: nextVersion })
await installation.remove()An update preserves Installation identity: the ID, the position in diagnostics and the Group membership all stay. The active Instance is replaced, and only the affected dependency closure restarts; unrelated Installations are untouched.
installation.id
installation.status
installation.groupId
await installation.ready()id is stable (for example, app.db:1); groupId identifies the owning Group. Status is pending, active, stopping, failed or removed. ready() resolves after committed readiness and rejects on failure.
A complete example
import { createHost, definePlugin, extensionPoint, optional, service } from "dougong"
import { z } from "zod"
const DATABASE = service<Database>("app/database")
const METRICS = service<Metrics>("app/metrics")
const ROUTES = extensionPoint<Route>("http/routes")
const database = definePlugin({
name: "app.database",
config: z.object({ url: z.string(), poolSize: z.number().default(10) }),
provides: { db: DATABASE },
async setup(ctx, config) {
const client = await createPool(config.url, config.poolSize)
ctx.cleanup(() => client.end())
ctx.log.info("database connected")
return { db: client }
},
})
const users = definePlugin({
name: "app.users",
requires: { db: DATABASE, metrics: optional(METRICS) },
setup(ctx) {
ctx.contribute(ROUTES, "list", {
path: "/users",
run: async () => {
ctx.metrics?.count("users.list")
return ctx.db.query("select * from users")
},
})
},
})
const host = createHost({ name: "api" })
host.install(users)
host.install(database, { url: process.env.DATABASE_URL! })
await host.start()Next
- Lifetime and resources — the full rules for
cleanup/spawn/lifetime - Transactions and change — changing several plugins atomically
- Core API specification — edge cases for every API