What a Fastify plugin is and how register() builds a tree
A Fastify plugin is an async function with the signature (instance, opts) that you pass to fastify.register(). Fastify does not run it against the instance you called register() on. It creates a new child context that inherits from the parent and passes that child in as instance. Anything the plugin adds stays inside that child and its descendants: decorators, hooks, routes, and nested registrations. Your application is therefore a tree of encapsulated contexts, not one shared object.
Consider two sibling plugins registered on the same parent. The billing plugin adds an onRequest hook that checks an API key and decorates the instance with invoiceService. The public plugin registers a health route. Requests to the health route never run the API-key hook, and public cannot access invoiceService. The parent cannot see either addition. Inheritance only flows downward.
root sees: logger, config
└── infrastructure sees: root + db decorator
├── billing sees: root + db + auth hook + invoiceService
└── public sees: root + db + health route
The reason is containment. A hook or decorator affects only the subtree that asked for it, so one feature's auth rules, error handler, or serializer cannot leak into another feature. You can reason about a single branch without reading the whole app.
Model your app this way if several teams or features share one process and need different auth, validation, or error handling. It also suits plugins you might later move into a separate service. Each branch is close to standalone.
const Fastify = require('fastify')
const app = Fastify({ logger: true })
app.decorate('config', { env: 'staging' }) // root: every context sees this
app.register(async function infrastructure (infra) {
// Visible to infrastructure and everything registered inside it
infra.decorate('db', { query: async () => [{ id: 'inv_123' }] })
infra.register(async function billing (instance) {
instance.decorate('invoiceService', { list: () => instance.db.query() })
// Runs only for routes in this context, so /health is not affected
instance.addHook('onRequest', async (request, reply) => {
if (request.headers['x-api-key'] !== 'example-key-not-real') {
reply.code(401).send({ error: 'invalid api key' })
return reply
}
})
instance.get('/invoices', async () => instance.invoiceService.list())
})
infra.register(async function publicRoutes (instance) {
instance.get('/health', async () => ({
hasDb: instance.hasDecorator('db'), // true: inherited from parent
hasInvoiceService: instance.hasDecorator('invoiceService') // false: sibling
}))
})
})
app.ready().then(() => {
// The root cannot see anything added by its descendants
console.log(app.hasDecorator('db'), app.hasDecorator('invoiceService')) // false false
})
Two sibling plugins under an infrastructure plugin. Both inherit the db decorator, but billing's auth hook and invoiceService are hidden from the public plugin and from the root.
How does it work? The mechanics in order
The behavior above comes from a small number of steps that Fastify repeats for every plugin:
register()queues the plugin. It does not call your function. The plugin and its options are added to a queue managed by Fastify's loader, avvio.- Boot drains the queue in registration order. This happens on
ready(), onlisten(), or when youawaitaregister()call. Each plugin is loaded before the next one in the same scope starts. - Fastify creates a child context, unless told not to. For a normal plugin, Fastify creates a new instance whose prototype is the parent instance. Property lookups on the child fall through to the parent, so the child sees the parent's decorators. Properties set on the child stay on the child, so the parent and siblings never see them. Hooks are handled the same way: the child starts with the parent's hooks and adds its own on top. If the plugin function carries
Symbol.for('skip-override'), which is whatfastify-pluginsets, this step is skipped and the plugin runs against the parent instance. - The plugin runs against that instance. Every
decorate(),addHook(), route definition, and nestedregister()it makes targets the child context. Nested registrations go into the queue and repeat steps 3 and 4 one level deeper. - Prefixes and options compose. A
prefixoption is appended to the parent's prefix, so nested plugins build up full paths. Other options are passed to the plugin function as its second argument. - Each route is bound to the context it was declared in. When a request arrives, Fastify runs the hooks that apply to that route's context, which are the hooks inherited from ancestors followed by the context's own, and then the handler.
Because all of this happens during boot, errors from step 4 surface from ready() or listen(), not from register(). The sections below cover each step in more detail.
Breaking encapsulation on purpose with fastify-plugin
Sometimes isolation is the wrong default. A database pool, an authenticate decorator or parsed configuration should be visible to every route, not just to the plugin that created it. The fastify-plugin package exists for this. It wraps your plugin function and sets Symbol.for('skip-override') on it. When register() sees that symbol, Fastify skips creating a child context and runs the plugin against the parent instance. Any decorators and hooks the plugin adds then belong to the parent, and every sibling and descendant registered after it inherits them.
The legitimate uses share one trait: the resource is infrastructure that the rest of the app depends on. Examples include a shared database client opened once per process, auth decorators such as request.user or fastify.authenticate, and a validated config object. Wrapping these lets feature plugins consume them without each one re-registering the dependency.
The trade-off cuts both ways. If you wrap too much, the tree collapses into one global namespace. Hooks then fire on routes that never asked for them, decorator names collide, and you lose the per-branch isolation described above. If you wrap too little, you hit the most common Fastify confusion: a decorator added in one plugin is undefined in a sibling, because it lives only inside that plugin's child context.
| Encapsulated plugin | fastify-plugin-wrapped plugin |
|
|---|---|---|
| Visibility | Its own context and descendants | Parent context, its siblings and their descendants |
| Typical use | Feature routes, route-specific hooks | DB clients, auth decorators, config |
| Risk | Decorators missing in siblings | Global namespace, unintended hook execution |
// app.mjs: run with `node app.mjs` (ESM, so top-level await works)
import Fastify from 'fastify'
import fp from 'fastify-plugin'
const app = Fastify()
// Encapsulated: the decorator lives only in this plugin's child context.
async function featureFlags (instance) {
instance.decorate('featureFlag', true)
}
// Wrapped with fastify-plugin: the decorator and hook are added to the parent.
const db = fp(async function dbPlugin (instance) {
// Stand-in for a real pool, opened once per process.
const client = { query: async (sql) => [{ id: 1, name: 'placeholder' }] }
instance.decorate('db', client)
// Close the shared resource when the app shuts down.
instance.addHook('onClose', async () => { /* await client.end() */ })
}, { name: 'db' })
app.register(featureFlags)
app.register(db)
// A sibling feature plugin registered after both.
app.register(async function users (instance) {
console.log('db visible:', instance.hasDecorator('db')) // true
console.log('featureFlag visible:', instance.hasDecorator('featureFlag')) // false
instance.get('/users', async () => instance.db.query('SELECT id, name FROM users'))
})
await app.ready()
await app.close()
A sibling plugin can see the db decorator from the fastify-plugin-wrapped plugin. It cannot see featureFlag from the encapsulated plugin, because that decorator stays in its own child context.
Decorators and hooks: how scope affects behavior
fastify.decorate() attaches a property to the instance. decorateRequest() and decorateReply() add properties to the request and reply objects. All three follow the same scoping rules as everything else in the tree. A decorator is visible in the context that declared it and in that context's descendants.
Declare request and reply decorators with a placeholder such as null, then assign the real value per request in a hook. There are two reasons for this. First, every request object ends up with the same set of properties, so its shape stays stable and the engine can optimize property access. Second, it avoids shared state. A reference-type initial value, such as an object or array, would be shared across every request, so one user's data could leak into another's response. The cost is boilerplate: you write a declaration and a hook for every per-request field.
Lifecycle hooks run for each request:
onRequestruns early, before the body is parsed. It is a good place for authentication.preHandlerruns after validation, just before the handler.onSendcan inspect or change the payload before it is written.
Application hooks run once per instance. onReady runs before the server starts accepting traffic, and onClose runs during shutdown, which is where you release connections.
A hook registered inside a child context applies only to routes in that context and its descendants. Sibling contexts never see it. For a route nested several levels deep, the parent's hooks run first, then each child's hooks in turn, down to the route's own context.
This has a cost in code review. A route's behavior depends on where it sits in the tree, so the hooks that affect it may be declared in files far from the route definition. Reviewers have to trace the registration path to know what actually runs.
const fastify = require('fastify')()
// Placeholders only. Real values are assigned per request in hooks, so each
// request has the same shape and no object is shared between requests.
fastify.decorateRequest('user', null)
fastify.decorateRequest('trace', null)
// Root hook: runs for every route in the tree, and runs first.
fastify.addHook('onRequest', async (request) => {
request.trace = ['root onRequest'] // a new array for each request
})
fastify.register(async function adminContext (admin) {
// Child hooks apply only to routes registered inside this context.
admin.addHook('onRequest', async (request) => {
request.trace.push('admin onRequest')
request.user = { id: request.headers['x-user-id'] ?? 'anonymous' }
})
admin.addHook('preHandler', async (request) => {
request.trace.push('admin preHandler')
})
// Response: trace is [root onRequest, admin onRequest, admin preHandler]
admin.get('/admin/stats', async (request) => ({ trace: request.trace, user: request.user }))
})
fastify.register(async function publicContext (pub) {
// Sibling context: the admin hooks never run here, so user stays null.
// Response: trace is [root onRequest] and user is null.
pub.get('/public', async (request) => ({ trace: request.trace, user: request.user }))
})
// Application hooks run once per instance, not once per request.
fastify.addHook('onReady', async () => { /* warm caches, verify config */ })
fastify.addHook('onClose', async () => { /* close database pools, flush queues */ })
fastify.listen({ port: 3000 }).catch((err) => {
console.error(err)
process.exit(1)
})
Request decorators start as null and get their values in hooks. A hook registered in the admin context runs after the root hook and only for admin routes, while the sibling public context sees only the root hook.
Load order, boot and why decorators go missing
Calling register() does not run the plugin. It adds the plugin to a queue managed by Fastify's loader (avvio), and the queue is processed asynchronously, in registration order, when the application boots. Boot happens on ready(), on listen(), or when you await a register() call. This is why a plugin can register child plugins, and why Fastify can build the encapsulation tree before any route is served.
Synchronous code that runs right after register() executes before the queued plugin has run. If a plugin decorates the instance with a database client and the next line reads that property, the property is not there yet. In TypeScript, the type declaration may still claim it exists. after() fixes this by scheduling a callback that runs once the plugins queued before it have loaded. Awaiting register() does the same thing in async code: execution resumes only after that plugin and everything queued ahead of it have finished.
Errors follow the same timing. An exception thrown inside a plugin, a duplicate decorator, or a plugin that never calls done or resolves its promise (which triggers a plugin timeout) is not reported at the register() call. It is reported when the queue runs, usually as a rejection from ready() or listen(). Tests that never call ready() can therefore pass while the application would fail at startup.
You can make ordering explicit in three ways:
- Wrap the plugin with
fastify-pluginand set itsdependencies(other plugin names) anddecoratorsoptions. The plugin then fails fast with a clear error if those dependencies are missing. - Pass a dependency list to
decorate(). It throws if the named decorators do not exist yet. - Register providers before consumers in the same scope, so that queue order matches dependency order.
import Fastify from 'fastify'
import fp from 'fastify-plugin'
// Provider: fp() skips encapsulation so `db` is visible in the parent scope.
const dbPlugin = fp(async (fastify) => {
fastify.decorate('db', { query: async (sql) => [] }) // placeholder client
}, { name: 'db-plugin' })
// Consumer: declares what it needs so a missing provider fails with a clear error.
const usersPlugin = fp(async (fastify) => {
// Third argument: decorate() throws if `db` does not exist yet.
fastify.decorate('users', { list: () => fastify.db.query('SELECT id FROM users') }, ['db'])
}, {
name: 'users-plugin',
dependencies: ['db-plugin'],
decorators: { fastify: ['db'] }
})
const app = Fastify()
app.register(dbPlugin)
console.log(app.hasDecorator('db')) // false: the plugin is only queued
app.after((err) => {
if (err) throw err
console.log(app.hasDecorator('db')) // true: everything queued before this has loaded
})
// Execution resumes only after usersPlugin and everything ahead of it have loaded.
await app.register(usersPlugin)
console.log(app.hasDecorator('users')) // true
// Boot errors (duplicate decorators, missing dependencies, plugin timeouts)
// surface here, not at the register() call.
await app.ready()
This example shows a decorator that is missing right after register() and present inside after() or after await register(). The consumer plugin uses fastify-plugin's dependencies option and decorate()'s dependency list to enforce load order, and ready() reports any boot errors.
How to structure a real codebase with prefixes and plugin layers
Split plugins into two layers and make the split visible in the directory layout.
Infrastructure plugins hold shared capabilities such as a database pool, configuration, an authentication decorator, or metrics. Wrap each one with fastify-plugin and register it at the root, so its decorators and hooks reach every route. Keep this layer small. Anything it exposes becomes an implicit contract for every team in the codebase.
Feature plugins own a domain: their routes, schemas, preHandler hooks, and any decorators that only they use. Do not wrap them. Encapsulation keeps a hook added for billing out of the users API, and lets two features declare a decorator with the same name without a collision.
Prefixes compose through nesting. If you register a v1 plugin with { prefix: '/v1' } and it registers the users feature with { prefix: '/users' }, a GET / route inside the users plugin is served at /v1/users. The feature does not need to know which version mounts it. A v2 parent can mount the same plugin, or a revised one, under a different prefix. Other values in the options object reach the plugin function as its second argument. Use them for per-mount settings such as page-size limits, and avoid reaching for globals.
This layout cuts coordination cost. Each feature directory has one owner and one entry point. Most changes stay inside one scope, so teams rarely touch each other's code. Removing a feature means deleting one register() call and its folder. Moving a feature to another service means lifting that same unit out with its hooks attached.
Debugging and testing the plugin tree
When a decorator seems to vanish or a route returns 404, inspect the tree before you read code. After await fastify.ready(), fastify.printPlugins() returns a text tree of every registered plugin, nested by encapsulation context. If a plugin you expected at the root appears one level down, it was registered inside a child context and is invisible to its siblings. fastify.printRoutes() does the same for routes, showing the final paths after all prefixes are applied. This catches a doubled or missing prefix right away.
For a single decorator, call hasDecorator('db') on the instance where the code runs, not on the root. The result depends on scope: it reports what that context can see, which is the question you are trying to answer. Use hasRequestDecorator() and hasReplyDecorator() for decorators on the request and reply objects.
Testing with a factory and inject()
Export a function that creates a Fastify instance and registers plugins based on its arguments, instead of exporting one shared, fully wired server. A test for the billing routes can then register only the billing plugin and a stub for the database decorator it depends on. inject() sends a simulated request through the full lifecycle (hooks, validation, serialization) without opening a socket. You avoid port conflicts in parallel test runs, and the app is booted for you.
Registering only the plugins a test needs keeps setup short because unrelated plugins do not connect to anything. It also narrows failures: a broken test points to one plugin and its declared dependencies, not the whole application.
Who should use it?
A deliberately designed plugin tree is worth the effort for these groups:
- Teams sharing one Fastify service. When two or more teams ship routes into the same process, encapsulation lets each team own its hooks, error handlers, schemas and decorators without negotiating global changes.
- APIs with distinct domains or versions. Billing, admin, and public routes often need different auth, rate limiting or validation. A context per domain, and a prefix per version, keeps those rules attached to the routes they govern.
- Services that may be split later. If a domain might move into its own service, keeping it as a self-contained feature plugin with declared dependencies makes the move a matter of relocating one unit.
- Authors of reusable plugins. Anyone publishing a plugin for other Fastify apps needs to decide which parts are lifted with
fastify-pluginand which stay encapsulated, and should declare names and dependencies explicitly.
It is a weaker fit for a small service with one owner and a handful of routes, for prototypes, and for teams that are new to Fastify and have not yet hit the problems encapsulation solves. A flat app is easier to read in those cases, and you can introduce feature plugins later without changing route code much.
How can it save engineering time?
The time savings come from limiting how far a change, a failure, or a test reaches:
- Smaller review scope. A hook added inside the billing context can only affect billing routes. Reviewers check one subtree instead of asking whether every route in the service is affected.
- Fewer cross-team regressions. Because hooks and decorators do not leak sideways, one team's new auth check or error handler cannot change another team's responses. That removes a class of incidents and the coordination meetings that follow them.
- Faster, narrower tests. With a factory and
inject(), a test boots only the plugin under test and stubs for its dependencies. No real sockets are opened, unrelated plugins do not connect to databases, and a failure points to one plugin. - Faster startup debugging. Declared
dependencies,decorators, anddecorate()dependency lists turn missing-provider bugs into explicit boot errors fromready().printPlugins()andprintRoutes()show the actual tree and final paths, so you inspect structure rather than reading code to find where a decorator went. - Cheaper removal and extraction. Deleting a feature is one
register()call and one folder. Versioning is a new parent with a new prefix. Extracting a service starts from a unit whose dependencies are already listed.
To decide whether this is worth it for your team, compare the time currently spent on cross-domain breakage, broad reviews and slow integration tests against the cost of the extra structure described in the next section.
When to use it, when to avoid it, alternatives and cost
A designed plugin tree pays off when several teams ship into one service, or when one API carries distinct domains or versions. Each subtree owns its hooks, decorators and prefix, so a team can change its own auth hook or schema without reviewing every other route. For a small service with a handful of routes and one owner, a flat app with a single root context is simpler. Nested plugins there add indirection without isolating anything that needs isolating.
Encapsulation is a design aid, not a security boundary. All plugins share one process, one event loop and one heap, so a buggy or hostile plugin can still read memory, patch globals or block the loop. Use separate processes or services for real isolation.
| Approach | Structure | Strengths | Costs |
|---|---|---|---|
| Flat | Everything in the root context | Easiest to read and onboard | Every hook applies to every route; harder to split later |
| Fully lifted | Every plugin wrapped with fastify-plugin |
No surprises about visibility | Global side effects; no per-domain hooks; tests load everything |
| Layered tree | Shared infrastructure lifted, domains encapsulated | Per-domain isolation; small test setups | More files, boilerplate and up-front design |
Weigh the tree's ongoing costs against the time it saves. Those costs are extra indirection, a learning curve for new engineers, and wrapper boilerplate. The savings come from contained changes and narrow, fast tests. If no one is losing time to cross-domain breakage, the tree probably isn't worth it yet.
Plugin and boot behavior changed between major versions such as v4 and v5. Check the reference documentation for the major version you have installed.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to fastify plugins, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.