Home/Blog/System Design/System Design for microservices: Scalable apps with Express.js
Home/Blog/System Design/System Design for microservices: Scalable apps with Express.js

System Design for microservices: Scalable apps with Express.js

Muaz Niazi
Oct 31, 2024
12 min read

Introduction#

Have you ever pondered the vast changes that have taken place in the realm of web services over the decades? Let’s embark on a journey together, starting from my early days at Fidelity Investments and moving towards the innovative landscape of microservices and Express.js that fuel developers’ creativity today!

The first time I used web services was probably back in 2001 while I was working at Fidelity Investments. We had a working relationship with Microsoft that had senior consultants working with us on our Marlborough campus in addition to the weekly builds of Visual Studio (VS) sent over from their VS team. In the 23 years since, a lot has changed. Things have moved on, not only in terms of technology involved, but also from the architecture perspective. So, when it all started, the focus was more on service-oriented architecture (SOA), which was more relevant to enterprise-level organizations with a centralized repository for service registration than microservices, which are more suitable for lightweight services for everyday use.

While we can never be certain what trajectory technology might take even in the near future, Express.js is one of the best choices for building microservices due to its lightweight and flexible nature, which allows developers to create robust RESTful APIs quickly and efficiently. Its minimalistic design means that developers can focus on building the core functionality of their services without being bogged down by unnecessary complexity. Additionally, Express.js supports a wide range of middleware, enabling easy integration of features such as authentication, logging, and error handling. This modular approach aligns well with the microservices architecture, where each service can be developed, deployed, and scaled independently, ensuring high performance and responsiveness in handling requests.

System Design plays a crucial role in the successful implementation of microservices since it involves defining the architecture, components, and interactions of the application. A well-thought-out System Design ensures that each microservice is not only functionally cohesive but also capable of communicating effectively with other services through APIs. It addresses key considerations such as data management, service discovery, load balancing, and fault tolerance, which are essential for building a resilient and scalable application. By leveraging Express.js within a solid System Design framework, developers can create applications that are maintainable, adaptable to changing requirements, and capable of efficiently handling varying loads, ultimately leading to a better user experience and operational efficiency.

Connecting microservices
Connecting microservices

Introduction to microservices#

Microservices are an architectural style that structures an application as a collection of loosely coupled, independently deployable services, each focused on a specific business capability. This approach allows for greater flexibility, scalability, and resilience, as each service can be developed, deployed, and maintained independently, often using different programming languages and technologies. Microservices communicate with each other through well-defined APIs, enabling teams to work on different services simultaneously and facilitating continuous integration and delivery. This modularity enhances the ability to adapt to changing requirements and improves overall system reliability.

Importance in modern app development#

Microservices architecture has become increasingly important in modern application development due to its ability to enhance scalability, flexibility, and maintainability. By breaking down applications into smaller, independent services that can be developed, deployed, and scaled individually, teams can respond more rapidly to changing business requirements and technological advancements. This modular approach allows for the use of diverse technologies and programming languages tailored to specific service needs, fostering innovation and reducing the risk of system-wide failures. Additionally, microservices facilitate continuous integration and continuous deployment (CI/CD) practices, enabling faster release cycles and improved collaboration among development teams. Overall, the adoption of microservices empowers organizations to build robust, resilient applications that can adapt to the dynamic demands of the market.

Key principles of microservices architecture#

Before we move on, let's first understand the key principles of microservices:

Single responsibility principle#

The single responsibility principle (SRP) is a fundamental concept in software design that states that a class or module should have only one reason to change, meaning it should only have one responsibility or job. This principle promotes better organization of code, making it easier to understand, maintain, and test, as each component is focused on a specific task. By adhering to SRP, developers can reduce the complexity of their systems and enhance code reusability, ultimately leading to more robust and adaptable software solutions.

Handling decentralized data: Credit Bing Designer
Handling decentralized data: Credit Bing Designer

Decentralized data management#

Decentralized data management refers to the distribution of data storage and processing across multiple locations or nodes, rather than relying on a central repository. This approach enhances data availability, resilience, and security, as it reduces the risk of a single point of failure and allows for more efficient data access and processing closer to the source. Additionally, decentralized data management can empower users with greater control over their data, fostering trust and compliance with privacy regulations while enabling collaborative data sharing across diverse stakeholders.

Independent deployment#

Independent deployment is a key principle in modern software development that allows individual components or services of an application to be deployed separately without affecting the entire system. This approach enhances agility and flexibility, enabling teams to release updates, bug fixes, or new features quickly and efficiently, thereby reducing downtime and minimizing risks associated with large-scale deployments. By facilitating independent deployment, organizations can improve their continuous integration and continuous delivery (CI/CD) processes, leading to faster innovation and a more responsive development cycle.

Cover
Microservice Architecture: Practical Implementation

