The most surprising thing about building microservices with Express and service discovery is that the "discovery" part often ends up being more about announcing your presence than actively finding others.
Let’s see it in action. Imagine we have two simple Express microservices: user-service and order-service.
user-service (index.js):
const express = require('express');
const app = express();
const port = 3000;
// Simulate some user data
const users = {
'123': { id: '123', name: 'Alice' },
'456': { id: '456', name: 'Bob' },
};
app.get('/users/:id', (req, res) => {
const user = users[req.params.id];
if (user) {
res.json(user);
} else {
res.status(404).send('User not found');
}
});
app.listen(port, () => {
console.log(`User service listening on port ${port}`);
});
order-service (index.js):
const express = require('express');
const app = express();
const port = 3001;
// Simulate some order data
const orders = {
'abc': { id: 'abc', userId: '123', item: 'Laptop' },
'def': { id: 'def', userId: '456', item: 'Keyboard' },
};
app.get('/orders/:id', (req, res) => {
const order = orders[req.params.id];
if (order) {
res.json(order);
} else {
res.status(404).send('Order not found');
}
});
// This is where the "discovery" magic will happen
// For now, let's just expose a health check
app.get('/health', (req, res) => {
res.status(200).send('OK');
});
app.listen(port, () => {
console.log(`Order service listening on port ${port}`);
});
Now, how do these services find each other? They don’t, not directly. Instead, they register with a central registry. A popular choice is HashiCorp Consul.
Consul Setup (Simplified):
You’d typically run Consul as a separate process or Docker container. For local development, you can download it and run consul agent -dev.
Once Consul is running, our services need to tell it they exist.
Registering user-service with Consul:
We can do this via the Consul API or by creating a configuration file. Using a config file (user-service.json):
{
"service": {
"name": "user-service",
"port": 3000,
"check": {
"http": "http://localhost:3000/health",
"interval": "10s",
"timeout": "1s"
}
}
}
And start the service with consul agent -dev -config-file=user-service.json. Consul will then poll http://localhost:3000/health every 10 seconds. If it gets a 2xx response, the service is considered healthy and discoverable.
Registering order-service with Consul:
Similarly, order-service.json:
{
"service": {
"name": "order-service",
"port": 3001,
"check": {
"http": "http://localhost:3001/health",
"interval": "10s",
"timeout": "1s"
}
}
}
Start with consul agent -dev -config-file=order-service.json.
The "Discovery" Part:
Now, order-service needs to call user-service. Instead of hardcoding http://localhost:3000, it queries Consul. A common pattern is to use a client library for Consul. In Node.js, consul is a good choice.
order-service Modified to Use Discovery:
const express = require('express');
const app = express();
const port = 3001;
const consul = require('consul')(); // Connect to Consul agent
const axios = require('axios'); // For making HTTP requests
// Simulate some order data
const orders = {
'abc': { id: 'abc', userId: '123', item: 'Laptop' },
'def': { id: 'def', userId: '456', item: 'Keyboard' },
};
// Health check endpoint
app.get('/health', (req, res) => {
res.status(200).send('OK');
});
// New endpoint to get order details including user info
app.get('/orders/:id', async (req, res) => {
const order = orders[req.params.id];
if (!order) {
return res.status(404).send('Order not found');
}
try {
// 1. Query Consul for the user-service
const users = await consul.service.lookup('user-service');
if (!users || users.length === 0) {
return res.status(503).send('User service unavailable');
}
// 2. Pick one instance (e.g., the first one)
const userServiceInstance = users[0];
const userServiceUrl = `http://${userServiceInstance.Address}:${userServiceInstance.Port}`;
// 3. Make the request to user-service
const userResponse = await axios.get(`${userServiceUrl}/users/${order.userId}`);
const user = userResponse.data;
res.json({ ...order, user });
} catch (error) {
console.error('Error fetching user service:', error.message);
res.status(500).send('Error retrieving user information');
}
});
app.listen(port, () => {
console.log(`Order service listening on port ${port}`);
});
When order-service receives a request for /orders/abc, it first asks Consul, "Hey, where can I find user-service?". Consul responds with the address and port of a healthy user-service instance (e.g., localhost:3000). order-service then uses axios to call http://localhost:3000/users/123.
The health check is crucial. If user-service crashes, Consul will mark its health check as failing. When order-service queries Consul, it won’t get back a healthy user-service instance, preventing it from sending requests to a dead service.
The core problem this solves is dynamic service location and resilience. Instead of a static configuration that breaks when a service moves or fails, services can find each other by name, and the system can automatically route around failures.
The mental model is a centralized directory service. Services "publish" their location and health, and other services "subscribe" to find them. The health check is the mechanism by which services "prove" they are still alive and capable of serving requests.
A subtle but powerful aspect is how Consul handles multiple instances of the same service. If you spin up three user-service instances, Consul will list all three. The client (order-service in this case) can then implement its own load-balancing strategy, like round-robin, by simply iterating through the list of returned service instances. This allows for horizontal scaling without any change to the service consumer.
The next concept you’ll run into is distributed tracing, which helps you follow requests as they hop between these discovered services.