All posts

Rethinking Microservices: Using NATS to Dramatically Simplify Your Microservices

Rahul Rai
Jul 11, 2024Guest Post
Rethinking Microservices: Using NATS to Dramatically Simplify Your Microservices

Microservice architecture breaks applications into smaller, independent services that communicate through lightweight protocols like HTTP and gRPC. These services are built around specific business capabilities or domains, such as user management, product cataloging, or order processing. Following domain-driven design (DDD) principles, each service can be independently deployed using a fully automated pipeline, enabling faster and more efficient development and deployment of complex applications.

While microservices offer significant advantages, they can be complex to manage. Development teams need a broader skill set to handle configurations, IP addresses, deployments, and traffic routing. Monitoring, scaling, and fault tolerance become more challenging across numerous services, and increased communication can lead to network congestion. Adhering to the twelve-factor app methodology also adds another layer of complexity, as you have to maintain strict separation between configuration between code and treat logs as event streams. Traditionally, addressing these challenges required third-party solutions, leading to increased costs and maintenance overhead.

This article covers the challenges associated with the traditional microservice architecture and how NATS solves them with a streamlined, efficient approach to building a robust microservices backbone.

Typical Microservice Architecture

Microservices is an architectural style that structures an application as a collection of small autonomous services modeled around a business domain. The architecture is popular due to its ability to provide a high degree of modularity that makes applications easier to develop, test, and deploy. It also allows organizations to evolve their technology stack by enabling each service to use its own distinct technology stack as appropriate.

Let's first take a look at the architecture of a typical microservices system, consisting of several loosely coupled services:

Typical microservice architecture

Each service can communicate with the others via synchronous or asynchronous protocols like HTTP or messaging queues. These are the key integration points in the architecture:

  1. Load balancer and API gateway: The load balancer distributes incoming network traffic across multiple servers, ensuring the application's high availability and reliability. The API gateway provides a unified interface for clients to access various services in the microservices system. It also provides additional functionalities like authentication, rate limiting, and caching.
  2. Cache, service registry, and logs: The cache stores frequently accessed data to reduce the load on the database. It can be used by both the API gateway and the services. The service registry is used to store the IP addresses and ports of the services. On startup, each service registers itself with the service registry. The API gateway and other services can then resolve the destination of a request by querying the service registry. The logs are used to store information generated by the services and can be used for debugging and monitoring purposes.
  3. Synchronous and asynchronous communication: Microservices communicate with one another using synchronous protocols like HTTP or asynchronous mediums like messaging queues.
  4. Services and databases: Microservices typically follow the database-per-service pattern, which enables each service to store its state independently of the others.

Building and maintaining a microservices system with third-party services introduces several challenges. The complexity of managing multiple components, increased failure points, domains and scenarios, and operational costs require additional skills to handle the system effectively. Traditional microservice architectures also struggle with addressing concerns like tracing and monitoring, as each service often has its own incompatible logging and monitoring systems. Additionally, if you want to add features such as monitoring, fault tolerance, or canary deployments, this typically requires integrating additional third-party libraries or frameworks that further complicate the system. Rethinking microservice architecture with a unified approach can help solve many of these issues. NATS provides a comprehensive solution that simplifies service communication and efficiently manages the above concerns.

Using NATS to Build a Microservices System

NATS is a powerful messaging system designed for cloud-native applications, IoT messaging, and microservice architectures. It allows secure communication across various platforms, including cloud vendors and on-premise, edge, web, and mobile devices. NATS consists of a family of open source products that are tightly integrated yet can be deployed independently. In addition to messaging, it offers real-time data streaming, resilient data storage, and flexible data retrieval through its JetStream platform. Incorporating NATS in your architecture is easy, as it provides official clients in multiple popular programming languages and can be deployed on VMs or containers.

The following diagram illustrates how NATS can be used to build a microservices system that offers the same functionalities as the traditional microservice architecture but with less complexity and overhead:

Microservice architecture with NATS

In this architecture, the services communicate with one another either synchronously or asynchronously, using NATS as the messaging backbone. NATS also replaces the individual databases with durable storage provided by JetStream. Additionally, NATS handles load balancing, service discovery, caching, monitoring, and more, making it easier to build and maintain the microservices system. This allows developers to focus on building the business logic of the services without worrying about these concerns, leading to faster development cycles and more reliable microservices systems.

