Architects and their development teams will undoubtedly encounter frustrating obstacles once they implement a microservices-based architecture. While the benefits of modularity and deployment flexibility are undeniable, the cost is increased complexity when it comes to service discovery, registration, front-to-back-end integration, architecture resiliency and legacy migration.
As microservices have matured, developers have managed to create architectural design patterns that specifically target these not-so-simple issues. While they won't necessarily use every single one, architects should at least familiarize themselves with the patterns to better understand which design approach will best suit their development and application performance needs.
Let's look at 10 patterns all architects should know and understand no matter where they are in their journey toward a microservices-based architecture.
Client-side discovery and server-side discovery
When it comes to microservices, there are two main types of service discovery patterns: client-side discovery and server-side discovery.
In a client-side discovery pattern, the endpoint clients that access the application search a registry to locate the provisioning service, pinpoint an available instance, and submit their request. Once the service process terminates, the registry disposes that instance automatically. The benefit of client-side is its simplicity, speed and ability of the client to easily coordinate with the providing service. However, this creates tight coupling between client and service components, making the system rigid and difficult to update.
In a server-side discovery pattern, the microservices' clients have no knowledge of the service registry's contents, nor do they access it directly. Instead, those clients make requests through a dedicated messaging broker, such as an API gateway. As such, the client doesn't need to select the correct endpoint for a request, since the broker is already configured to do so. One of the major benefits of server-side is its decoupled nature, which means that services become much less dependent on each other. However, abstracting the service registry adds a new layer of management complexity that will require careful configuration of the message broker.
Self-registration and third-party registration
In the self-registration pattern, service instances are programmed to register their addresses in the service registry once they receive a request, and automatically remove themselves from the registry once they complete their task and return a response. The benefit is that the responding service can perform more complex operations without overburdening the registry or requesting service with state data. However, because the services must still register themselves, it requires tight coupling with the registry. On top of that, there is a risk that a service will forget to remove its listing from the registry, which can lead to mismatched data and failed requests made to unavailable services.
With third-party registration, service instances are registered and de-registered by an additional service registrar component. This registrar could be a sidecar proxy application, a parent container, or perhaps an orchestrator like Kubernetes. This approach mitigates the coupling problems associated with self-registration, and also provides a mechanism to regularly monitor for any services that are incorrectly registered. However, this approach also creates another critical component that will likely require regular maintenance and be prone to breakages. While this is an ideal approach for large-scale microservices systems, it may be overkill for small teams performing relatively simple operations.
MVC and MVVM
MVC stands for model-view-controller, and is a design pattern that creates a basic, architectural separation of concerns, particularly regarding the coupling between back-end application data and the front-end interface. It essentially creates a cyclical system, where a user interacts with the controller element, the controller passes that info to the model, and the model passes instructions to the view element accordingly. This controller abstracts application data away from the user-facing view element, so developers can make changes to visual front-end elements without putting model data at risk, since the controller can catch potential breaking changes. But while MVC does introduce a degree of modularity for the model and view elements, the controller and view are still tightly coupled, and an update to one will always require an update to the other.
MVVM, which stands for model-view-ViewModel, is another design pattern that targets the relationship between the user interface and back-end logic. Although similar to MVC, MVVM takes the concept one step further by decoupling the view and controller through a ViewModel component. Unlike the controller, the ViewModel acts as an abstracted representation of both the front end and back end of the application. Because the decoupled view and model elements can retrieve the necessary information from the ViewModel, this design style provides the modularity needed for an enterprise-scale microservices architecture. However, the extra code complexity introduced by the ViewModel element might overcomplicate a small-scale microservices architecture, or one in the early stages of a migration from a monolith.
The saga design pattern aims to take long-term, multi-system business procedures and introduce a means to reliably recover failed systems, as well as revert services to previous versions when needed. The pattern essentially consists of creating two types of services: one that relays process instructions, and another that performs the application's underlying functions. These "controller" services receive messages from a broker in the form of events, which they can use to identify the service the event is meant to trigger.
As each service along the workflow completes its job, it returns a message to the event bus to let it know. In turn, the bus fires the good news to the controller, and then the controller initiates the next process. On the other hand, if a service cannot perform its duty, it will transmit a failure message back to the bus, which informs the controller to roll back the previous processes that may have introduced the error. While this is all very beneficial, keep in mind that the saga pattern adds more code, which complicates things like debugging, data processing and bandwidth management.
Retry and circuit breaker
It's no secret that distributed services share dependencies that can propagate cascading call failures, even if just one service fails to respond quickly enough. However, it's possible for developers to train their services to retry failed calls rather than simply shut down in response. This design style, appropriately named the retry pattern, configures services to make a predetermined number of call attempts before terminating operation completely and potentially breaking other dependent services. Provided teams diligently maintain error logs and closely monitor call behavior, this pattern will also provide ample time to either correct the error quickly or reroute services to detour the failure.
While the retry pattern focuses on individual call failures, the circuit breaker pattern creates a safety net for services that fail completely, and even acts as an intermediary between the failed service and its dependents. If services communicate normally, the circuit breaker relays messages in a closed state, invoking the retry mechanism when individual calls fail. After a predetermined number of retry requests, the breaker switches to an "open" state that breaks the connection to prevent rippling failures and returns error messages to the requesting services, very similar to a traditional electric circuit breaker. From that point, the breaker will open and close the loop intermittently to see if communication is restored. It will return to a closed state upon success.
The strangler fig pattern (also known simply as the strangler pattern) is an architectural remodeling approach that allows developers to turn an existing "big ball of mud" monolith into independent components and services without the need for complete system rewrites. By segmenting application code into specific functions, developers can subsequently refactor those collections of code one at a time and turn them into decoupled submodules. Eventually, a facade interface can step in as an intermediary between the new modules and the original back end and ultimately assume responsibility for connecting these now-distributed application services.
Through refactoring, all the services and related functions will integrate with the updated system, so development teams can incrementally retire legacy applications without needing to completely untether the monolith's back-end code. By focusing on one service at a time, teams can mitigate the headaches and breakages associated with a rip-and-replace transition approach.