Microservices are one of the most important software architecture trends, but it’s one thing to define an architecture and quite another to implement it. This course focuses on the nitty-gritty details of real-world implementation. You’ll learn recipes for tech stacks that can be used to implement microservices, as well as the pros and cons of each. You’ll start by exploring some fundamental concepts for implementing microservices. Within each concept, you’ll learn about the different technologies used to implement it. The technologies include: Frontend Integration with Edge Side Includes (ESI), asynchronous microservices with Kafka and REST feeds, synchronous microservices with the Netflix stack and Consul, Docker, Kubernetes, Cloud Foundry. Each technology you learn about is described and then demonstrated with real code. By the end of this course, you’ll be a microservice pro. Whether you’re a software engineer or an engineering manager, this course will prove useful throughout your career.

8hrs
Advanced
10 Playgrounds
71 Quizzes

Communication between services#

Communication between services in a microservices architecture is crucial for ensuring that different components can interact effectively and share data. This interaction can occur through various protocols and methods, such as RESTful APIs, message queues, or gRPC, allowing services to exchange information asynchronously or synchronously based on their needs. Effective communication strategies, including service discovery and load balancing, are essential for maintaining system performance, reliability, and scalability as the number of services grows.

Designing microservices
Designing microservices

Designing microservices with Express.js#

Identifying microservices#

Domain-driven design (DDD) approach#

The domain-driven design (DDD) approach focuses on understanding the core business domain and its complexities to identify microservices. By modeling the domain into bounded contexts, teams can define clear service boundaries that align with business capabilities, ensuring that each microservice addresses a specific aspect of the domain.

Decomposing monolithic applications#

Decomposing monolithic applications involves breaking down a large, tightly coupled system into smaller, manageable microservices. This process typically starts by analyzing the existing application to identify distinct functionalities and dependencies, allowing teams to gradually extract services that can be developed, deployed, and scaled independently, ultimately enhancing agility and maintainability.

Structuring an Express.js application#

Directory structure#

A well-organized directory structure is essential for maintaining clarity and scalability in an Express.js application. A common structure includes folders for routes, controllers, models, middleware, and views, allowing developers to easily locate and manage different components of the application. For example, a typical structure might look like this:

/my-app
/controllers
/models
/routes
/middleware
/views
/config
/public
app.js
Modularizing code#

Modularizing code in an Express.js application involves breaking down the application into smaller, reusable components or modules. This can be achieved by creating separate files for routes, controllers, and middleware, which helps to keep the codebase organized and promotes separation of concerns. By using modules, developers can enhance maintainability, facilitate testing, and enable easier collaboration among team members, as each module can be developed and updated independently.

Creating RESTful APIs with Express.js#

Setting up routes#

Setting up routes in an Express.js application is the first step in creating a RESTful API. Routes define the endpoints that clients can interact with, using HTTP methods such as GET, POST, PUT, and DELETE to perform various operations. By organizing routes in a dedicated file or folder, developers can maintain clarity and structure, allowing for easy management of the API’s endpoints. For example, a simple route setup might look like this:

const express = require('express');
const router = express.Router();
router.get('/items', itemController.getAllItems);
router.post('/items', itemController.createItem);
router.put('/items/:id', itemController.updateItem);
router.delete('/items/:id', itemController.deleteItem);
module.exports = router;
Handling requests and responses#

Handling requests and responses is crucial for building a functional RESTful API with Express.js. When a client makes a request to a defined route, the corresponding controller function processes the request, interacts with the database if necessary, and sends an appropriate response back to the client. This involves parsing incoming data, validating it, and formatting the response, typically in JSON format, to ensure that clients receive the information they need in a structured manner. For example:

app.get('/items', (req, res) => {
Item.find({}, (err, items) => {
if (err) return res.status(500).json({ error: err.message });
res.status(200).json(items);
});
});

Service communication#

Synchronous communication (HTTP/REST)#

Pros and cons#

Synchronous communication, such as that facilitated by HTTP/REST, allows clients to send requests and wait for responses in real-time, making it straightforward to implement and understand. Pros include simplicity in design, ease of debugging, and the ability to leverage existing web standards and tools. However, cons include potential latency issues, as clients must wait for responses, which can lead to performance bottlenecks, especially in high-load scenarios. Additionally, synchronous communication can be less resilient to failures, as a single service outage can disrupt the entire flow of requests.

Implementing with Express.js#

Implementing synchronous communication (HTTP/REST) with Express.js

Synchronous communication, also known as request-response architecture, is a common pattern for building web applications. In this approach, a client sends a request to a server, and the server responds with the requested data. The client waits for the response before proceeding with further actions.

HTTP/REST

HTTP (Hypertext Transfer Protocol) is a widely used protocol for synchronous communication. REST (Representational State of Resource) is an architectural style that builds on top of HTTP to provide a standardized way of interacting with resources.