To illustrate how this approach applies in a real-world scenario, consider Netflix. The company famously implemented a microservice architecture to handle its vast and varied functionality, which ranges from user account management to streaming content delivery. It uses a combination of third-party solutions and custom-built tools to manage its microservices ecosystem. For instance, Netflix uses:

  1. Zuul for its API gateway: This handles routing, monitoring, resiliency, security, and more.
  2. Eureka for service discovery: This helps manage the instances of services and ensures that traffic is directed to healthy instances.
  3. Hystrix for fault tolerance: This handles latency and fault tolerance logic for communication between microservices.
  4. Ribbon for load balancing: This provides client-side load balancing.

While this approach works, it introduces a significant level of complexity and requires a lot of communication between these tools. Implementing a similar system using NATS could streamline these components by providing a unified backbone for messaging and more. NATS reduces the need for multiple, potentially complex, third-party tools. It can handle service discovery, load balancing, and fault tolerance in a more integrated manner, reducing operational overhead and simplifying the architecture.

The rest of the tutorial uses an example app called "Vote App" (a simple app that allows a user to vote for candidates) to demonstrate NATS in action. While the application uses synchronous and asynchronous communication through NATS with client libraries, you'll communicate with the app using the NATS CLI. The CLI exposes all the functionalities of the NATS server, including publishing and subscribing to messages, managing streams and objects, and monitoring the server through various commands.

Example App Structure with NATS

Vote App high-level design

Vote App allows users to vote for one of several candidates and consists of three services:

  • Voting Service: This service accepts votes from users and consists of the following message subscriber (analogous to an endpoint in a REST API):
    • vote.send.*, which accepts a vote from the user using the request-reply pattern for a valid candidate. Once the vote is validated, the subscriber sends it to the NATS subject vote.save for asynchronous processing by the Vote Processor service. It retrieves the candidate names from Candidate Service using the NATS request-reply pattern.
  • Candidate Service: This service stores and manages the candidate names in the NATS key-value store. It consists of one message subscriber:
    • candidate.get, which retrieves the candidate names from the NATS key-value store and sends them to the requester using the request-reply pattern.
  • Vote Processor: This service receives valid votes from Voting Service and updates the vote count for the candidate. It stores the vote data in the NATS object store and consists of the following two message subscribers:
    • vote.save.*, which saves the vote count in the NATS object store.
    • vote.get, which provides the current vote count for all candidates to the requester using the request-reply pattern.

The following diagram illustrates how the services in Vote App communicate with one another using NATS:

Vote App sequence diagram

Note that Vote App uses NATS for both synchronous and asynchronous communication between the services. Since the individual services do not need to use any communication protocol, they are also deployed as services and can be scaled horizontally to handle high loads. None of the services require inbound network access, which makes them watertight and secure.

Getting Started

Vote App is a simple .NET-based microservices application. Please prepare your environment by installing the following tools:

  1. .NET SDK 8.0 or later
  2. NATS Server
  3. NATS .NET Client
  4. The NATS CLI
  5. An IDE of your choice. If you don't have a preference, Visual Studio Code with the C# Dev Kit extension is a good choice.

Setting Up NATS Server

For this project, you'll use NATS Server with JetStream enabled. JetStream is a persistence layer for NATS that provides capabilities to store and retrieve messages via streams and objects. The KV and Object Store are abstractions over a stream. To start a NATS Server with JetStream enabled, run the following command:

Terminal window
nats-server --jetstream

This will start NATS Server with JetStream on the default port 4222.

Developing the Voting Service

Start with creating a new solution file for the project in a folder named VoteApp using the following commands:

Terminal window
mkdir VoteApp
cd VoteApp
dotnet new sln --name NATSDemo

In the solution, create a new .NET console application named VotingService and install the NATS.Net and NATS.Client.Serializers.Json NuGet packages using the following commands:

Terminal window
dotnet new console -o VotingService
dotnet sln add VotingService
cd VotingService
dotnet add package NATS.Net
dotnet add package NATS.Client.Serializers.Json

Add the following code to the Program.cs file to implement Voting Service:

