Phase 3 - Communication | Post 6 | The Context Object — The Backbone of Every Request

Post 6 of 15 | Phase 3: Communication


The Context Object — The Backbone of Every Request

In every post so far you have seen ctx appearing everywhere. ctx.params, ctx.meta, ctx.call, ctx.emit. You have been using it without fully understanding what it is. This post fixes that completely.

The Context object is one of the most important things in Moleculer. Every action call and every event creates a Context object. It travels through the entire call chain carrying information about the request. Understanding it deeply will make debugging easier, your code cleaner, and advanced features like tracing and auth much simpler to implement.


What is the Context Object?

When you call an action, Moleculer does not just call your handler function directly. It first creates a Context object that wraps everything about that request — the input data, who called it, which node it came from, timing information, metadata, and more. Then it passes this Context to your handler as the ctx argument.

Think of ctx as a package that travels with a request. Like a courier package that has the item inside, but also has a label with the sender address, receiver address, tracking number, and delivery instructions. The item is ctx.params. Everything else on the label is the rest of ctx.


Complete Map of the Context Object

Here is every property on ctx that you will use:

actions: {
    example(ctx) {
        // INPUT DATA
        ctx.params          // The data passed to this action call
        ctx.meta            // Shared metadata across the call chain

        // REQUEST IDENTITY
        ctx.id              // Unique ID for this specific request
        ctx.requestID       // The root request ID — same across the whole chain
        ctx.parentID        // The ID of the context that called this one

        // NODE AND SERVICE INFO
        ctx.nodeID          // Which node sent this request
        ctx.caller          // Which service.action called this action

        // COMMUNICATION
        ctx.call()          // Call another action (carries context forward)
        ctx.emit()          // Emit a balanced event
        ctx.broadcast()     // Broadcast to all instances

        // EVENT SPECIFIC (only available in event handlers)
        ctx.eventName       // The name of the event that triggered this handler
        ctx.eventType       // "emit" or "broadcast"
        ctx.eventGroups     // Which groups this event was sent to

        // TIMEOUT AND LEVEL
        ctx.options         // The call options passed to this request
        ctx.level           // How deep in the call chain this request is
    }
}

Let us go through the important ones in detail.


ctx.params

This is the data passed when the action was called. You have used this in every post. Quick recap:

// Caller
broker.call("math.add", { a: 5, b: 3 });

// Handler
actions: {
    add(ctx) {
        console.log(ctx.params); // { a: 5, b: 3 }
        return ctx.params.a + ctx.params.b;
    }
}

ctx.params is always the direct input to your action. It is validated against your params schema before reaching the handler.


ctx.meta

You saw this briefly in Post 4. Let us go deep now because this is one of the most powerful and most used features in real applications.

ctx.meta is a shared object that travels across the entire call chain. Any service can read from it and write to it. Changes made in one service are visible in subsequent calls.

The most common use case is authentication. Your API Gateway verifies the JWT token and puts the authenticated user into ctx.meta. Every downstream service can then read ctx.meta.user without needing to verify the token again.

// api.service.js — the API Gateway
// This runs before every request (we cover this in Post 10)
async onBeforeCall(ctx, route, req, res) {
    const token = req.headers["authorization"];
    const user = verifyJWT(token);
    // Put user into meta — now all downstream services can see this
    ctx.meta.user = user;
    ctx.meta.requestID = req.headers["x-request-id"];
}

// user.service.js
actions: {
    getProfile(ctx) {
        // Read from meta — no need to pass user ID in params
        const currentUser = ctx.meta.user;
        this.logger.info(`Profile requested by ${currentUser.email}`);
        return { id: currentUser.id, name: currentUser.name };
    }
}

// order.service.js
actions: {
    myOrders(ctx) {
        // Same meta is available here too
        const currentUser = ctx.meta.user;
        return orders.filter(o => o.userId === currentUser.id);
    }
}

You can also write back to ctx.meta from a service and the caller will see the updated value:

// user.service.js
actions: {
    login(ctx) {
        const user = authenticateUser(ctx.params.email, ctx.params.password);
        // Write the token back to meta
        // The caller (API Gateway) will receive this in the response meta
        ctx.meta.token = generateJWT(user);
        return { message: "Login successful" };
    }
}

// In the API Gateway after the call completes
const result = await ctx.call("user.login", { email, password });
// ctx.meta.token is now available here
// You can set it as a cookie or response header
res.setHeader("Authorization", ctx.meta.token);

