Articles
Fastify Interview Questions: Plugins, Encapsulation, and Request Hooks
Explain Fastify plugin encapsulation, decorators, route hooks, and request lifecycle timing through a concrete authorization bug and practical tests.
A Fastify interview question often hides its answer in where code was registered, not in the handler itself. An authentication hook may protect /admin but leave /reports open; a decorator may work in a child plugin yet be undefined in a sibling. To diagnose either bug, draw Fastify's plugin tree and request lifecycle before changing code. register() creates a scope by default, while hooks run at specific phases inside the scopes where they apply.
Suppose an API has a root logger, an authenticated /admin plugin, and a public /status plugin. You add a currentUser decorator and a preHandler authorization hook inside /admin. Later, a /reports route is registered as a sibling and unexpectedly cannot access currentUser. The fix depends on the intended boundary: should reports also be protected, or should only a shared identity decorator move to an ancestor? Practice the authorization reasoning with Design a To-Do List Service (CRUD, Auth, Rate Limiting, Caching & API Versioning).
Evidence boundary: Official facts about Fastify plugin scopes, lifecycle hooks, and testing come from the linked current Fastify documentation, checked September 2026. Candidate reports do not establish any employer's exact Fastify exercise here. Inference covers the route scenario, suggested design, and interview test cases; adapt those to your own security policy.
What does registering a Fastify plugin change?
Fastify's plugin documentation says register() creates a new encapsulated scope by default. A child inherits decorators, hooks, and plugins from its ancestors, but changes it makes are not automatically visible to its parent or siblings. Think of the registration tree as a directed graph of visibility, not as one mutable global server object. Registration order also matters when a later plugin expects a decorator from an earlier registration in a shared scope.
Here is a small conceptual tree:
root: logger, database
├─ admin plugin: currentUser, authorization hook
│ └─ admin routes: /admin/users, /admin/audit
├─ reports plugin: /reports
└─ status plugin: /status
The /admin routes can use root database and child currentUser. The /reports route inherits root database, but not its sibling's currentUser or authorization hook. If reports should require authentication, register the relevant authentication plugin inside a shared ancestor, or register it explicitly in the reports branch. If reports should remain public, moving the authorization hook to root would create a new bug. An interview answer should state the desired route policy before choosing a registration point.
| Registration location | Who receives it? | Typical use |
|---|---|---|
| Root decorator | Root and descendants | Shared database or configuration access |
| Child decorator | That child and descendants | Feature-specific service or request helper |
| Child hook | Routes in that child scope and descendants | Feature-specific authorization or tracing |
| Sibling plugin | Its own descendants | Independent feature boundary |
Fastify's encapsulation reference makes this hierarchy explicit. The practical advantage is avoiding accidental cross-feature coupling. The interview trap is treating registration as if it mutates a single global instance for all routes. Test visibility with one route in each branch; inspecting only the route where the decorator works will not reveal leakage or absence elsewhere.
When should you use fastify-plugin?
The fastify-plugin package intentionally changes the normal encapsulation behavior for a plugin. Fastify's own plugin guide recommends it when a plugin is meant to extend the parent scope, such as a shared decorator. That does not mean every plugin should be wrapped. A feature route bundle with a private authorization hook benefits from a boundary; wrapping it without understanding the scope may broaden access or make the hook affect routes you did not intend.
Consider a database connector plugin that calls decorate('db', connection). If it is registered as a normal child, a sibling feature may not see db. Wrapping a true shared connector with fastify-plugin can expose the decorator to the containing scope. But for an /admin route plugin, preserving encapsulation lets its auth hook stay local. State the contract in the plugin name and test it. A decorator visible at root is a deliberate architecture choice, not a convenience fix for an undefined-property error.
There is a subtle route-prefix issue: Fastify's plugin documentation notes that options such as prefix behave differently when the route plugin itself is wrapped with fastify-plugin; the typical prefix option does not work on that wrapped route plugin as it does with a normal encapsulated registration. If a candidate says “wrap everything to share dependencies,” ask what happens to the routes' intended prefix and hook boundary. A good answer keeps shared infrastructure and feature routes in separate plugins.
Which request hook belongs at which phase?
Fastify documents a request lifecycle with hooks including onRequest, preParsing, preValidation, preHandler, preSerialization, onSend, and onResponse. The choice depends on what information the hook needs and whether it must change the result. In onRequest, request.body is not yet parsed. Body-aware validation or authorization must wait until an appropriate later phase. onResponse runs after the response has been sent, so it can observe or log but cannot change the client response.
| Need | Likely phase | Why |
|---|---|---|
| Reject an unauthenticated request based on headers | onRequest or preHandler | Header identity can be checked before handler work |
| Inspect parsed body before schema validation | preValidation | Parsing has occurred; validation has not |
| Apply route business authorization after validation | preHandler | Handler has not run; validated request context is available |
| Transform a serializable response object | preSerialization | Runs before serialization for supported payloads |
| Add or change a supported outgoing payload | onSend | Runs before bytes are sent |
| Record completed response metrics | onResponse | Response is already sent |
These are placement guides, not a rule that every auth check belongs in one hook. A header-only token check may be early; an authorization decision requiring a parsed resource ID or body may be later. Fastify's hook docs also warn against mixing an async hook with a done callback. Doing both can cause unexpected duplicate progression. In an interview, show one consistent style and explain error propagation.
How do scoped and route-level hooks compose?
Hooks are subject to plugin encapsulation. A root hook applies broadly; a hook registered in the /admin plugin applies within that branch. A route can also declare its own hook. Fastify documents that route-level hooks run last within their hook category. Thus, for preHandler, an applicable ancestor hook runs before the route-specific preHandler. This is useful when a shared authentication hook establishes identity and a route-level hook checks a specific permission.
Imagine root has a correlation-ID onRequest hook, /admin has a token-auth preHandler, and /admin/delete has a route-level preHandler that checks administrator permission. A request to /admin/delete needs all three. /status should receive the correlation ID but not the admin token check. Draw that matrix rather than saying “middleware runs globally.” Fastify's scoped hooks are not a conventional flat Express middleware list.
Be careful with error or early-response behavior. If an auth hook sends a 401 response, later handler work should not proceed as though the request were authorized. Test the actual response and a side-effect spy. A test that merely asserts the hook function was called does not establish that the protected operation was blocked. Likewise, a post-response metrics hook may log failure without having an opportunity to rewrite the status; place response decisions before the send boundary.
Debug the missing-decorator case step by step
Suppose /admin/audit returns 200 and can access request.currentUser, but /reports/export throws because that property is undefined. First identify where decorateRequest('currentUser', ...) was declared, where the auth hook populates it, and the exact register() calls for both routes. Do not start by changing TypeScript declarations; a type can claim the property exists globally while runtime encapsulation says otherwise.
Next determine product policy. If reports are private, a shared auth plugin may belong at an ancestor of both /admin and /reports. If admin and reports use different policies, each branch may need its own hook while a small shared identity parser can live above. If /reports/export is public, it should not rely on a required currentUser at all. The architecture follows the policy, not vice versa.
Finally test negative space: /admin/audit without a token returns 401; /reports/export with a permitted user returns the expected data; /status remains public; a user lacking export permission receives 403 and the export side effect does not run. These assertions distinguish authentication from authorization and show that encapsulation is protecting the intended routes.
How would you test boot order and plugins?
Fastify's testing guide recommends fastify.inject() for HTTP-level tests without opening a network port. An injected request boots registered plugins and exercises the route's actual lifecycle. Build the server in a function, register plugins, then use inject() to assert status, body, and side effects. Close the instance when the test is done. This catches a plugin that works when imported alone but is registered in the wrong branch of the application.
Use a small boot-order test for a shared decorator. Register the database connector before a feature that depends on it, then call ready() or inject(). If the feature cannot see the decorator, inspect its scope and whether the connector was intentionally wrapped. Fastify's registration docs explain that after, ready, and listen expose plugin-loading errors at different points. Do not swallow a boot failure and blame an unrelated request handler.
An interview extension is to ask whether the plugin is safe to load twice. Two feature branches may each register a local helper intentionally, while a shared singleton connection might need one owner and explicit shutdown. A thoughtful answer names who creates the resource, which scope consumes it, and where cleanup runs. The exact lifecycle code varies, but ownership should be observable in tests.
What answers sound plausible but miss the issue?
“Add the decorator to the TypeScript interface” helps type checking but cannot change runtime visibility. “Move every hook to root” fixes one missing hook by weakening the scope boundary. “Use onRequest for everything” fails when a decision needs a parsed body. “Wrap all plugins with fastify-plugin” discards useful encapsulation and may disrupt route-prefix assumptions. “The route works in one test” says nothing about its sibling or an unauthorized request.
The stronger pattern is to explain two axes explicitly: scope answers which routes inherit the feature, and phase answers which request data and reply operations are available. A bug can involve either or both. In a code review, annotate the plugin tree and lifecycle once; the fix usually becomes much smaller.