Terminal window
1
using NATS.Client.Core;
2
using NATS.Client.Serializers.Json;
3
using NATS.Client.Services;
4
5
// Set the ad-hoc JSON serializer registry as the default for the connection.
6
var serializerRegistry = NatsJsonSerializerRegistry.Default;
7
8
var natsConnectionOptions = NatsOpts.Default with { SerializerRegistry = serializerRegistry };
9
10
await using var connection = new NatsConnection(natsConnectionOptions);
11
12
// Register as service with NATS
13
var svc = new NatsSvcContext(connection);
14
await using var service = await svc.AddServiceAsync(new("vote", "1.0.0")
15
{
16
Description = "Casts vote for candidate",
17
});
18
19
// Receiver for vote.send
20
await service.AddEndpointAsync<object>(HandleMessage, "send", "vote.send.*");
21
22
Console.WriteLine("Voting Service is ready.");
23
Console.ReadKey();
24
return;
25
26
async ValueTask HandleMessage(NatsSvcMsg<object> msg)
27
{
28
var candidateId = Convert.ToInt32(msg.Subject.Split('.')[^1]);
29
Console.WriteLine($"Received vote for candidate: {candidateId}");
30
31
// Retrieve the candidate IDs from the Candidate Service
32
var candidateResponse = await connection.RequestAsync<object, Dictionary<int, string>>("candidate.get", null);
33
var candidates = candidateResponse.Data?.Keys.ToList() ?? [];
34
35
// Validate the candidate ID
36
if (!candidates.Contains(candidateId))
37
{
38
await msg.ReplyAsync("Invalid candidate ID");
39
}
40
else
41
{
42
//Publish the vote to the Vote Processor service
43
await connection.PublishAsync("vote.save", candidateId);
44
await msg.ReplyAsync("Vote has been cast");
45
}
46
47
Console.WriteLine("Vote processed");
48
}

To transport JSON formatted data between services, NATS provides the NatsJsonSerializer class that uses the .NET System.Text.Json serializer to serialize and deserialize the messages. You can read more about NATS serializers in the NATS guidance.

This example uses the NATS services framework to define the listener as a service that listens for messages on the vote.send.* subject. When you use the services framework, the NATS client library automatically places all subscriptions into a queue group, which makes it easy to scale the service horizontally through built-in load-balancing and geo-affinity. You'll see how this works with the Vote Processor service later in the example. It also simplifies the discovery and monitoring of services.

The HandleMessage handler activates synchronously when it receives a vote message from the user, which you'll build using the NATS CLI later. It first reads the candidate ID that it receives from the final part of the subject. It then verifies the candidate ID by requesting the candidate list from Candidate Service via a synchronous request through the candidate.get subject. In response, it receives a list of valid candidate IDs. If the candidate ID is valid, it publishes a message to the vote.save subject to save the vote. Finally, it sends a reply to the user confirming that the vote has been cast.

Although the NATS library supports passing cancellation tokens to all NATS and JetStream operations, it has been deliberately left out to maintain the brevity of the solution. In production code, you should use cancellation tokens to ensure that operation cancellations lead to the proper disposal of connections and other shared resources.

Developing the Candidate Service

Add another .NET console application named CandidateService to the solution and install the NATS.Net and NATS.Client.Serializers.Json NuGet packages to it for messaging and serialization support.

To implement Candidate Service, add the following code to the Program.cs file:

Terminal window
1
using NATS.Client.Core;
2
using NATS.Client.JetStream;
3
using NATS.Client.KeyValueStore;
4
using NATS.Client.Serializers.Json;
5
6
// Set the ad-hoc JSON serializer registry as the default for the connection.
7
var serializerRegistry = NatsJsonSerializerRegistry.Default;
8
9
var natsConnectionOptions = NatsOpts.Default with { SerializerRegistry = serializerRegistry };
10
11
await using var connection = new NatsConnection(natsConnectionOptions);
12
13
var jsContext = new NatsJSContext(connection);
14
var kvContext = new NatsKVContext(jsContext);
15
16
// Create a KV bucket named candidates
17
var candidateStore = await kvContext.CreateStoreAsync(new NatsKVConfig("candidates"));
18
19
// Add seed data to the candidates bucket
20
await candidateStore.PutAsync("1", "Cat");
21
await candidateStore.PutAsync("2", "Dog");
22
await candidateStore.PutAsync("3", "Fish");
23
24
Console.WriteLine("Candidate Service is ready.");
25
26
// Receiver for candidate.get
27
await foreach (var message in connection.SubscribeAsync<string>("candidate.get"))
28
{
29
Console.WriteLine("Received candidate fetch request");
30
31
// Retrieve the candidates from the KV store
32
var candidateList = new Dictionary<int, string>();
33
await foreach (var key in candidateStore.GetKeysAsync())
34
{
35
candidateList.Add(Convert.ToInt32(key), (await candidateStore.GetEntryAsync<string>(key)).Value!);
36
}
37
38
// Send the candidate list as a response
39
await message.ReplyAsync(candidateList);
40
Console.WriteLine("Request processed");
41
}

Candidate Service uses JetStream to store the candidate names in a key-value store named candidates. It first adds seed data to the key-value store and then subscribes to the candidate.get subject to receive requests for the candidate list. When it receives a request, it retrieves the candidate list from the key-value store and sends it back as a JSON response.

