FeathersJS Hooks: Composable Cross-Cutting Logic Without Transport Coupling
FeathersJS hooks provide transport-agnostic, composable middleware for service methods. This guide shows a worked validation-hashing-sanitization chain, explains execution order, and covers common mistakes like missing returns and unhandled promises.
22 May 2026, 10:04 UTC

The Problem: Business Logic Leaking Into Transport Layers
When building APIs with FeathersJS, developers often duplicate validation, authentication checks, logging, or data transformation across REST endpoints, WebSocket handlers, and internal service calls. Express middleware solves this for HTTP, but Feathers services are transport-agnostic—they serve REST, Socket.io, Primus, and direct internal calls from the same method. Hooks are Feathers' answer: reusable functions that wrap service methods (find, get, create, update, patch, remove) regardless of how the client connects.
How Hooks Work: The Context Object and Execution Order
A hook is a function that receives a context object containing:
context.params– request parameters, query, headers, and authenticated usercontext.data– input data forcreate,update,patchcontext.result– return value from the service method (available inafterhooks)context.service– reference to the service instancecontext.method– the service method namecontext.type–'before','after', or'error'
Hooks are registered per service in service.hooks.js (or via app.service('name').hooks()). They execute in registration order: before hooks run sequentially, the service method runs, then after hooks run in reverse registration order. error hooks only fire when a previous hook or the service method throws.
Worked Example: Validation, Hashing, and Sanitization on a Users Service
Below is a complete hook chain for a users service that validates input, hashes passwords before write, and strips sensitive fields from responses. This runs identically for POST /users (REST), a Socket.io create event, and an internal app.service('users').create() call.
// src/services/users/users.hooks.js
const { validateSchema } = require('@feathersjs/schema');
const { hashPassword, protect } = require('@feathersjs/authentication-local');
const { resolve } = require('@feathersjs/schema');
// 1. Schema for create/update validation
const userSchema = {
type: 'object',
required: ['email', 'password'],
properties: {
email: { type: 'string', format: 'email' },
password: { type: 'string', minLength: 8 }
},
additionalProperties: false
};
// 2. Hook that validates against the schema
const validateUser = validateSchema(userSchema, { validator: 'ajv' });
// 3. Hook that hashes password before write (runs after validation)
const hashPasswordHook = async (context) => {
if (context.data.password) {
context.data.password = await hashPassword(context.data.password);
}
return context;
};
// 4. After hook: remove password from result before sending to client
const sanitizeUser = protect('password');
module.exports = {
before: {
all: [],
find: [],
get: [],
create: [validateUser, hashPasswordHook],
update: [validateUser, hashPasswordHook],
patch: [validateUser, hashPasswordHook],
remove: []
},
after: {
all: [],
find: [sanitizeUser],
get: [sanitizeUser],
create: [sanitizeUser],
update: [sanitizeUser],
patch: [sanitizeUser],
remove: []
},
error: {
all: [],
find: [],
get: [],
create: [],
update: [],
patch: [],
remove: []
}
};
Registration in the service file (generated by feathers generate service):
// src/services/users/users.js
const { UsersService } = require('./users.class');
const hooks = require('./users.hooks');
module.exports = (app) => {
app.use('/users', new UsersService({ /* options */ }), {
methods: ['find', 'get', 'create', 'update', 'patch', 'remove']
});
app.service('users').hooks(hooks);
};
Verification: Confirm the Hook Chain Executes
Run these steps in a fresh Feathers v5 project (Node 18+, npm 9+):
npm create feathers@latest my-app→ select "JavaScript", "REST", "Socket.io"cd my-app && npm run generate:service→ name:users, database: "None" (in-memory)- Replace
src/services/users/users.hooks.jswith the example above npm start(runs onhttp://localhost:3030)- In another terminal, test the create endpoint:
curl -X POST http://localhost:3030/users \ -H "Content-Type: application/json" \ -d '{"email":"[contact removed]","password":"secret123"}' - Expected response:
201 Createdwith user object without apasswordfield. The password is hashed in the database (checkconsole.logoutput from the service). - Test validation failure:
Returnscurl -X POST http://localhost:3030/users \ -H "Content-Type: application/json" \ -d '{"email":"not-an-email","password":"short"}'400 Bad Requestwith AJV validation errors.
Common Mistakes and Limits
1. Forgetting to Return Context
A before hook must return the context object (or a modified clone). Omitting the return silently breaks the chain—the service method never receives the modified data, and subsequent hooks don't run.
// ❌ Broken: missing return
const badHook = (context) => {
context.data.timestamp = Date.now();
};
// ✅ Correct
const goodHook = (context) => {
context.data.timestamp = Date.now();
return context;
};
2. Unhandled Promise Rejections in Async Hooks
Async hooks must be declared async and await any promises. An unhandled rejection crashes the Node process unless caught by an error hook or a global process.on('unhandledRejection') handler.
// ❌ Risky: unhandled rejection if hashPassword throws
const riskyHook = (context) => {
hashPassword(context.data.password).then(hash => {
context.data.password = hash;
});
return context; // returns before promise resolves
};
// ✅ Safe
const safeHook = async (context) => {
context.data.password = await hashPassword(context.data.password);
return context;
};
3. Overusing Hooks for Domain Logic
Hooks excel at cross-cutting concerns (validation, auth, logging, serialization). Business logic that depends on multiple services, complex state transitions, or domain rules belongs in the service method itself or a dedicated module. Overloaded hooks become hidden control flow that is hard to test and debug.
4. Hook Scope: Service-Level Only
Hooks registered on a service do not run for other services. If you need shared logic across services (e.g., audit logging on every write), create a reusable hook function and register it on each service, or use an app.hooks() global hook (runs for every service method call).
When to Use Global vs. Service Hooks
| Scope | Use Case | Registration |
|---|---|---|
| Service hooks | Validation, field transformation, per-service auth checks | app.service('users').hooks({ before: { create: [validate] } }) |
| Global hooks | Request logging, correlation IDs, global error formatting | app.hooks({ before: { all: [addCorrelationId] } }) |
Verification Checklist for Production
- Run the official Feathers hooks test suite: clone
github.com/feathersjs/feathers,npm ci && npm test -- --grep hooks - Add an
errorhook that logscontext.errorand returns a sanitized error response - Load-test with
autocannonork6to verify hook overhead is negligible (typically <1ms per hook) - Ensure
NODE_ENV=productiondisables verbose hook logging
Summary
FeathersJS hooks give you transport-agnostic, composable middleware for service methods. Register them per service in before/after/error phases, return the context object, and keep domain logic out of hooks. The example above shows a production-ready pattern: validate → transform → sanitize, all running identically across REST, WebSockets, and internal calls.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.