Phase 2 - Core Concepts | Post 4 | Services and Actions — The Building Blocks You Write Every Day

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

Phase 4 - Fault Tolerance | Post 8 | Fault Tolerance — Keeping Your App Alive When Things Break

Post 8 of 15 | Phase 4: Fault Tolerance Fault Tolerance — Keeping Your App Alive When Things Break In every post so far we have assumed that...