Developing the Vote Processor Service

Finally, create a third .NET console application named VoteProcessorService and install the NATS.Net and NATS.Client.Serializers.Json NuGet packages to it.

Add the following code to the Program.cs file to implement the Vote Processor service:

Terminal window
1
using NATS.Client.Serializers.Json;
2
using NATS.Client.Core;
3
using NATS.Client.JetStream;
4
using NATS.Client.ObjectStore;
5
6
var semaphore = new SemaphoreSlim(1, 1);
7
8
await using var connection = new NatsConnection();
9
10
var jsContext = new NatsJSContext(connection);
11
var objectStoreContext = new NatsObjContext(jsContext);
12
13
// Create an object store named votes
14
var voteStore = await objectStoreContext.CreateObjectStoreAsync("votes");
15
16
// Create two receivers for vote.save to demonstrate load balancing between multiple instances
17
var voteResponder1 = VoteResponder("vote.save", "group1", voteStore, 1);
18
var voteResponder2 = VoteResponder("vote.save", "group1", voteStore, 2);
19
20
// Receiver for vote.get
21
var voteGetResponder = Task.Run(async () =>
22
{
23
await foreach (var msg in connection.SubscribeAsync<string>("vote.get"))
24
{
25
Console.WriteLine("Received candidate fetch request");
26
var candidateVotes = new Dictionary<string, int>();
27
28
// Fetch the votes from the vote store
29
await foreach (var item in voteStore.ListAsync())
30
{
31
candidateVotes.Add($"Candidate-{Convert.ToInt32(item.Name)}",
32
BitConverter.ToInt32(await voteStore.GetBytesAsync(item.Name)));
33
}
34
35
// Serialize the candidate votes to JSON using NatsJsonContextSerializer
36
await msg.ReplyAsync(candidateVotes,
37
serializer: NatsJsonSerializer<Dictionary<string, int>>.Default);
38
Console.WriteLine("Request processed");
39
}
40
});
41
42
Console.WriteLine("Vote Processor Service is ready.");
43
await Task.WhenAll(voteResponder1, voteResponder2, voteGetResponder);
44
return;
45
46
Task VoteResponder(string subject, string queue, INatsObjStore objectStore, int consumerId)
47
{
48
var task = Task.Run(async () =>
49
{
50
await foreach (var msg in connection.SubscribeAsync<int>(subject, queue))
51
{
52
var candidateId = msg.Data;
53
Console.WriteLine($"Processor {consumerId}: Storing vote for candidate: {candidateId}");
54
55
try
56
{
57
// Acquire lock to ensure thread safety when updating the vote count
58
await semaphore.WaitAsync();
59
60
// Increment the vote count for the candidate
61
var dataBytes = await objectStore.GetBytesAsync(candidateId.ToString());
62
var voteCount = BitConverter.ToInt32(dataBytes);
63
voteCount++;
64
await objectStore.PutAsync(candidateId.ToString(), BitConverter.GetBytes(voteCount));
65
}
66
catch (NatsObjNotFoundException)
67
{
68
// If candidate record does not exist in the store, create it
69
await objectStore.PutAsync(candidateId.ToString(), BitConverter.GetBytes(1));
70
}
71
finally
72
{
73
semaphore.Release();
74
}
75
76
Console.WriteLine($"Processor {consumerId}: Vote saved");
77
}
78
});
79
80
return task;
81
}

The Vote Processor service primarily completes two tasks:

  1. Stores vote counts for each candidate in the NATS object store named votes
  2. Responds to requests for the current vote count for all candidates

The request-reply pattern used with the vote.get subject is the same as the one used in Candidate Service, so you'll focus on the data storage aspect. You also created two receivers for the vote.save subject, which will later allow you to see how NATS handles load balancing between multiple instances of the same service. To enable load balancing, NATS requires the receivers to be part of the same queue group, which is specified as the second argument to the SubscribeAsync method. In this case, each receiver will randomly receive messages from the vote.save subject.

The VoteResponder method is responsible for storing the vote count for each candidate in the object store. It uses a semaphore to ensure thread safety when updating the vote count. In production, you should consider using a distributed lock or NATS optimistic concurrency control to handle concurrency issues.

Executing Vote App and Exploring How NATS Reduces Microservice Communications

To run Vote App, start Voting Service, Candidate Service, and the Vote Processor service in separate terminal windows using the following commands:

Terminal window
dotnet run --project VotingService
dotnet run --project CandidateService
dotnet run --project VoteProcessorService

