DEV Community

Cover image for 2. Designing a microservice Architecture
Ashutosh Sahu
Ashutosh Sahu

Posted on

1

2. Designing a microservice Architecture

In our last article, we have seen the differences between Microservices and monoliths, advantages, and common challenges with both architectures. In this article, we are going to dive more into how to design a microservice architecture using a Food delivery application as an example.
There are certain characteristic principles of microservices that serve as a rule set and help us tackle some of the challenges faced in microservices. Beware that these characteristics break some conventions and principles that were followed previously in software engineering. Let's take a look at them.

Characteristics to be Present in a Micro Architecture

Single responsibility principle

This principle helps very much in deciding what our architecture should look like. According to the single responsibility principle, microservice should be focused on a single business capability or function that it provides. We should avoid creating services that have multiple or unclear responsibilities. We should also avoid creating services that duplicate or overlap with each other's functionality.

The problem with this rule is it's hard to follow. As a general rule, we should not create a service that is too small or too large. This conflicts with the Single responsibility principle and makes us ask how small or how big a service should be to be able to fulfill this principle.

At the low level, there is always too much going on. A new requirement at first seems adjustable to an existing service, which also saves on the cost and time of implementation. Slowly these requirements gather up to become a large service, handling different tasks and doing too much out of the bounds breaking our single responsibility principle.

The solution is to gradually split the services as we develop new features. For example, suppose you created a single service at the beginning to handle Product inventory management and searching. It also used to store prices and related discounts, so later we started adding pricing, sales, offers, and discount handling to it. At first, all of this looked like a part of inventory management. As the features grew, the pricing logic started to become complex based on seasons, analytics, previous sales, etc. The codebase evolves around these and becomes a much bigger part of the process. Now it's time to separate Pricing into a new service.

Splitting services

One key factor while deciding on main services is that we should always keep scopes to split a service into smaller services. So that we only have to spawn a new service and move half of the functionalities to it when required, rather than ending up in a mess, where we need to redesign the whole service architecture.

The final decision on how to split into different microservices, and how big or small a service should be depends completely on your application and you. There is no hard constraint or rule for it. It's okay to compromise with some principles to gain benefits in other sectors.

One Database one service

A microservice should carry its data and manage its persistence and transactions separately. We should avoid sharing databases or tables among different services, as this can create coupling and inconsistency issues. We should also avoid using foreign key constraints or joins across different services, as this can create performance and scalability issues. This helps with scaling also, where you can scale a single database based on requirements.
Now this rule breaks the conventions of a single database where we used to rely on foreign keys and transactions to maintain consistency. we were also able to do joins on different tables under a single database to pull any kind of data we needed.

In an inconsistent microservice system, anything can go wrong. following are the most common cases:
 Case 1: you have 3 different services to manage orders, payments, and inventory. you got an order, it saved the details of the order and updated the stock in inventory, but the payment failed. Now all these services have their databases, so they all run under different transactions. Failure of payment transactions will not revert the inventory and order service transactions. So an order gets placed without a successful payment.
Case 2: Let's take the same example again, you got an order, you updated the inventory that an item is sold, and also mapped the order ID against that item. But before payment, the user canceled the order, it got soft deleted from the order service. Now the inventory has an item reserved for the order that does not exist. Foreign keys could have made handling such situations easier, by using delete cascades, but they are not available.

These cases are the most common examples of errors happening in a microservice. They are complex to handle. If we need data consistency and want to avoid such situations, we need to implement a SAGA pattern which involves coordinating the transactions in different services using either choreography or orchestration approaches to compensate for any failures or errors by undoing or reversing the previous transactions.

Similarly, if we frequently need to join queries among tables of different services, we need to use a CQRS pattern for that.
We will discuss these patterns later in more detail in our series.

Again the decision of whether to use a centralized database or go with a DB for every service is up to you. It's not completely necessary to use a database for each service. If your application is small data needs to be highly coupled or implementing SAGA or CQRS is not affordable, you can go with a centralized database. you can also go with a hybrid approach, like 5 microservices and 2 databases, 3 services are using db1 other 2 services are using db2.

Scalability

A microservice should be able to handle increasing or decreasing demand without affecting its performance or availability. We should be able to scale our services independently from each other by adding or removing instances as needed. We should also be able to scale our services across different dimensions such as load balancing, partitioning, replication, and caching. we will discuss these techniques in detail later in our series.

Loosely coupled

Microservices should be able to communicate with each other without knowing too much about their internal details or dependencies. We should use well-defined interfaces that expose only the necessary functionality and hide other implementation details. We should also use standard protocols and formats that enable interoperability and compatibility among different services.


Design Process

