Post 4 of 15 | Phase 2: Core Concepts
Services and Actions — The Building Blocks You Write Every Day
In the previous post you learned about the ServiceBroker and its configuration. Now we go deep into Services and Actions. These are the things you will write every single day in a Moleculer project. Understanding them thoroughly will make you productive immediately.
What is a Service — The Full Picture
A service is a plain JavaScript object that you export from a file. The broker reads this object and registers it. That is it. No classes to extend, no framework-specific decorators, no magic.
A service object can have the following top-level properties:
module.exports = {
// Required
name: "user",
// Optional
version: 1,
settings: {},
metadata: {},
dependencies: [],
mixins: [],
// Lifecycle hooks
created() {},
async started() {},
async stopped() {},
// The main work
actions: {},
events: {},
methods: {}
};
We already covered name, created, started, and stopped in Post 3. Let us now go through the rest.
version
module.exports = {
name: "user",
version: 1
};
When you have multiple versions of the same service running simultaneously, version lets you differentiate them. The full service name becomes v1.user. Actions become v1.user.create.
This is useful when you are deploying a new version of a service but cannot take down the old one immediately. Old clients call v1.user, new clients call v2.user, both run at the same time until migration is complete.
For now you will not use this. Just know it exists.
settings
Settings is an object for service-level configuration values. Think of it as constants or default values specific to this service.
module.exports = {
name: "user",
settings: {
defaultPageSize: 10,
maxPageSize: 100,
jwtSecret: process.env.JWT_SECRET
},
actions: {
list(ctx) {
// Access settings via this.settings
const pageSize = ctx.params.pageSize || this.settings.defaultPageSize;
return { pageSize };
}
}
};
You access settings inside actions and methods using this.settings. Settings are also visible to other nodes when using a transporter, which can be useful for service discovery.
dependencies
module.exports = {
name: "order",
dependencies: ["user", "product"],
async started() {
this.logger.info("Order service started. User and Product are ready.");
}
};
Dependencies tell the broker to wait until the listed services are available before starting this service. In the example above, the order service will not start until both user and product services are registered and running.
This is important in production where services start up at different times. Without dependencies, your order service might try to call user.getById before the user service is even ready.
methods
Methods are private functions that belong to the service. They cannot be called from outside the service. They are helper functions used internally by actions and lifecycle hooks.
module.exports = {
name: "user",
actions: {
create(ctx) {
// Call an internal method
const hashedPassword = this.hashPassword(ctx.params.password);
return { user: ctx.params.name, password: hashedPassword };
}
},
methods: {
// This cannot be called from outside
// Only actions, events, and other methods in this service can use it
hashPassword(password) {
// In real code you would use bcrypt here
return Buffer.from(password).toString("base64");
},
validateEmail(email) {
return email.includes("@");
}
}
};
You call methods inside the service using this.methodName(). They are bound to the service instance so they have access to this.settings, this.logger, and everything else.
Think of methods as private class methods in object-oriented programming. They keep your action handlers clean and your logic reusable within the service.
Actions — The Full Syntax
You have seen two syntaxes for actions already. Let us make this completely clear.
Shorthand syntax
Use this when your action is simple and does not need params validation or HTTP exposure.
actions: {
hello(ctx) {
return `Hello ${ctx.params.name}`;
}
}
Full syntax
Use this in real projects. It gives you full control.
actions: {
hello: {
rest: {
method: "GET",
path: "/hello"
},
params: {
name: "string"
},
handler(ctx) {
return `Hello ${ctx.params.name}`;
}
}
}
In real projects always use the full syntax. The shorthand is fine for quick tests and learning but not for production code.
Action Parameters and Validation
The params property defines what data an action expects. Moleculer uses the fastest-validator library under the hood. When validator: true is set in moleculer.config.js, every action call is automatically validated against this schema before the handler runs.
Here are the most common validation rules:
actions: {
createUser: {
params: {
// Required string
name: "string",
// Required number
age: "number",
// Required email
email: "email",
// Optional string with default value
role: { type: "string", default: "user" },
// String with min and max length
username: { type: "string", min: 3, max: 20 },
// Number with min and max value
score: { type: "number", min: 0, max: 100 },
// Boolean
isActive: "boolean",
// Optional field
bio: { type: "string", optional: true },
// Array of strings
tags: { type: "array", items: "string" },
// Enum — only these values allowed
status: { type: "enum", values: ["active", "inactive", "pending"] }
},
handler(ctx) {
return ctx.params;
}
}
}
If validation fails, Moleculer automatically throws a ValidationError and your handler never runs. The error message tells the caller exactly which field failed and why.
Test this yourself. Add this action to your greeter.service.js and call it without passing name:
greet: {
params: {
name: "string"
},
handler(ctx) {
return `Hello ${ctx.params.name}`;
}
}
Call it via REPL:
call greeter.greet {}
You will see a validation error like this:
ValidationError: Parameters validation error!
- name: The 'name' field is required.
No code needed on your end. Moleculer handles it.
Calling Actions — All the Ways
You have seen broker.call() and ctx.call(). Let us go through all the options available when calling an action.
Basic call
const result = await broker.call("user.create", {
name: "Rahul",
email: "rahul@example.com"
});
Call with options
The third argument to broker.call is an options object:
const result = await broker.call("user.create", {
name: "Rahul",
email: "rahul@example.com"
}, {
// Override the global timeout for this specific call
timeout: 5000,
// Retry this call up to 3 times if it fails
retries: 3,
// Pass metadata — visible in ctx.meta inside the handler
meta: {
userAgent: "Mozilla/5.0",
requestID: "abc-123"
}
});
Calling from inside a service action
Inside a service action always use ctx.call() instead of broker.call():
actions: {
async createOrder(ctx) {
// Call user service to verify user exists
const user = await ctx.call("user.getById", {
id: ctx.params.userId
});
if (!user) {
throw new Error("User not found");
}
// Call product service to check stock
const product = await ctx.call("product.checkStock", {
id: ctx.params.productId
});
return {
order: "created",
user: user.name,
product: product.name
};
}
}
The reason you use ctx.call() inside services is that it carries the request context forward. This means the tracing system can see the full chain of calls — order called user, order called product — as one connected request. If you used broker.call() instead, this chain would break and tracing would not work correctly.
Calling multiple actions in parallel
When two calls do not depend on each other, run them at the same time using Promise.all:
actions: {
async getDashboard(ctx) {
// These two calls run at the same time, not one after another
const [user, orders] = await Promise.all([
ctx.call("user.getById", { id: ctx.params.userId }),
ctx.call("order.listByUser", { userId: ctx.params.userId })
]);
return { user, orders };
}
}
This is significantly faster than awaiting them one by one when the calls are independent.
ctx.meta — Passing Data Across the Call Chain
ctx.meta is a special object that travels with the request through the entire call chain. Unlike ctx.params which contains the action input, ctx.meta is for cross-cutting data — things like the authenticated user, request ID, language preference, etc.
// In your API Gateway or first action in the chain
actions: {
async getProfile(ctx) {
// Set something in meta
ctx.meta.authUser = { id: 1, role: "admin" };
// Call another service
const result = await ctx.call("user.getById", { id: 1 });
return result;
}
}
// In user.service.js
actions: {
getById(ctx) {
// meta is automatically available here
// even though this is a different service
console.log(ctx.meta.authUser); // { id: 1, role: "admin" }
return { id: ctx.params.id, name: "Rahul" };
}
}
ctx.meta is how you pass the authenticated user's information across all services without having to explicitly include it in every action's params. We will use this heavily in the API Gateway post.
Throwing Errors from Actions
When something goes wrong in an action, throw an error. Moleculer has built-in error classes you should use:
const { MoleculerClientError, MoleculerServerError } = require("moleculer").Errors;
module.exports = {
name: "user",
actions: {
getById: {
params: {
id: "string"
},
async handler(ctx) {
const user = await findUserById(ctx.params.id);
if (!user) {
// 404 — client made a bad request
throw new MoleculerClientError(
"User not found",
404,
"USER_NOT_FOUND",
{ id: ctx.params.id }
);
}
return user;
}
}
}
};
MoleculerClientError is for errors caused by bad input from the caller — wrong ID, missing field, unauthorized. MoleculerServerError is for internal failures — database down, unexpected exception.
The API Gateway automatically converts these errors into the correct HTTP status codes. A MoleculerClientError with code 404 becomes an HTTP 404 response.
A Complete Realistic Service Example
Let us put everything together. This is what a real service looks like:
"use strict";
const { MoleculerClientError } = require("moleculer").Errors;
// In-memory store for this example
// In real projects this would be a database
const users = [
{ id: "1", name: "Rahul Sharma", email: "rahul@example.com", role: "admin" },
{ id: "2", name: "Priya Singh", email: "priya@example.com", role: "user" }
];
module.exports = {
name: "user",
settings: {
defaultPageSize: 10
},
actions: {
// List all users
list: {
rest: {
method: "GET",
path: "/"
},
handler(ctx) {
return users;
}
},
// Get a single user by ID
getById: {
rest: {
method: "GET",
path: "/:id"
},
params: {
id: "string"
},
handler(ctx) {
const user = this.findUser(ctx.params.id);
if (!user) {
throw new MoleculerClientError(
"User not found",
404,
"USER_NOT_FOUND"
);
}
return user;
}
},
// Create a new user
create: {
rest: {
method: "POST",
path: "/"
},
params: {
name: { type: "string", min: 2 },
email: "email",
role: { type: "enum", values: ["admin", "user"], default: "user" }
},
handler(ctx) {
const newUser = {
id: String(users.length + 1),
...ctx.params
};
users.push(newUser);
return newUser;
}
},
// Delete a user
remove: {
rest: {
method: "DELETE",
path: "/:id"
},
params: {
id: "string"
},
handler(ctx) {
const index = users.findIndex(u => u.id === ctx.params.id);
if (index === -1) {
throw new MoleculerClientError(
"User not found",
404,
"USER_NOT_FOUND"
);
}
users.splice(index, 1);
return { message: "User deleted successfully" };
}
}
},
methods: {
// Private helper method
findUser(id) {
return users.find(u => u.id === id);
}
},
started() {
this.logger.info(`User service started with ${users.length} users`);
}
};
Save this as services/user.service.js in your project. Run npm run dev and test these endpoints:
GET http://localhost:3000/api/user
GET http://localhost:3000/api/user/1
POST http://localhost:3000/api/user
DELETE http://localhost:3000/api/user/1
For the POST request, send this JSON body in Postman:
{
"name": "Amit Kumar",
"email": "amit@example.com",
"role": "user"
}
You have a fully working user service with proper validation, error handling, and REST endpoints.
Summary
- A service is a plain exported JavaScript object. No classes, no decorators.
- settings stores service-level config values, accessed via this.settings.
- dependencies makes a service wait for other services before starting.
- methods are private helper functions inside a service, called via this.methodName().
- Always use full action syntax in real projects — with rest, params, and handler.
- params schema handles both validation and type coercion automatically.
- Use ctx.call() inside services, not broker.call(), to preserve the call chain for tracing.
- Run independent calls in parallel using Promise.all for better performance.
- ctx.meta carries cross-cutting data like auth info across the entire call chain.
- Use MoleculerClientError for bad input errors and MoleculerServerError for internal failures.
Up Next
Post 5 covers Events — the second way services communicate in Moleculer. Instead of request-reply like actions, events are fire-and-forget broadcasts. We will cover emitting events, listening to events, balanced vs broadcast events, and when to use events over actions.
Course Progress: 4 of 15 posts complete.
No comments:
Post a Comment