You'll now use the NATS CLI to interact with the services and explore some common features of NATS while interacting with Vote App.

NATS as an API Gateway

In the example application, each service is responsible for a specific set of functionalities. NATS acts as an API gateway by abstracting the location of the services from the clients and providing a unified interface for communication. This feature helps reduce overhead in communication by eliminating the need for a third-party API gateway in your architecture.

Execute the following command to send ten votes for candidate 1, which will be served by Voting Service:

Terminal window
nats request --count 10 --reply-timeout=1000ms vote.send.1 ''

The following screenshot shows the output of the command. You can also inspect the console messages of the various services to see how the votes are processed:

Output of command

You can retrieve the candidate list from Candidate Service using the following command:

Terminal window
nats request candidate.get ''

The following screenshot shows the output of the command, which lists the candidate names and their IDs:

Output of command

Use the vote.get command to cast some votes for other candidates. Finally, retrieve the current vote count for all candidates using the following command:

Terminal window
nats request vote.get ''

The following screenshot shows the output of the command, which lists the candidate IDs and their corresponding vote counts:

Output of command

NATS as a Load Balancer

NATS provides distributed load balancing by distributing messages across multiple subscribers to a subject in a queue group. In the example application, you created two receivers for the vote.save subject in the Vote Processor service. Inspect the console messages of the Vote Processor service to see how NATS distributed the incoming messages between the two receivers:

Console messages from the Vote Processor service

NATS as a Service Discovery Tool

You exposed the services of Vote App using a common subject prefix (vote). You can use the subject naming convention to segregate the services of different applications and enable communication between clients and services (North-South communication) and between services (East-West communication):

Message routing by subject

NATS as a Monitoring and Observability Service

The NATS services framework provides built-in monitoring and observability features. In the example, Voting Service is registered as a service with NATS, and you can use this command to monitor it:

Terminal window
nats micro stats vote

The following screenshot shows the output of the command. It provides information about the service, like the number of messages processed, the number of errors, and the message processing time:

Output of command

If you use Prometheus for aggregating metrics from your microservices application, you can use the Prometheus NATS Exporter to send metrics to your desired metrics visualization service, such as Grafana. Since NATS is the medium of communication between services, it can serve traffic metrics for all services registered with NATS, saving you the effort of scraping metrics from individual services.

NATS for Canary Deployment

NATS allows you to route a subset of traffic to new versions of services during a canary deployment. You can achieve this by creating a new subject for the new version of the service and updating the clients to use the new subject. For example, to deploy a new version of Voting Service, you can create a new subject (vote.send.v2) used by the new version of the service and update NATS Server with the following configuration:

Terminal window
1
vote.send.*: [
2
{ destination: vote.send.$1, weight: 80% },
3
{ destination: vote.send.v2.$1, weight: 20% }
4
]

To learn more about canary deployments with NATS, refer to the official documentation.

NATS as a Data Store

NATS JetStream provides a lightweight data store for storing large stateful information as well as key-value data that can be used as configuration data. The example app uses the NATS object store to store the vote counts for each candidate and the key-value store to store the candidate details.

There are many more features of NATS that are applicable to a microservice architecture, such as distributed tracing, security, and fault tolerance. You can explore these features and more by referring to the code samples on the NATS by Example website.

Conclusion

Building and maintaining a microservices system with third-party services presents several challenges. Managing multiple components adds complexity, increases failure points, and raises operational costs. It also requires specialized skills to manage the system effectively. Traditional microservice architectures often struggle with issues like tracing and monitoring, as each service may have its own incompatible logging and monitoring systems. Additionally, features like monitoring, fault tolerance, or canary deployments typically involve adding more third-party libraries or frameworks, further complicating the system. NATS addresses these issues by simplifying service communication and offering built-in support for essential microservices platform features, thus reducing complexity and overhead.

Synadia, the creator of NATS, offers a fully managed cloud-based NATS service, Synadia Cloud, with features like NATS workflows, alerts, and access control. If you want more control, you can try Synadia Platform, a private cluster that can be run on-premises or in your own cloud enclave, managed by either you or Synadia. Sign up for Synadia Cloud today and experience the power of NATS without the hassle of managing the infrastructure.

You can download the source code for the sample application from GitHub.

Profile Picture

Written by Rahul Rai

Rahul is an experienced technology professional with expertise in cloud-native technologies, microservices, and cloud. He has a knack for breaking down complex technologies into accessible content that benefits both novices and seasoned professionals. You can reach out to him by visiting his blog at thecloudblog.net.