This is a very clean pattern. The service sets the token in meta, the gateway picks it up and sends it to the client.


ctx.id and ctx.requestID

Every Context has a unique ID. These two fields help you trace requests through the system.

actions: {
    create(ctx) {
        // ctx.id — unique ID of THIS specific context
        // Changes at every hop in the call chain
        this.logger.info(`Context ID: ${ctx.id}`);

        // ctx.requestID — the ROOT request ID
        // Stays the same across the ENTIRE call chain
        // If API Gateway called order which called user,
        // all three have the same requestID
        this.logger.info(`Request ID: ${ctx.requestID}`);
    }
}

ctx.requestID is extremely useful for debugging. When something goes wrong in production, you can search your logs for a specific requestID and see every single step of that request across all services.

You can also set your own requestID from outside:

await broker.call("order.create", { userId: "1" }, {
    requestID: "my-custom-id-abc123"
});

Now every service in the chain will have requestID as my-custom-id-abc123. This is useful when you want to correlate a Moleculer request with an external request ID from your frontend or mobile app.


ctx.caller

This tells you which service and action triggered the current call.

// order.service.js
actions: {
    create(ctx) {
        // Who called this action?
        this.logger.info(`Called by: ${ctx.caller}`);
        // Output: "api.rest" if called from API Gateway
        // Output: "payment.process" if called from payment service
    }
}

This is useful for authorization. For example, a sensitive internal action should only be callable by specific services, not from the API Gateway directly.

actions: {
    internalRecalculate(ctx) {
        // Only allow calls from the admin service
        if (ctx.caller !== "admin.trigger") {
            throw new Error("Unauthorized internal call");
        }
        // proceed
    }
}

ctx.level

This is the depth of the current call in the call chain. The first call from outside has level 1. If that action calls another action, that inner call has level 2. And so on.

actions: {
    process(ctx) {
        this.logger.info(`Call depth: ${ctx.level}`);
        // Level 1 if called directly
        // Level 2 if called from another action
    }
}

Moleculer has a maxCallLevel option in moleculer.config.js that defaults to 100. If your call chain goes deeper than 100 levels, Moleculer throws an error. This prevents infinite loops where service A calls service B which calls service A again.


ctx.options

This contains the options that were passed when this action was called — timeout, retries, etc.

actions: {
    process(ctx) {
        console.log(ctx.options);
        // { timeout: 5000, retries: 3, ... }
    }
}

You rarely read this directly but it is good to know it exists.


Practical Example — Tracing a Full Request Chain

Let us build a small example that demonstrates how ctx travels through multiple services. Create or update these files:

services/gateway-demo.service.js

"use strict";

module.exports = {
    name: "gateway-demo",

    actions: {
        async placeOrder(ctx) {
            this.logger.info(`[gateway-demo] requestID: ${ctx.requestID}`);
            this.logger.info(`[gateway-demo] level: ${ctx.level}`);

            // Set something in meta
            ctx.meta.initiatedBy = "gateway-demo";

            // Call order service
            const order = await ctx.call("order-demo.create", {
                product: ctx.params.product,
                userId: ctx.params.userId
            });

            return order;
        }
    }
};

services/order-demo.service.js

"use strict";

module.exports = {
    name: "order-demo",

    actions: {
        async create(ctx) {
            this.logger.info(`[order-demo] requestID: ${ctx.requestID}`);
            this.logger.info(`[order-demo] level: ${ctx.level}`);
            this.logger.info(`[order-demo] caller: ${ctx.caller}`);
            this.logger.info(`[order-demo] meta.initiatedBy: ${ctx.meta.initiatedBy}`);

            // Write something back to meta
            ctx.meta.orderProcessedBy = "order-demo";

            // Call user service
            const user = await ctx.call("user-demo.getById", {
                id: ctx.params.userId
            });

            return {
                product: ctx.params.product,
                user: user.name,
                status: "created"
            };
        }
    }
};

services/user-demo.service.js

"use strict";

module.exports = {
    name: "user-demo",

    actions: {
        getById(ctx) {
            this.logger.info(`[user-demo] requestID: ${ctx.requestID}`);
            this.logger.info(`[user-demo] level: ${ctx.level}`);
            this.logger.info(`[user-demo] caller: ${ctx.caller}`);
            this.logger.info(`[user-demo] meta:`, ctx.meta);

            return { id: ctx.params.id, name: "Rahul Sharma" };
        }
    }
};

