Centralizing Validation and Transformation with FeathersJS Service Hooks
FeathersJS hooks unify validation, transformation, and logging across services. Learn how to centralize cross‑service logic, avoid pitfalls, and keep your API contract clean with a practical example.
22 Sept 2026, 00:16 UTC

The Problem with Inconsistent Payloads
In a multi‑service Feathers application, clients often send payloads that miss required fields, contain extra keys, or violate business rules. These inconsistencies can leave the database with dirty records and make the API contract fragile.
Hook Basics
Feathers hooks run in three phases: before, around, and after. Each phase receives a hookContext object that contains the request data, params, and the eventual result.
- before hooks execute before the service method (e.g.,
create) and can modifydataor abort the request. - around hooks wrap the method call; they must call
next()to continue the pipeline. - after hooks run after the method and can alter the
resultbefore it reaches the client.
Hooks can be registered per service or globally. Global hooks affect every method of every service unless scoped with only or except options.
Validation Hook Example
Using @feathersjs/schema you can declaratively validate incoming data. The following hook rejects a payload that does not match the User schema and returns a HTTP 400 error.
const { createSchema, validate } = require('@feathersjs/schema');
const { BadRequest } = require('@feathersjs/errors');
const userSchema = createSchema({
type: 'object',
required: ['email', 'password'],
properties: {
email: { type: 'string', format: 'email' },
password: { type: 'string', minLength: 8 },
role: { type: 'string', enum: ['user', 'admin'] }
},
additionalProperties: false
});
const validateUser = async context => {
const error = validate(userSchema, context.data);
if (error) {
throw new BadRequest('Invalid user payload', { details: error });
}
return context;
};
app.service('users').hooks({
before: {
create: [validateUser]
}
});
When validateUser throws, Feathers automatically translates the error into a 400 response with the details field included in the body.
Transformation Hook Example
An after hook can strip sensitive fields and add computed properties before the response leaves the server.
const sanitizeUser = async context => {
const { result } = context;
const removePassword = user => delete user.password;
if (Array.isArray(result)) {
result.forEach(removePassword);
} else {
removePassword(result);
}
// Add a profile URL for convenience
if (!Array.isArray(result)) {
result.profileUrl = `https://api.example.com/users/${result.id}`;
}
return context;
};
app.service('users').hooks({
after: {
create: [sanitizeUser],
get: [sanitizeUser]
}
});
Worked Example: The Full Users Service
Below is a compact illustration of a users service that ties together the three hook types. The file is intentionally minimal; replace placeholders with your actual database adapter and logging setup.
// users.service.js
const { Service } = require('@feathersjs/feathers');
const { BadRequest } = require('@feathersjs/errors');
const { createSchema, validate } = require('@feathersjs/schema');
const userSchema = createSchema({
type: 'object',
required: ['email', 'password'],
properties: {
email: { type: 'string', format: 'email' },
password: { type: 'string', minLength: 8 },
role: { type: 'string', enum: ['user', 'admin'] }
},
additionalProperties: false
});
const validateUser = async context => {
const error = validate(userSchema, context.data);
if (error) throw new BadRequest('Invalid payload', { details: error });
return context;
};
const logDuration = async (context, next) => {
const start = Date.now();
const result = await next();
const duration = Date.now() - start;
context.app.log.info(`users.${context.method} took ${duration}ms`);
return result;
};
const sanitizeUser = async context => {
const { result } = context;
const strip = u => delete u.password;
if (Array.isArray(result)) result.forEach(strip);
else strip(result);
if (!Array.isArray(result)) result.profileUrl = `https://api.example.com/users/${result.id}`;
return context;
};
module.exports = function (app) {
const options = { paginate: { default: 10, max: 25 } };
const service = new Service({ /* adapter options */ });
app.use('/users', service);
app.service('users').hooks({
before: { create: [validateUser] },
around: { create: [logDuration], get: [logDuration] },
after: { create: [sanitizeUser], get: [sanitizeUser] }
});
};
Trade‑Off: Centralized Logic vs. Hidden Dependencies
Centralizing validation and transformation in hooks keeps service files clean, but it also introduces hidden dependencies:
- Hooks run for every method unless explicitly scoped. A global validation hook can inadvertently block unrelated services.
- Each hook adds latency. A chain of expensive hooks can noticeably slow down requests; profiling after adding hooks is essential.
- Ordering matters. An
aroundhook that forgets to callnext()will stall the entire pipeline.
Mitigation strategies:
- Scope hooks with
onlyorexceptwhen registering globally. - Write unit tests that send malformed payloads to verify that the validation hook returns a 400.
- Use an
aroundhook to log duration and confirm that the logged time matches real latency. - Run a lightweight load test (e.g., Artillery) before and after adding hooks to quantify performance impact.
Actionable Closing
Start by creating a global validation hook for shared schemas. Keep business‑specific rules in service‑level hooks to avoid cross‑service contamination. Write unit tests that assert error handling and hook ordering, and profile critical services after each change. With this disciplined approach, Feathers hooks become a powerful tool for enforcing consistency, enhancing security, and keeping your API contract tight.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.