Now that we have understood the challenges and characteristics of a microservice architecture, let us see how we can design one for our application. The design process involves the following steps:

  1. Business Oriented division of service
  2. Bounding Contexts
  3. Selecting a communication type
  4. Selecting a database
  5. Applying Design Pattern / Scaling

Step 1. Business-oriented divisions of service

The first step is to analyze our domain and identify the business capabilities or functions that our application provides or will provide in the future. Each system is built around certain unique features. These features sometimes have the potential to completely change the architecture. To keep the system fixed within a certain architecture, It's necessary to analyze the domain.
When gathering the domain knowledge, start with building a vocabulary and language around it. When in a team, generally the discussions are verbose, not in much detail, or often referred to with different words. Words like slider, carousel, rotating view, dynamic view, and animation, are generally mixed up. We should avoid using our jargon during such discussions and use a simplified and predefined vocabulary.
Documenting/creating diagrams is good but only up to a point. UML and flowcharts are always better than explaining something in words. However, in a domain analysis, the vocabulary can grow over time. A diagram or document should be made as small as possible and should be divided into parts. In the beginning, there can be too frequent changes that will try to change most of the designs. Keeping track of all changes, and updating a big diagram/document based on it is hard. Eventually, with a single document, there comes a time when you cannot trust the document to gather information, You have to go through code and implementation to know what that piece is supposed to do.

Let's start designing the food ordering application, by creating a vocabulary. 

Vocabulary

This is just a basic vocabulary example. Notice how with the vocabulary, we fixed up a terminology. We also identified major entities (white circles), some functionalities (gray circles), and a soft relationship between them using arrows. Now we can pick each of these entities separately and identify its attributes, functionalities, and relationships. This analysis could introduce more entities as we go deeper into it. While doing so, maintain small documents dedicated to a specific entity only. 

Step 2. Bounding contexts

After we are done with domain analysis, we know well the different entities, their attributes, relationships, and functionalities in our application. Now we can define bounded contexts. Bounded contexts are a grouping of related subdomains that share a common language and model. When bounding contexts, start with smaller groups of say 2 or 3 contexts. Pick an entity and assign it to a context based on the functionalities. As you start doing this, you will see there is a possibility to introduce a new context or split an existing one. If you are in doubt about whether to allow this new context in the system, then just don't add it now, but always make enough room so that it can be easily added in the future. Then assign a microservice to each context. When doing this, keep in check the characteristics that we went through earlier. Ask yourself, Is your microservice going to be scalable? Does it follow the single responsibility principle? Restructure it if you are in doubt. These questions will help you through a raw High-level design of the system.


Let's start with 3 contexts for our food-ordering app
User Context 

  • add customer, restaurant and Delivery Partner,  - manage Profile  - authentication/authorization  - restaurant menu/inventory CRUD Ordering Context  - Maintaining a Cart 
  • Order food  - Payment confirmation  - Cancel Order  - Refund/Cashback  - Delivery Partner / Dish Rating Tracking and Navigation Context  - Assign a Delivery Partner to Order
  • Track Order for customer  - Navigator for Delivery Partner.

Step 3. Selecting the Communication type

The next step is to choose the communication model for our microservices. There are two main types of communication models: synchronous and asynchronous

Synchronous Communication

Synchronous communication is a real-time communication model, where the sender and the receiver are both active and available at the same time. Synchronous communication is usually implemented using request/response-based protocols, such as HTTP, over TCP/IP or UDP.

TCP/IP: This protocol is used where reliability and consistency are important. In this, each data packet sent receives an acknowledgment. It also maintains the order in which data is sent. This makes it consistent and slow. It's widely used for most of the API calls and is the default protocol in a NodeJS server.
 
 UDP: This protocol is used for creating custom messaging protocols. This doesn't guarantee delivery and order of delivery of the data. That's why it is fast but also lossy and inconsistent. It's suitable for situations where it's okay to lose data, like a video or audio live stream where ensuring previous data is transferred is not needed. We constantly need to send real-time data only. NodeJS provides dgram module for creating a UDP server.

REST (Representational State Transfer): REST is a widely used architectural style for designing web APIs, based on the principles of statelessness, uniform interface, and resource orientation. REST uses HTTP/1.1 with verbs (GET, POST, PUT, DELETE, etc.) to perform operations on resources, identified by URIs, and exchanges data in various formats, such as JSON, XML, or plain text.

gRPC (Remote Procedure Call): gRPC is a high-performance, open-source framework for RPC communication, developed by Google. gRPC uses HTTP/2 as the underlying transport layer, and protocol buffers as the default data serialization format. Protocol buffers are binary, compact, and schema-based messages, defined by .proto files, that can be generated into native code for various languages and platforms. They are faster than REST and are preferred for inter-service communications.

