In this article, we look at four design patterns that can be used for distributed apps and microservices. We look at their individual pros and cons and illustrate these with practical use cases.
In their seminal book, Design Patterns – Elements of Reusable Object-Oriented Software, Erich Gamma, John Vlissides, Ralph Johnson, and Richard Helm, aka “the Gang of Four,” created an approach to software development that stressed interfaces over implementation as well as composition over inheritance.
In the time following the book’s original release in 1994, the book’s twenty-three patterns became known as a collection of distilled programming experiences that presented proven solutions to common problems. But recently, the change from monolithic to distributed architectures, along with the growing popularity of microservices, has prompted developers to search for new patterns to deal with this new environment.
Before we look at specific patterns, let’s define what we mean by design patterns. A design pattern represents an ideal solution to any given problem. Each pattern is distilled from practical experience that represents a set of best practices. A pattern does not describe how to implement a given solution, but it should give you everything you need to achieve the desired outcome.
In addition to preventing development teams from having to reinvent the wheel, design patterns provide a common design language and methodology to build software efficiently. To ensure that their patterns would be used to achieve the desired results, the authors of the original design pattern book grouped them into creational, structural, and behavioral patterns.
Creational patterns are used to build new objects consistently to avoid unnecessary complexity. Structural patterns let you design the underlying structure of a solution and define the relationship between each component. Behavioral patterns describe the behavior of objects and how they communicate with each other.
Command and Query Responsibility Segregation (CQRS)
The CQRS pattern is designed to separate an application’s read and write operations. In this model, commands handle writing data to persistent storage media, such as flash storage. Queries handle the task of locating and fetching, or reading, stored data without changing it.
These tasks are handled by a command service that receives requests sent by users or other applications. For each command, the service retrieves data from a data store, makes the necessary manipulations, stores the result, and notifies a read service. When a read service receives this notification, it updates the read model.
The first advantage of this approach is that it helps enforce a clear separation between business logic and validation. This helps in a microservices environment, where large numbers of processes are executing multiple tasks, and it can become easy to lose track of what each process is doing. This approach also reduces the number of unexpected and catastrophic changes to shared data.
While it does not eliminate the problem, reducing the number of entities that can simultaneously mutate data will reduce the overall possibility of loss and corruption. Also, reducing overall complexity and limiting read and write operations is both more efficient and performant.
The disadvantages of CQRS are that it is hard to manage and requires constant synchronization between command and read models.
CQRS is most ideal for data-intensive applications, such as databases and message queues. One surprising use of this pattern is for managing immutable application states, as demonstrated by ReactJS’s Redux state management framework, or database management systems.
Two-Phase Commit (2PC)
CQRS provides a solution for managing the actions of individual services, but it does not provide a means for coordinating between them. Each process will cause less harm, but there is a danger that, in aggregate, multiple services could create chaos. 2PC is a transaction management approach that is strikingly similar to CQRS but is designed to reduce the risks of system-wide anarchy.
Like CQRS, 2PC relies on a central coordinator to manage operations. But instead of separating tasks by their operations, it separates them into two phases. In the first, or Prepare phase, the coordinator requests that the services prepare data. In the second, the Commit phase, the services notify the coordinator that they are ready to commit data.
The coordinator locks the participating services, unlocks a single service, and requests that one service submit its data. If the service is unable to complete the transaction, another service is unlocked and tries to submit its data.
The coordinator repeats this process until a single process has successfully submitted data. As soon as a single service succeeds, all the services are unlocked simultaneously and wait for the coordinator to assign them a new task. The coordinator repeats this sequence as necessary.
Since 2PC is intended to only let one service operate at any given time, this approach is very resilient and produces consistent and reliable results. It is also highly scalable. Despite a very different design philosophy from CRQS, 2PC’s ability to ensure that only one service at a time can submit data provides a similar or higher level of read/write isolation. This makes 2PC ideal for applications that share data but also require isolation, such as message queues and containers.
2PC is not without its problems. For a start, it’s an asynchronous process in an increasingly asynchronous world. If the intended result is achieved on the first try, then there shouldn’t be any major issues.
But if there are multiple retries, then there is a serious risk of blocking and bottlenecks that could reduce performance. Even if everything goes according to plan, implementing 2PC requires more network resources and bandwidth than other solutions.
Saga is an asynchronous design pattern that is meant to overcome the disadvantages of synchronous patterns, such as 2PC. This design pattern uses Event Bus to communicate with microservices. This bus is used to send and receive requests between services, with each participating service creating a local transaction and emitting an event.
Other services listen for events, and the first request to intercept an event performs the required action. If a microservice fails to complete the transaction, the responsibility for completing the task is passed along to other services.
The major advantage of Saga is that it is an asynchronous pattern that is better suited for distributed systems. By removing a central coordinator to manage flow and inter-service communication, it makes it possible for individual services to handle much longer transactions. By making services autonomous, a Saga-based design will result in fewer locks and blocking.
Another major advantage of Saga is that it handles workloads that require parallel processing, higher throughput, and faster performance – all with far fewer bottlenecks. These advantages make it a perfect choice for message queues and serverless functions.
Of course, like the previous services, Saga is not without its downsides. For a start, its highly asynchronous and autonomous nature makes it difficult to tell which process is handling a task. It also requires more complex management and orchestration, which makes it difficult to troubleshoot and debug.
Yet another problem with Saga is that it does not offer the same level of data isolation provided by other patterns. Specifically, there is no way to ensure that a service is using the most current version of the available system data.
So far, the patterns we’ve reviewed have been behavioral. Let’s now look at a structural pattern. The Sidecar pattern is designed to help you build distributed software. Sidecar enables applications to be decomposed into isolated components and includes the dependencies and packages that it requires.
Unlike the other patterns in this article, Sidecars can be developed for any purpose and do not have clearly defined usage patterns, so they can provide a high level of flexibility and reuse. Another clear advantage of this pattern is that individual Sidecars can be written using the appropriate languages and relevant supporting technologies. Furthermore, once you have created and deployed a Sidecar, its underlying source code doesn’t need to be rewritten when the Sidecar is repurposed.
A less obvious benefit of Sidecars is that they can be deployed in close proximity to other containerized components, which should result in lower latency. Sidecars can be reused by other applications as well and are ideally suited for container-based systems, such as Docker.
While Sidecars are a highly flexible approach, they are better suited to large applications that require specific resources. It is not a good solution for smaller apps with more modest needs. In theory, it should also be possible to build generic Sidecars to service multiple apps and scenarios. But the temptation exists to build specialized Sidecars with specific requirements, which are much harder to scale.
A design pattern is an abstract solution that lets you hit the ground running and helps you avoid common problems. Each pattern explains how to solve a given problem but will not tell you how to build a solution.
In this article, we looked at four different patterns that will help you build distributed applications and microservices. Each of these patterns comes with its advantages and disadvantages.
CQRS manages the read/write operations of individual processes. 2PC and Saga deal with coordination and communication between services. Sidecar helps build, deploy, support, and reuse code between containerized applications.
Each pattern discussed serves different needs and solves different problems. They are based on real-world situations and have proven themselves each in their niche. Choosing any of these patterns will help you avoid unforeseen problems and errors – and help you build better-distributed apps.