The Plugin System
Here's the one sentence that makes Fastify click: your whole app is a tree of plugins. Not "an app that can use plugins" — the app itself, every route, every shared bit of logic, is a plugin or lives inside one. Once you hold that picture, the things that confuse newcomers (why a decorator "disappears," why two halves of the app can't see each other) stop being mysteries and become the system working exactly as designed.
💡 The mental model for this whole phase: a tree of little worlds. Each plugin is a node. Each node gets its own scope — its own routes, hooks, and decorators — and by default that scope is sealed off from its siblings and its parent. You opt out of the sealing on purpose, not into it.
In the last two phases you wrote routes directly on app. That works for a toy. The moment you have a books section, a users section, and a shared database, you want them in separate files that don't trip over each other. That's what plugins give you.
What a plugin actually is
A plugin is just a function. Two shapes are allowed:
// async style (preferred — Fastify awaits it)
// callback style (call done() when finished)
What just happened: Fastify handed your function a fastify instance and an opts object (whatever you passed at registration time). That fastify argument is not the same object as the top-level app — it's a child instance scoped to this plugin. Hold that thought; it's the whole reason encapsulation works.
Writing a real plugin: book routes
Let's pull our books routes into their own plugin. Put this in book-routes.js:
module.exports = bookRoutes;
What just happened: nothing here registers on the global app — every fastify.get/fastify.post attaches to the child instance for this plugin. The routes don't exist anywhere until someone registers bookRoutes. Notice the paths are relative (/, /:id) — we'll give them a home in the next step.
Mounting it with register and a prefix
Back in your main file, you bring the plugin into the tree with app.register:
const Fastify = ;
const bookRoutes = ;
const app = ;
app.;
app.;
What just happened: register created a child instance, ran bookRoutes against it, and the prefix: '/api/books' option scoped every route inside the plugin to that path. So the plugin's '/' becomes GET /api/books, and '/:id' becomes GET /api/books/:id. The plugin doesn't know or care what prefix it lives under — you decide that at the mount point. That's reusability: register the same plugin twice under two prefixes and you get two mounted copies.
📝
registeris asynchronous and deferred. Fastify doesn't run your plugin the instant you callregister— it queues it and boots the whole tree when you calllisten(orready). If you ever need something to be fully wired up before continuing,await app.ready().
Encapsulation: the sealed-off scope
Here's the part that surprises people, so let's hit it head-on. Anything you register inside a plugin — routes, hooks, decorators, even sub-plugins — is visible to that plugin and its children, but invisible to its siblings and its parent.
Picture two plugins registered on the same app:
app.;
app.;
What just happened: the requireAdmin hook is registered inside adminArea, so it guards /admin/stats — and only that subtree. It does not touch /health, because publicArea is a sibling, not a child. The auth check can't leak across. This is the feature that lets a 200-route app stay sane: each section's middleware, error handling, and decorations stay contained in its own little world. No "global middleware accidentally ran on the wrong route" bugs.
💡 Why this matters: in frameworks without encapsulation, registering middleware is a global act and order-of-registration becomes a minefield. In Fastify, scope is structural — where a thing lives in the tree is its reach.
decorate: sharing things on the instance
You'll want shared resources — a database connection, a config object, a helper — available to handlers without importing them everywhere. That's decorate:
const db = await ;
app.;
app.;
What just happened: decorate('db', db) hung a db property on the Fastify instance, so any handler in scope can reach it as fastify.db (and inside a handler, via req.server.db or by closing over app). There are two siblings for the request and reply objects:
app.; // adds req.user, default null
app.;
What just happened: decorateRequest and decorateReply add properties/methods to every request and reply. Declaring req.user up front (with a default) is the right pattern — a hook later fills it in. And here's the catch that sets up the next section: decorations follow the exact same encapsulation rules as everything else. Decorate inside a plugin, and only that plugin's subtree sees it.
The fastify.db is undefined trap — and fp
This is the single most common Fastify "why?!" moment, so let's make sure you never lose an hour to it.
You write a tidy database plugin:
// db.js
module.exports = dbPlugin;
Then you register it and try to use it from a sibling:
app.;
app.;
// inside bookRoutes: fastify.db → undefined 💥
What just happened: dbPlugin decorated its own child instance with db. By the encapsulation rules, that decoration is sealed inside dbPlugin's scope. bookRoutes is a sibling, not a child — so it never sees db. Everything is behaving correctly; the connection is just trapped one level too deep.
⚠️ The fix is to deliberately break encapsulation for this one plugin using fastify-plugin (conventionally imported as fp). Wrapping a plugin in fp tells Fastify "don't create a sealed child scope for this — apply its decorations and hooks to the parent instead":
// db.js
const fp = ;
module.exports = ; // ← decorations now reach the whole app
What just happened: fp removed the wall around dbPlugin. Now decorate('db', conn) lands on the parent app, so bookRoutes and every other sibling can reach fastify.db. This is the canonical use of fastify-plugin: shared infrastructure (database, auth, config) that the whole app legitimately needs.
💡 The decision rule: leave a plugin encapsulated when it's a self-contained feature (a routes section, an isolated subsystem). Reach for
fpwhen the plugin exists to share something app-wide. Encapsulation is the default;fpis the explicit, intentional exception. If you wrap everything infp, you've thrown away the one feature that keeps large Fastify apps from collapsing into spaghetti.
Here's the tree we just built, with the books API wired up properly:
flowchart TD
app["app (root)"]
db["dbPlugin — wrapped in fp\ndecorates the WHOLE tree with .db"]
books["bookRoutes (encapsulated)\nprefix /api/books"]
admin["adminArea (encapsulated)\nonRequest: requireAdmin"]
app --> db
app --> books
app --> admin
db -.->|.db now visible everywhere| books
db -.->|.db now visible everywhere| admin
Recap
- Everything is a plugin. Your app is a tree of plugins;
app.register(plugin, opts)adds a node, and a plugin is justasync (fastify, opts) => {}(or a callback form ending indone()). - A
prefixoption onregisterscopes a plugin's routes under a path — the plugin stays unaware of where it's mounted, so it's reusable. - Encapsulation is the core idea: routes, hooks, and decorators registered inside a plugin reach that plugin and its children only — never siblings or the parent. That's how big apps stay isolated.
decorateadds shared things to the instance (fastify.db);decorateRequest/decorateReplyadd to every request/reply. All of them obey encapsulation.- ⚠️ A decorator trapped in a plugin's scope is the classic
fastify.db is undefinedbug. Wrap the plugin infastify-plugin(fp) to deliberately share across the whole app — use it for app-wide infrastructure, not for ordinary feature plugins.
Quick check
[
{
"q": "You register dbPlugin (it calls fastify.decorate('db', conn)) and, as a sibling, bookRoutes. Inside bookRoutes, fastify.db is undefined. Why?",
"choices": [
"decorate only works on request objects, not the instance",
"The decoration is sealed inside dbPlugin's encapsulated scope, and bookRoutes is a sibling, not a child",
"You must call decorate after listen()",
"prefix erases decorations on registered plugins"
],
"answer": 1,
"explain": "Decorations follow encapsulation. dbPlugin decorated its own child instance, so a sibling plugin never sees it. Wrapping dbPlugin in fastify-plugin (fp) applies the decoration to the parent so the whole app can use it."
},
{
"q": "What does the prefix option do in app.register(bookRoutes, { prefix: '/api/books' })?",
"choices": [
"Renames the plugin function",
"Mounts every route inside bookRoutes under /api/books",
"Makes the plugin's decorators global",
"Delays the plugin until app.ready()"
],
"answer": 1,
"explain": "prefix scopes the plugin's routes to a path, so the plugin's '/' becomes GET /api/books. The plugin itself stays unaware of its mount point, which is what makes it reusable."
},
{
"q": "When should you wrap a plugin in fastify-plugin (fp)?",
"choices": [
"Always — every plugin should use fp",
"Never — fp is deprecated",
"When the plugin provides something app-wide (db, auth, config) that siblings legitimately need to share",
"Only for plugins that define routes"
],
"answer": 2,
"explain": "fp deliberately breaks encapsulation so a plugin's decorators/hooks reach the whole tree. Use it for shared infrastructure. Wrapping everything in fp throws away the isolation that keeps large apps maintainable."
}
]
← Phase 2: Routing & Schemas · Guide overview · Phase 4: Hooks & the Lifecycle →
Check your understanding
1. You register dbPlugin (it calls fastify.decorate('db', conn)) and, as a sibling, bookRoutes. Inside bookRoutes, fastify.db is undefined. Why?
2. What does the prefix option do in app.register(bookRoutes, { prefix: '/api/books' })?
3. When should you wrap a plugin in fastify-plugin (fp)?