Express.js

Express.js is a popular Node.js web framework for building web applications. It provides a flexible and modular way to handle HTTP requests and responses.

Example: Building a Simple RESTful API with Express.js

Here’s an example of building a simple RESTful API with Express.js to manage books:

const express = require('express');
const app = express();
// Middleware to parse JSON requests
app.use(express.json());
// In-memory data store for books
const books = [
{ id: 1, title: 'Book 1', author: 'Author 1' },
{ id: 2, title: 'Book 2', author: 'Author 2' },
];
// GET /books - Retrieve all books
app.get('/books', (req, res) => {
res.json(books);
});
// GET /books/:id - Retrieve a book by ID
app.get('/books/:id', (req, res) => {
const id = parseInt(req.params.id);
const book = books.find((book) => book.id === id);
if (!book) {
res.status(404).json({ error: 'Book not found' });
} else {
res.json(book);
}
});
// POST /books - Create a new book
app.post('/books', (req, res) => {
const book = req.body;
books.push(book);
res.json(book);
});
// PUT /books/:id - Update a book
app.put('/books/:id', (req, res) => {
const id = parseInt(req.params.id);
const book = books.find((book) => book.id === id);
if (!book) {
res.status(404).json({ error: 'Book not found' });
} else {
Object.assign(book, req.body);
res.json(book);
}
});
// DELETE /books/:id - Delete a book
app.delete('/books/:id', (req, res) => {
const id = parseInt(req.params.id);
const index = books.findIndex((book) => book.id === id);
if (index === -1) {
res.status(404).json({ error: 'Book not found' });
} else {
books.splice(index, 1);
res.json({ message: 'Book deleted' });
}
});
app.listen(3000, () => {
console.log('Server listening on port 3000');
});

This example demonstrates how to create a simple RESTful API with Express.js to manage books. The API supports CRUD (Create, Read, Update, Delete) operations for books.

Testing the API

We can test the API using tools like curl or a REST client like Postman. Here are some examples:

  • GET /books: Retrieve all books

  • GET /books/1: Retrieve a book by ID

  • POST /books: Create a new book

  • PUT /books/1: Update a book

  • DELETE /books/1: Delete a book

Asynchronous communication (message queues)#

Overview of message brokers (e.g., RabbitMQ, Kafka)#

Message brokers, such as RabbitMQ and Apache Kafka, facilitate asynchronous communication between services by allowing them to send and receive messages without requiring an immediate response. These brokers act as intermediaries that store messages in queues, enabling producers to send messages at their own pace while consumers can process them independently. RabbitMQ is known for its ease of use and support for various messaging patterns, while Kafka excels in handling high-throughput data streams and providing durability and scalability. This decoupling of services enhances system resilience, as services can continue to operate even if one or more components are temporarily unavailable.

Integrating with Express.js#

Integrating message brokers with Express.js

Message brokers are specialized software that enable asynchronous communication between different components of a system. They provide a way to decouple producers and consumers of messages, allowing them to operate independently and improving the overall scalability and reliability of the system.

Why integrate message brokers with Express.js?

Integrating message brokers with Express.js can help to:

  1. Decouple components: By using a message broker, we can decouple the components of our system, allowing them to operate independently and improving the overall scalability and reliability of the system.

  2. Improve performance: Message brokers can help to improve the performance of our system by allowing components to operate asynchronously and reducing the load on individual components.

  3. Provide fault tolerance: Message brokers can provide fault tolerance by allowing messages to be stored and forwarded even if one or more components of the system are unavailable.

Popular message brokers

Some popular message brokers that can be integrated with Express.js include:

  1. RabbitMQ: RabbitMQ is a popular open-source message broker that supports multiple messaging patterns, including request/reply, publish/subscribe, and message queuing.

  2. Apache Kafka: Apache Kafka is a distributed streaming platform that provides high-throughput and fault-tolerant messaging capabilities.

  3. Amazon SQS: Amazon SQS is a fully managed message queuing service that provides a scalable and reliable way to decouple components of a system.

Integrating RabbitMQ with Express.js

Here’s an example of how to integrate RabbitMQ with Express.js:

const express = require('express');
const amqp = require('amqplib');
const app = express();
// Connect to RabbitMQ
const rabbitUrl = 'amqp://localhost';
const queueName = 'my_queue';
async function sendMessage(message) {
const connection = await amqp.connect(rabbitUrl);
const channel = await connection.createChannel();
await channel.assertQueue(queueName, { durable: true });
channel.sendToQueue(queueName, Buffer.from(message));
console.log(`Sent message: ${message}`);
}
app.post('/send', (req, res) => {
const message = req.body.message;
sendMessage(message);
res.send(`Message sent: ${message}`);
});
// Consume messages from the queue
async function consumeMessages() {
const connection = await amqp.connect(rabbitUrl);
const channel = await connection.createChannel();
await channel.assertQueue(queueName, { durable: true });
channel.consume(queueName, (msg) => {
if (msg !== null) {
console.log(`Received message: ${msg.content.toString()}`);
channel.ack(msg);
}
});
}
consumeMessages();

