Somewhere around 2018 the phrase "we're doing microservices" silently became "we're running Kubernetes". The two ideas fused. Today when a Node.js team asks how to split a backend into services, the answer they get is a shopping list: a cluster, an ingress controller, a service mesh for retries and mTLS, Consul or CoreDNS for discovery, Prometheus and Jaeger wired into sidecars, and a Helm chart per service to hold it all together.
That stack is excellent. It is also, for most teams that are not Google, the wrong first step. This article is about the other path: getting the actual properties of microservices — independent deployment, service discovery, load balancing, fault isolation, an event bus — in plain Node.js processes, on a single VM or a handful of them, without an orchestrator.
I maintain Moleculer, a microservices framework for Node.js and TypeScript that was built for exactly this, so the examples use it. But the argument comes first, and it holds regardless of which tool you pick.
What you actually need from "microservices"
Strip away the branding and a microservice architecture is a short list of capabilities:
Service discovery — service A needs to find a live instance of service B without hardcoding IPs.
— service A needs to find a live instance of service B without hardcoding IPs. Load balancing — if B has three instances, calls should spread across them, and a dead instance should stop receiving traffic.
— if B has three instances, calls should spread across them, and a dead instance should stop receiving traffic. Inter-service communication — request/response (RPC) and publish/subscribe (events), with serialization handled for you.
— request/response (RPC) and publish/subscribe (events), with serialization handled for you. Resilience — timeouts, retries, circuit breakers, bulkheads and fallbacks so one slow dependency doesn't take the whole system down.
— timeouts, retries, circuit breakers, bulkheads and fallbacks so one slow dependency doesn't take the whole system down. Observability — metrics and distributed tracing across service boundaries.
— metrics and distributed tracing across service boundaries. Independent deployment — ship service B without redeploying A.
Kubernetes plus a service mesh gives you all of that at the infrastructure level: discovery via DNS and Services, balancing via kube-proxy or Envoy, resilience via Istio/Linkerd policies, tracing via sidecars. It is a legitimate design. It's just a lot of moving parts, and every one of them is something your team has to run, upgrade and debug at 3am.
The alternative is to put those capabilities in the application layer — in a library your services import — and let the infrastructure be as dumb as a VM with Docker Compose. That's the Moleculer model.
Moleculer in sixty seconds
A few terms, each glossed with what you'd type into a search box:
ServiceBroker — the runtime and service registry that lives in every process. It knows which services exist, where they are, and routes calls to them.
— the runtime and service registry that lives in every process. It knows which services exist, where they are, and routes calls to them. Service — a named object with actions (RPC endpoints, callable as "orders.create" ) and event handlers (subscribers).
— a named object with (RPC endpoints, callable as ) and (subscribers). Transporter — the message broker / transport layer between processes: NATS, Redis, Kafka, MQTT, AMQP or plain TCP. Swappable with one config line.
The crucial property: a service's code does not know whether the service it's calling is in the same process or on another machine. ctx.call("products.get") is the same line either way.
Step 1: three services, one process
Let's write a tiny shop: products , orders and notifications . Here is the business logic, and note that this file never changes for the rest of the article.
// services.js — the business logic. It never changes between "one process" and "many processes".
const
Products
=
{
name
:
"
products
"
,
actions
:
{
get
:
{
params
:
{
id
:
{
type
:
"
number
"
,
convert
:
true
}
},
// convert: URL params arrive as strings
handler
(
ctx
)
{
return
{
id
:
ctx
.
params
.
id
,
name
:
`Product #
${
ctx
.
params
.
id
}
`
,
price
:
42
,
servedBy
:
this
.
broker
.
nodeID
};
},
},
},
};
const
Orders
=
{
name
:
"
orders
"
,
actions
:
{
create
:
{
params
:
{
productId
:
"
number
"
,
qty
:
"
number
"
},
async
handler
(
ctx
)
{
// Looks like a local function call — may be a network hop. The framework decides.
const
product
=
await
ctx
.
call
(
"
products.get
"
,
{
id
:
ctx
.
params
.
productId
});
const
order
=
{
id
:
Date
.
now
(),
product
,
qty
:
ctx
.
params
.
qty
,
total
:
product
.
price
*
ctx
.
params
.
qty
};
// Publish an event. Whoever cares subscribes.
await
ctx
.
emit
(
"
order.created
"
,
order
);
return
order
;
},
},
},
};
const
Notifications
=
{
name
:
"
notifications
"
,
events
:
{
"
order.created
"
(
ctx
)
{
this
.
logger
.
info
(
`Email sent: order
${
ctx
.
params
.
id
}
, total
${
ctx
.
params
.
total
}
(from
${
ctx
.
nodeID
}
)`
);
},
},
};
module
.
exports
=
{
Products
,
Orders
,
Notifications
};
Enter fullscreen mode Exit fullscreen mode
The params block is a validation schema (Moleculer ships with fastest-validator); an invalid call is rejected with a structured ValidationError before your handler runs.
Now load all three into a single broker and run it. No message broker, no Docker, nothing listening on a port:
// monolith.js — all three services in ONE process. No broker, no network, no Docker.
const
{
ServiceBroker
}
=
require
(
"
moleculer
"
);
const
{
Products
,
Orders
,
Notifications
}
=
require
(
"
./services
"
);
const
broker
=
new
ServiceBroker
({
logger
:
{
type
:
"
Console
"
,
options
:
{
level
:
"
info
"
}
}
});
broker
.
createService
(
Products
);
broker
.
createService
(
Orders
);
broker
.
createService
(
Notifications
);
broker
.
start
()
.
then
(()
=>
broker
.
call
(
"
orders.create
"
,
{
productId
:
7
,
qty
:
3
}))
.
then
((
order
)
=>
console
.
log
(
"
Order:
"
,
order
))
.
then
(()
=>
broker
.
stop
());
Enter fullscreen mode Exit fullscreen mode
$ npm install moleculer $ node monolith.js INFO dev-laptop-51203/NOTIFICATIONS: Email sent: order 1788453205755, total 126 (from dev-laptop-51203) Order: { id: 1788453205755, product: { id: 7, name: 'Product #7', price: 42, servedBy: 'dev-laptop-51203' }, qty: 3, total: 126 }
Enter fullscreen mode Exit fullscreen mode
This is a modular monolith: one deployable, but the modules only talk through the same contract they'd use over a network. It is a perfectly good place to stay for a long time. Many Moleculer users never leave it.
Step 2: split into processes with one config line
To run the services in separate processes, add a transporter. We'll use NATS — a single ~20 MB binary with no dependencies, and the best default for most teams.
$ docker run -d -p 4222:4222 nats:2 $ npm install nats
Enter fullscreen mode Exit fullscreen mode
The node script takes the names of the services it should host from the command line:
// node.js — start ONE process that hosts the services named on the command line.
// node node.js products
// node node.js orders notifications
// Same service code as before. The only new thing is the transporter.
const
{
ServiceBroker
}
=
require
(
"
moleculer
"
);
const
all
=
require
(
"
./services
"
);
const
broker
=
new
ServiceBroker
({
nodeID
:
`
${
process
.
argv
.
slice
(
2
).
join
(
"
+
"
)}
-
${
process
.
pid
}
`
,
transporter
:
process
.
env
.
TRANSPORTER
||
"
nats://localhost:4222
"
,
logger
:
{
type
:
"
Console
"
,
options
:
{
level
:
"
info
"
}
},
});
for
(
const
name
of
process
.
argv
.
slice
(
2
))
{
const
schema
=
Object
.
values
(
all
).
find
((
s
)
=>
s
.
name
===
name
);
if
(
!
schema
)
throw
new
Error
(
`Unknown service:
${
name
}
`
);
broker
.
createService
(
schema
);
}
broker
.
start
();
Enter fullscreen mode Exit fullscreen mode
Start two of them in two terminals:
$ node node.js products $ node node.js orders notifications
Enter fullscreen mode Exit fullscreen mode
And a client that has no services of its own — it just joins the cluster and calls in:
// client.js — a broker with no services of its own; it just calls into the cluster.
const
{
ServiceBroker
}
=
require
(
"
moleculer
"
);
const
broker
=
new
ServiceBroker
({
nodeID
:
"
client
"
,
transporter
:
process
.
env
.
TRANSPORTER
||
"
nats://localhost:4222
"
,
logger
:
false
,
});
broker
.
start
()
.
then
(()
=>
broker
.
waitForServices
([
"
orders
"
]))
.
then
(
async
()
=>
{
for
(
let
i
=
1
;
i
<=
4
;
i
++
)
{
const
order
=
await
broker
.
call
(
"
orders.create
"
,
{
productId
:
i
,
qty
:
2
});
console
.
log
(
`order
${
order
.
id
}
→ total
${
order
.
total
}
, product served by
${
order
.
product
.
servedBy
}
`
);
}
// Built-in introspection: which nodes are online and what do they host?
const
nodes
=
await
broker
.
call
(
"
$node.list
"
,
{
withServices
:
true
});
for
(
const
n
of
nodes
)
console
.
log
(
`node
${
n
.
id
}
:
${
n
.
services
.
map
((
s
)
=>
s
.
name
).
filter
((
s
)
=>
s
!==
"
$node
"
).
join
(
"
,
"
)}
`
);
})
.
then
(()
=>
broker
.
stop
());
Enter fullscreen mode Exit fullscreen mode
$ node client.js order 1788453251083 → total 84, product served by products-3455407 order 1788453251095 → total 84, product served by products-3455407 order 1788453251104 → total 84, product served by products-3455407 order 1788453251112 → total 84, product served by products-3455407 node client: node orders+notifications-3455409: orders, notifications node products-3455407: products
Enter fullscreen mode Exit fullscreen mode
Look at what just happened without any configuration:
The orders process called products.get in a different process . Nobody told it where products lives.
process called in a . Nobody told it where lives. The order.created event crossed the network and reached notifications (check the second terminal — it logged the four emails).
event crossed the network and reached (check the second terminal — it logged the four emails). The client discovered the whole cluster via the built-in $node service — a service registry you get for free.
That is service discovery. There is no Consul, no etcd, no DNS trick. When a node starts it announces itself over the transporter; every other broker updates its local registry; heartbeats detect when a node disappears. The registry lives inside each process, so a lookup is a hash-map read, not a network call.
Step 3: scale a service by starting another copy
Open a third terminal and start a second products process. Then run the client again:
$ node node.js products # second instance $ node client.js order 1788453267932 → total 84, product served by products-3455456 order 1788453267948 → total 84, product served by products-3455458 order 1788453267956 → total 84, product served by products-3455456 order 1788453267965 → total 84, product served by products-3455458 node orders+notifications-3455460: orders, notifications node products-3455456: products node products-3455458: products
Enter fullscreen mode Exit fullscreen mode
Calls now alternate between the two instances. That's the default round-robin load balancing; you can switch to random, CPU-usage-based, latency-based or shard-by-key strategies with one option. Stop one of the products processes and traffic moves to the survivor: a graceful stop deregisters the node immediately, and a hard crash is caught by the heartbeat timeout (configurable; the balancing docs cover the strategies and the fault-tolerance docs the failover behaviour).
Notice the shape of the "deploy": starting a process. Scaling: starting another process. There is no Deployment manifest, no replica count to reconcile, no rollout to watch. If your team runs services with systemd , PM2 or Docker Compose, you already have everything the framework needs.
Resilience is a broker option, not a library
In the Kubernetes world, retries, timeouts and circuit breaking usually arrive with the service mesh. Without a mesh you'd reach for opossum , p-retry and AbortSignal.timeout and glue them into every call site. Moleculer bakes them into the call path:
// resilience.js — timeout, retry and circuit breaker are broker options, not extra libraries.
const
{
ServiceBroker
,
Errors
}
=
require
(
"
moleculer
"
);
const
broker
=
new
ServiceBroker
({
logger
:
false
,
requestTimeout
:
500
,
// ms; a hanging call becomes a RequestTimeoutError
retryPolicy
:
{
enabled
:
true
,
retries
:
2
,
delay
:
50
,
factor
:
2
},
circuitBreaker
:
{
enabled
:
true
,
threshold
:
0.5
,
// open when >50% of calls fail...
minRequestCount
:
5
,
// ...after at least 5 calls
windowTime
:
60
,
// seconds
halfOpenTime
:
5
_000
,
// ms until we probe again
},
});
let
calls
=
0
;
broker
.
createService
({
name
:
"
payments
"
,
actions
:
{
charge
:
{
retryPolicy
:
{
enabled
:
false
},
// retries make no sense for a non-idempotent charge
handler
()
{
calls
++
;
throw
new
Errors
.
MoleculerError
(
"
gateway down
"
,
503
);
// 5xx errors count against the breaker
},
},
fallbackDemo
:
{
fallback
:
()
=>
({
cached
:
true
}),
// return this instead of throwing
handler
()
{
throw
new
Errors
.
MoleculerError
(
"
nope
"
,
500
);
},
},
},
});
broker
.
start
().
then
(
async
()
=>
{
for
(
let
i
=
1
;
i
<=
8
;
i
++
)
{
try
{
await
broker
.
call
(
"
payments.charge
"
,
{
amount
:
10
});
}
catch
(
err
)
{
console
.
log
(
`call
${
i
}
:
${
err
.
name
}
(
${
err
.
message
}
)`
);
}
}
console
.
log
(
"
handler actually ran:
"
,
calls
,
"
times
"
);
console
.
log
(
"
fallback:
"
,
await
broker
.
call
(
"
payments.fallbackDemo
"
));
await
broker
.
stop
();
});
Enter fullscreen mode Exit fullscreen mode
$ node resilience.js call 1: MoleculerError (gateway down) call 2: MoleculerError (gateway down) call 3: MoleculerError (gateway down) call 4: MoleculerError (gateway down) call 5: MoleculerError (gateway down) call 6: ServiceNotAvailableError (Service 'payments.charge' is not available.) call 7: ServiceNotAvailableError (Service 'payments.charge' is not available.) call 8: ServiceNotAvailableError (Service 'payments.charge' is not available.) handler actually ran: 5 times fallback: { cached: true }
Enter fullscreen mode Exit fullscreen mode
After five failures the circuit breaker opened and the next calls failed fast without touching the handler — exactly what you want when a payment gateway is melting down. Retries and timeouts are configured globally and overridden per action (note retryPolicy: { enabled: false } on the non-idempotent charge ). A bulkhead (per-action concurrency limit) is one more option. None of this needed a sidecar.
Putting an HTTP door on it
Services talk to each other over the transporter, but browsers speak HTTP. The moleculer-web package is an API gateway that is itself a service — it joins the cluster like any other node and maps routes to actions:
// gateway.js — HTTP edge. It is just another node in the cluster; it owns no business logic.
const
{
ServiceBroker
}
=
require
(
"
moleculer
"
);
const
ApiGateway
=
require
(
"
moleculer-web
"
);
const
broker
=
new
ServiceBroker
({
nodeID
:
`gateway-
${
process
.
pid
}
`
,
transporter
:
process
.
env
.
TRANSPORTER
||
"
nats://localhost:4222
"
,
logger
:
{
type
:
"
Console
"
,
options
:
{
level
:
"
warn
"
}
},
});
broker
.
createService
({
name
:
"
api
"
,
mixins
:
[
ApiGateway
],
settings
:
{
port
:
process
.
env
.
PORT
||
3000
,
routes
:
[{
path
:
"
/api
"
,
// Only expose what you list here; everything else stays internal to the cluster.
whitelist
:
[
"
products.get
"
,
"
orders.create
"
],
aliases
:
{
"
GET /products/:id
"
:
"
products.get
"
,
"
POST /orders
"
:
"
orders.create
"
,
},
bodyParsers
:
{
json
:
true
},
}],
},
});
broker
.
start
().
then
(()
=>
console
.
log
(
`Gateway listening on http://localhost:
${
process
.
env
.
PORT
||
3000
}
/api`
));
Enter fullscreen mode Exit fullscreen mode
$ npm install moleculer-web $ node gateway.js $ curl localhost:3000/api/products/7 {"id":7,"name":"Product #7","price":42,"servedBy":"products-3455667"} $ curl -X POST localhost:3000/api/orders -H 'content-type: application/json' -d '{"productId":7,"qty":2}' {"id":1788453318747,"product":{"id":7,"name":"Product #7","price":42,"servedBy":"products-3455612"},"qty":2,"total":84} $ curl -X POST localhost:3000/api/orders -H 'content-type: application/json' -d '{"productId":"x"}' {"name":"ValidationError","message":"Parameters validation error!","code":422,"type":"VALIDATION_ERROR","data":[{"type":"number","message":"The 'productId' field must be a number.","field":"productId","actual":"x","nodeID":"gateway-3455616","action":"orders.create"},{"type":"required","message":"The 'qty' field is required.","field":"qty","nodeID":"gateway-3455616","action":"orders.create"}]}
Enter fullscreen mode Exit fullscreen mode
The gateway has no knowledge of where products runs. The validation error came from the schema on the service, propagated back through the transporter, and was turned into a 422 automatically. Run two gateways behind nginx or Caddy and you've got a redundant edge.
Deploying without an orchestrator
Here is the entire production topology for a small team, as a Compose file. One VM (or one per service if you like), a NATS container, and your Node processes:
# docker-compose.yml — the whole "cluster" on one VM. No orchestrator.
services
:
nats
:
image
:
nats:2
products
:
build
:
.
command
:
node node.js products
environment
:
{
TRANSPORTER
:
nats
:
//nats
:
4222
}
depends_on
:
[
nats
]
deploy
:
{
replicas
:
2
}
# two instances; the framework balances between them
orders
:
build
:
.
command
:
node node.js orders notifications
environment
:
{
TRANSPORTER
:
nats
:
//nats
:
4222
}
depends_on
:
[
nats
]
gateway
:
build
:
.
command
:
node gateway.js
environment
:
{
TRANSPORTER
:
nats
:
//nats
:
4222
}
ports
:
[
"
3000:3000"
]
depends_on
:
[
nats
]
Enter fullscreen mode Exit fullscreen mode
docker compose up -d and you have a load-balanced, self-discovering, fault-tolerant service cluster. To spread it over several machines, point every TRANSPORTER at the same NATS host — NATS itself clusters trivially if you need it to be redundant. Metrics (Prometheus, StatsD, Datadog) and distributed tracing (Jaeger, Zipkin, OpenTelemetry) are broker options too, so observability doesn't need sidecars either.
What you give up compared to Kubernetes, honestly:
Automatic rescheduling. If a VM dies, nothing restarts your containers elsewhere. You handle it with a second VM and a health check, or accept the risk.
If a VM dies, nothing restarts your containers elsewhere. You handle it with a second VM and a health check, or accept the risk. Rolling deploys as a primitive. Compose replaces containers; for zero-downtime you rely on Moleculer's graceful shutdown (a stopping node deregisters first and, with request tracking enabled, waits for in-flight requests before exiting) plus at least two replicas.
Compose replaces containers; for zero-downtime you rely on Moleculer's graceful shutdown (a stopping node deregisters first and, with request tracking enabled, waits for in-flight requests before exiting) plus at least two replicas. Autoscaling. You scale by editing replicas . For most teams that's a feature.
You scale by editing . For most teams that's a feature. Secrets, RBAC, network policy. You use your OS and cloud provider's tools instead.
When you do want Kubernetes
If you already run a cluster, keep it — Moleculer runs perfectly well inside pods. The two layers are complementary rather than competing: Kubernetes handles scheduling, restarts and rollouts; Moleculer handles discovery, balancing and resilience at the application level, which means you don't need a service mesh on top. The official Kubernetes guide covers it.
You should also reach for an orchestrator when you have a genuinely large fleet (dozens of services, hundreds of instances), strict multi-tenant isolation requirements, or a platform team whose job it is to run one.
But if you are three developers with a product to ship, the honest answer to "how do we do microservices in Node.js?" is: one message broker, a framework that owns the distributed-systems layer, and processes you start with a command you already know. Start as a modular monolith, split when a boundary is real, scale by launching another copy. Get to Kubernetes when Kubernetes solves a problem you actually have.
All the code above runs as shown against Moleculer 0.15 on Node.js 22. The full example project is at github.com/moleculerjs/moleculer-examples — docs at moleculer.services.
(0)Comments