Now test via REPL:

npm run repl
call gateway-demo.placeOrder {"product": "Keyboard", "userId": "user-1"}

Your terminal output will look like this:

[gateway-demo] requestID: abc-123-xyz
[gateway-demo] level: 1

[order-demo] requestID: abc-123-xyz       <-- same requestID
[order-demo] level: 2                     <-- deeper level
[order-demo] caller: gateway-demo.placeOrder
[order-demo] meta.initiatedBy: gateway-demo

[user-demo] requestID: abc-123-xyz        <-- still same requestID
[user-demo] level: 3                      <-- even deeper
[user-demo] caller: order-demo.create
[user-demo] meta: { initiatedBy: "gateway-demo", orderProcessedBy: "order-demo" }

Look at what happened:

  • requestID stayed the same across all three services. One request ID to trace the whole chain.
  • level increased at each hop. gateway called order (level 2), order called user (level 3).
  • caller shows exactly who called each service.
  • meta accumulated data as it traveled through the chain. Both values set by different services are visible at the end.

This is how you debug and trace requests in a real microservices system.


Copying ctx — When You Need a Fresh Context

Sometimes you want to call an action but start a fresh context instead of continuing the current chain. For example, a background job that should not be tied to the original request timeout.

actions: {
    async processOrder(ctx) {
        // This call uses the current context (shares timeout, requestID etc.)
        const result = await ctx.call("payment.charge", { amount: 100 });

        // This call creates a brand new independent context
        // Useful for background tasks that should not be limited
        // by the original request timeout
        await broker.call("email.sendReceipt", { orderId: result.id });

        return result;
    }
}

Using broker.call() instead of ctx.call() creates a new root context. The new call gets its own requestID and starts at level 1. It is completely independent of the original request.


Common Mistakes with Context

Mistake 1: Mutating ctx.params directly

// Wrong
actions: {
    create(ctx) {
        ctx.params.id = generateId(); // Do not mutate params
        return ctx.params;
    }
}

// Correct
actions: {
    create(ctx) {
        const newRecord = {
            ...ctx.params,
            id: generateId()
        };
        return newRecord;
    }
}

Mistake 2: Using broker.call() inside services when you should use ctx.call()

// Wrong — breaks the call chain, tracing does not work
actions: {
    async createOrder(ctx) {
        const user = await broker.call("user.getById", { id: ctx.params.userId });
    }
}

// Correct
actions: {
    async createOrder(ctx) {
        const user = await ctx.call("user.getById", { id: ctx.params.userId });
    }
}

Mistake 3: Storing ctx and using it after the action completes

// Wrong — ctx is only valid during the action execution
let savedCtx;
actions: {
    process(ctx) {
        savedCtx = ctx; // Do not do this
        return "ok";
    }
}
// Using savedCtx later is undefined behavior

Quick Reference Card

ctx.params          — input data for this action
ctx.meta            — shared data across the call chain, readable and writable
ctx.id              — unique ID of this specific context
ctx.requestID       — root request ID, same across the whole chain
ctx.caller          — which service.action called this
ctx.nodeID          — which node sent this request
ctx.level           — depth in the call chain
ctx.eventName       — event name (only in event handlers)

ctx.call()          — call another action, continues the chain
ctx.emit()          — emit a balanced event
ctx.broadcast()     — emit to all instances

Summary

  • Context is created automatically for every action call and event. You never create it manually.
  • ctx.params holds the input data validated against your params schema.
  • ctx.meta is a shared object that travels across the entire call chain. Use it for auth, request IDs, and cross-cutting data.
  • ctx.requestID stays the same across all services in one request chain. Essential for debugging.
  • ctx.caller tells you which service called the current action. Useful for internal authorization.
  • ctx.level shows how deep in the call chain you are. Moleculer stops at maxCallLevel to prevent infinite loops.
  • Always use ctx.call() inside services, not broker.call(). It carries the context forward correctly.
  • Never mutate ctx.params. Spread it into a new object instead.
  • Never store ctx and use it after the action finishes. It is only valid during execution.

Up Next

Post 7 covers Transporters — the communication layer that allows services running on completely different machines to talk to each other. We will set up NATS and Redis transporters, run services as separate processes, and see how the broker routes calls transparently across nodes.


Course Progress: 6 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...