This example demonstrates how to send and receive messages using RabbitMQ with Express.js.

Integrating Apache Kafka with Express.js

Here’s an example of how to integrate Apache Kafka with Express.js:

const express = require('express');
const kafka = require('kafka-node');
const app = express();
// Connect to Kafka
const kafkaClient = new kafka.KafkaClient({
kafkaHost: 'localhost:9092',
});
const producer = new kafka.Producer(kafkaClient);
async function sendMessage(message) {
producer.send(
[
{
topic: 'my_topic',
messages: [message],
},
],
(err, data) => {
if (err) {
console.error(err);
} else {
console.log(`Sent message: ${message}`);
}
}
);
}
app.post('/send', (req, res) => {
const message = req.body.message;
sendMessage(message);
res.send(`Message sent: ${message}`);
});
// Consume messages from the topic
const consumer = new kafka.Consumer(kafkaClient, [
{ topic: 'my_topic' },
]);
consumer.on('message', (message) => {
console.log(`Received message: ${message.value}`);
});

This example demonstrates how to send and receive messages using Apache Kafka with Express.js.

Integrating Amazon SQS with Express.js

Here’s an example of how to integrate Amazon SQS with Express.js:

const express = require('express');
const AWS = require('aws-sdk');
const app = express();
// Connect to Amazon SQS
const sqs = new AWS.SQS({
region: 'us-east-1',
accessKeyId: 'YOUR_ACCESS_KEY_ID',
secretAccessKey: 'YOUR_SECRET_ACCESS_KEY',
});
async function sendMessage(message) {
const params = {
MessageBody: message,
QueueUrl: 'https://sqs.us-east-1.amazonaws.com/123456789012/my_queue',
};
sqs.sendMessage(params, (err, data) => {
if (err) {
console.error(err);
} else {
console.log(`Sent message: ${message}`);
}
});
}
app.post('/send', (req, res) => {
const message = req.body.message;
sendMessage(message);
res.send(`Message sent: ${message}`);
});
// Consume messages from the queue
async function consumeMessages() {
const params = {
QueueUrl: 'https://sqs.us-east-1.amazonaws.com/123456789012/my_queue',
MaxNumberOfMessages: 10,
};
sqs.receiveMessage(params, (err, data) => {
if (err) {
console.error(err);
} else {
data.Messages.forEach((message) => {
console.log(`Received message: ${message.Body}`);
});
}
});
}
consumeMessages();

This example demonstrates how to send and receive messages using Amazon SQS with Express.js.

Express.js for handling scalability challenges in microservices#

Now that we have looked at how to build microservices and their relationship with System Design, we also need to take a look at a particular challenge from the System Design perspective–namely, scalability challenges in microservices and how Express.js can be used to mitigate them.

There are several challenges associated with scalability in microservices architecture that can negatively impact the performance and efficiency of applications. A primary obstacle is the effective management of the complexity that emerges when dealing with multiple independent services. With an increasing number of microservices, the communication between them becomes more burdensome, resulting in delays and a higher probability of failures. Guaranteeing consistent data management across distributed services can be problematic because each service typically operates with its own database, creating the possibility of encountering data synchronization complications. Deploying and monitoring numerous microservices can create complexities in the operational landscape, thus necessitating the implementation of advanced orchestration and management tools to effectively maintain the health and performance of the services.

Express.js can effectively address many of these scalability challenges by providing a lightweight and flexible framework for building microservices. Its minimalist design allows developers to create small, focused services that can be easily deployed and scaled independently. Express.js supports asynchronous programming, which helps in handling multiple requests concurrently, thereby improving the responsiveness of services under load. Additionally, its middleware architecture enables developers to implement features like logging, authentication, and error handling in a modular way, simplifying the management of complex service interactions. By leveraging Express.js, teams can build robust microservices that are easier to scale, maintain, and integrate, ultimately enhancing the overall performance of the application.

Conclusion#

In this blog, we covered how System Design and scalability are needed for microservices and how we can use a framework such as Express.js to develop them easily. 

To learn more about microservices, Express.js, or System Design, check the following resources.

Frequently Asked Questions

What is microservices architecture?

  • Microservices architecture is a design approach where an application is composed of small, independent services that communicate over well-defined APIs.
  • Each service focuses on a specific business capability, allowing for modularity, scalability, and easier maintenance.

Why use Express.js for building microservices?

How do microservices improve scalability?

How do you handle inter-service communication in microservices?

What are the key components of a microservices architecture?


  

Free Resources