Fastify Plugin Encapsulation: What It Actually Isolates (and What It Doesn't)
Fastify's plugin encapsulation scopes decorators and hooks — but never routes. Learn what it actually isolates, how to share deliberately with fastify-plugin, and how to verify it.
01 Jul 2026, 17:52 UTC

The problem: composing plugins without stepping on each other
When you split a Fastify service into plugins — one for authentication, one for logging, one for database access — each plugin typically wants to add helpers via fastify.decorate() or lifecycle logic via fastify.addHook(). If two plugins pick the same decorator name, the naive expectation is that the second registration silently overwrites the first, producing bugs that only show up at runtime. Fastify's answer to this is encapsulation, but it's widely misunderstood: developers often assume it isolates everything a plugin does, including routes. It doesn't.
Thesis: encapsulation scopes decorators and hooks — never routes
By default, fastify.register(plugin) creates a new encapsulated context for that plugin. Decorators and hooks added inside that context belong to it and its children; the parent instance cannot see them. Routes are different: every route, no matter how deeply nested in the plugin tree, ends up in the application's single global router. Registering two plugins that both define GET /hello will fail at ready time with a duplicate-route error (FST_ERR_DUPLICATED_ROUTE), regardless of any encapsulation settings. There is no encapsulation option that makes duplicate routes coexist.
How the scoping actually works
When you call fastify.register(plugin, options):
- By default the plugin function receives a child context. Anything it decorates or hooks lives in that context.
- Plugins registered inside that plugin inherit the encapsulated context, so scoping composes down the tree.
- To deliberately share a plugin's additions with the parent, you either wrap the plugin with
fastify-plugin(which skips encapsulation for that plugin) or, in Fastify v4, register it as a non-encapsulated plugin. Either way, its decorators and hooks land on the parent context — and can now collide with names already there. - Hooks follow the same rule: a hook added inside an encapsulated plugin only fires for routes in that plugin's subtree. There is no
scope: 'global'option onaddHook; scoping comes purely from where the hook is registered.
One more caveat: encapsulation only governs Fastify's own context. It does nothing for side effects in shared resources — a database pool, a Redis client, a module-level variable. Two encapsulated plugins can still interfere through those, and you must manage that separately.
Worked example: decorators isolated, duplicate routes rejected
Save this as server.js and run it with node server.js (requires Fastify v4, installed via npm install fastify). It demonstrates both behaviors. Note that the duplicate-route case is expected to fail, so it's wrapped in its own instance.
const Fastify = require('fastify')
// --- Part 1: decorator isolation ---
const app = Fastify()
function pluginA(instance, opts, done) {
instance.decorate('util', () => 'from A')
instance.get('/a/hello', () => ({ msg: instance.util() }))
done()
}
function pluginB(instance, opts, done) {
instance.decorate('util', () => 'from B')
instance.get('/b/hello', () => ({ msg: instance.util() }))
done()
}
app.register(pluginA)
app.register(pluginB)
app.ready(err => {
if (err) throw err
// The parent cannot see either decorator: both are scoped
console.log('parent has util?', app.hasDecorator('util')) // false
})
// --- Part 2: duplicate routes always fail, encapsulation or not ---
const app2 = Fastify()
function routeX(instance, opts, done) {
instance.get('/hello', () => ({ msg: 'x' }))
done()
}
function routeY(instance, opts, done) {
instance.get('/hello', () => ({ msg: 'y' }))
done()
}
app2.register(routeX)
app2.register(routeY)
app2.ready(err => {
// err is set: duplicate route GET /hello
console.log('duplicate route error?', err ? err.code : 'none')
})
Run it from the project directory with Node.js. Expected behavior: the first instance reports false for the parent decorator check, and each plugin's route still sees its own util; the second instance fails readiness with FST_ERR_DUPLICATED_ROUTE. Use app.hasDecorator(name) rather than hasOwnProperty — it's the supported API for checking whether a decorator is visible on a given context.
Sharing deliberately: fastify-plugin and its risks
If a plugin exists to provide something global — a database client, a config object, a logger — wrap it with fastify-plugin:
const fp = require('fastify-plugin')
module.exports = fp(async function dbPlugin(instance, opts) {
instance.decorate('db', await createClient(opts.url))
})
Now app.hasDecorator('db') is true after registration, and every other plugin can use fastify.db. The trade-off is real: the plugin's decorators and hooks become visible to the parent context, so a name clash will throw at boot (Fastify refuses to re-decorate an existing name). Treat anything shared this way as public API: document the names it adds, keep the surface small, and register it once, early.
How to verify this in your own codebase
- After
fastify.ready(), callfastify.hasDecorator('name')on the root instance for each decorator a plugin defines. With default encapsulation it should returnfalse; inside the plugin's own context it returnstrue. - Run
fastify.printRoutes()to confirm your route table. If two plugins need similar endpoints, give each a distinct prefix via theprefixregister option (e.g.app.register(auth, { prefix: '/auth' })) — this is the correct tool for route namespacing, not encapsulation. - Add a boot-time test that registers your full plugin tree and asserts
ready()resolves without error. Duplicate-route and duplicate-decorator mistakes surface there, before deployment.
Actionable closing
Audit your plugins with two questions: does this plugin consume shared context, or provide it? Providers should be wrapped in fastify-plugin, registered once, and documented. Consumers should keep default encapsulation so their internal decorators and hooks can't collide with anyone else's. And handle routes separately: encapsulation will never save you from a duplicate path — prefixes and versioning will.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.