Generally, you should use REST for client-to-service communications and gRPC for service-to-service communications. Use synchronous communications where the user expects an immediate response or feedback from the system, such as logging in, placing an order, or doing data queries such as retrieving a product detail, a customer profile, or a report.

Asynchronous Communication

Asynchronous communication is a non-blocking communication model, where the sender and the receiver are not required to be active and available at the same time. The sender sends a message and continues with its task, without waiting for the response from the receiver. The receiver processes the message and sends back the response whenever it is ready. Asynchronous communication is usually implemented using message-based protocols, such as AMQP, MQTT, or STOMP, over TCP/IP.

Some of the common asynchronous communication protocols for microservices are:
Message queue: A message queue is a data structure that stores and transmits messages between the sender and the receiver, using a FIFO (first-in, first-out) or a priority-based order. A message queue acts as a buffer and a mediator, decoupling the sender and the receiver, and ensuring reliable and durable delivery of the messages. A message queue can have one or more producers and consumers, but each message is delivered to exactly one consumer. Some examples of message queue technologies are Amazon SQS, RabbitMQ, ActiveMQ, and Azure Service Bus.

Event stream: An event stream is a data structure that stores and transmits messages between the sender and the receiver, using an append-only and immutable order. An event stream acts as a log and a source of truth, capturing the history and the state of the system, and enabling event-driven communication. An event stream can have one or more producers and consumers, and messages are available for all consumers. Some examples of event stream technologies are Apache Kafka, Apache Pulsar, and Amazon Kinesis.

There can be three types of routing strategies/exchanges involved in a message queue: Direct, Fan-out, and Topic.

Direct
  • The strategy routes the messages based on the routing keys. For example, a direct exchange can bind the queue "order-service" to the routing key "order.created", and send the messages with that routing key to that queue.

  • It has the advantages of simplicity and efficiency, as it delivers the messages to the exact consumers that need them.

  • It has the disadvantages of rigidity and redundancy, as it requires the producers and the consumers to agree on the routing keys, and it does not support broadcasting of the messages.

Fanout
  • Fanout routing is a strategy that delivers a message to all the consumers who are subscribed to the message queue.

  • The fanout exchange sends the messages to all the queues regardless of their routing keys. For example, a fanout exchange can send the same message to the queues "order-service", "inventory-service", and "notification-service", regardless of their routing keys.

  • It has the advantages of flexibility and scalability, as it allows the producers and the consumers to be loosely coupled, and it supports the broadcasting of the messages.

  • It also has the disadvantages of inefficiency and waste, as it delivers the messages to the consumers that may not need them, and it consumes more network and system resources.

Topic
  • Topic routing is a strategy that delivers a message to multiple consumers that match the topic of the message. The topic is a string that consists of words separated by dots, and it can represent a hierarchy or a category of the message. For example, a message with the topic "order.created.usa" can represent an order creation event that occurred in the USA.

  • The topic exchange binds the queues to the topics and sends the messages to the queues that have the same or a subset of the topic as the message. The topic exchange can also use wildcards to match the topics, such as an empty string to match any single word, and a "#" to match any number of words. For example, a topic exchange can bind the queue "order-service" to the topic "order.", and send the messages with the topics "order.created", "order.updated", and "order.deleted" to that queue.

  • It has the advantages of versatility and granularity, as it allows the producers and the consumers to use different levels of abstraction and specificity for the messages, and it supports filtering and grouping of the messages.

  • It also has the disadvantages of complexity and ambiguity, as it requires the producers and the consumers to understand and follow the topic conventions, and it may cause conflicts or overlaps of the topics.

Asynchronous communication is suitable in cases like notifying a change in a system such as a new order, status update, or payment confirmation. they can also be used between microservices to perform actions like processing some tasks, updating the database, and sending async reports via mail based on events/messages. Asynchronous communication can ensure eventual consistency and fault tolerance, where the slow receiver can catch up with the fast sender, even if there are some delays or failures in the communication.


In the next article, we will discuss selecting the database for each of our microservices and applying some design patterns and techniques for better scaling.

Hostinger image

Get n8n VPS hosting 3x cheaper than a cloud solution

Get fast, easy, secure n8n VPS hosting from $4.99/mo at Hostinger. Automate any workflow using a pre-installed n8n application and no-code customization.

Start now

Top comments (1)

Collapse
 
mathias_fernandes_0e110b7 profile image
Mathias Fernandes

Amaizing article. Please post the next one, I beg you.

Billboard image

The Next Generation Developer Platform

Coherence is the first Platform-as-a-Service you can control. Unlike "black-box" platforms that are opinionated about the infra you can deploy, Coherence is powered by CNC, the open-source IaC framework, which offers limitless customization.

Learn more