What most people outside of IT don’t realize is how difficult it is to manage complex enterprise systems. It’s a delicate balancing act that relies on understanding how a change will impact the entire system.
New developers spend months studying the system’s code base before they can start working on it. Even the most knowledgeable development teams are hesitant to make changes or add new code because it might disrupt operations in an unforeseen way. As a result, even the most mundane changes are debated and delayed.
When things go wrong, administration, development and QA blame each other. Project management blames the lack of budget, etc. As a result, companies lose their trust in IT and start looking for outsourcers to replace the internal team.
Unless you’ve just come back from a sabbatical, you’ve probably heard how microservices can turn this scenario on its head, creating a new, more agile world where developers and operations teams work hand in hand to deliver small, loosely coupled software bundles. Instead of a single monolithic system, functionality is performed by a smaller set of services that coordinate their operations.
How do you do it? In this post, we look at one possible approach. While a „one-size-fits-all“ approach to microservices adoption cannot exist, it is helpful to examine the basic principles that successful migrations have in common.
Adapting Microservices
A common approach for teams looking to adopt microservices is to identify existing functionality in the monolithic system that is both non-critical and fairly loosely coupled with the rest of the application. For example, in an e-commerce system, products and offers are often ideal candidates for a microservices proof of concept. Alternatively, more sophisticated teams can simply agree that all new functionality must be developed as a microservice.
In each of these scenarios, the key challenge is to design and develop the integration between the existing system and the new microservices. When redesigning a part of the system with microservices, a common practice is to introduce glue code to interface with the new services. An API gateway can help combine many individual service calls into one coarse-grained service, thereby reducing the cost of integrating with the monolithic system.
Um den Übergang zu unterstützen besteht die Hauptidee darin, die Funktionalität im System mit diskreten Microservices langsam zu ersetzen und gleichzeitig die Änderungen zu minimieren, die dem System selbst hinzugefügt werden müssen. Dies ist wichtig, um die Kosten für die Aufrechterhaltung des Systems zu reduzieren und die Auswirkungen der Migration zu minimieren.
Microservices Architektur Muster
Es gibt eine Reihe von architektonischen Mustern, die genutzt werden können, um eine solide Microservices-Implementierungsstrategie aufzubauen.
In ihrem Buch „The Art of Scalability“ erläutern Martin Abbott und Michael Fisher das Konzept des „Skalierungs-Würfels“, in dem verschiedene Möglichkeiten der Skalierung von Microservices veranschaulicht werden (Abbildung 1).
Das Microservices Muster selbst befindet sich auf der Y-Achse des Würfels, wobei eine funktionelle Zersetzung zur Skalierung des Systems verwendet wird. Jeder Dienst kann dann durch Klonen (X-Achse) oder Sharding (Z-Achse) weiter skaliert werden.
Alistair Cockburn stellte das „Ports and Adapters“ Muster vor, das auch hexagonale Architektur genannt wird. Obwohl dieses Muster im Zusammenhang mit dem Aufbau von Anwendungen, die isoliert getestet werden können entstanden ist wird es auch zunehmend für den Aufbau von wiederverwendbaren Microservices verwendet. Eine hexagonale Architektur ist die Implementierung eines Musters, das als Bounded Context bezeichnet wird, wobei die Funktionalitäten, die sich auf eine bestimmte Business Domain beziehen, von irgendwelchen äußeren Änderungen oder Effekten isoliert sind.
Beispiele, in denen diese Grundsätze von Unternehmen in die Praxis umgesetzt wurden:
- Click Travel: hat sein Cheddar-Framework als Open Source freigegeben. Es ist eine einfach zu nutzende Projektvorlage für Java-Entwickler, die Anwendungen für Amazon Web Services erstellen.
- SoundCloud: nach einem gescheiterten Versuch eines Big-Bang-Refactorings ihrer Anwendung, basierte ihre Microservices Migration auf die Verwendung des Bounded Context Architektur Musters, um zusammenhängende Feature-Sets zu identifizieren, die nicht mit dem Rest der Domain gekoppelt waren.
Eine Herausforderung von Teams, die anfangen mit Microservices zu arbeiten, sind verteilte Transaktionen, die mehrere unabhängige Dienste umfassen. In einem monolithischen System ist dies einfach, da Zustandsänderungen typischerweise auf einem gemeinsamen Datenmodell basieren, das von allen Teilen der Anwendung gemeinsam genutzt wird. Dies ist jedoch bei Microservices nicht der Fall.
With each microservice managing its own state and data, architectural and operational complexity is introduced in handling distributed transactions. Good design practices, such as Domain-Driven Design, help reduce this complexity by inherently constraining shared state.
Event-oriented patterns such as Event Sourcing or Command Query Responsibility Segregation ( CQRS ) can help teams ensure data consistency in a distributed microservices environment. With Event Sourcing and CQRS, the state changes required to support distributed transactions can be propagated as events (Event Sourcing) or commands (CQRS). Each microservice participating in a particular transaction can then subscribe to the corresponding event.
This pattern can be extended to support compensation operations by the microservice when eventual consistency is at stake. Chris Richardson presented an implementation of this pattern in his talk at hack.summit() 2014 and shared sample code via GitHub . It is also worth studying Fred George’s notion of „Streams and Rapids“ , which use asynchronous services and a high-speed messaging bus to connect the microservices in an application.
Architectures like this are promising. It is important to remember that during the transition from a monolithic system to a collection of microservices, both systems exist in parallel. To reduce the development and operational costs of the migration, the architectural and integration patterns of the microservices must match the architecture of the system.
Architectural and implementation considerations
Domain modeling
Domain modeling is at the heart of designing coherent and loosely coupled microservices. The goal is to ensure that each of your application’s microservices is sufficiently isolated from runtime constraints and insulated from changes in the implementation of the other microservices in the system.
The isolation of microservices also ensures their reusability. For example, consider an offer service that can be extracted from a monolithic e-commerce system. This service could be used by different consumers (clients) with mobile web, iOS or Android apps. For this to work predictably, the domain of „offer“, including its entities and logic, must be isolated from other domains in the system, such as „products“, „customers“, „orders“, etc. This means the offer service must not be overlaid with cross-domain logic or entities.
Proper domain modeling also helps avoid modeling the system along technological or organizational boundaries, resulting in data services, business logic, and presentation logic each implemented as separate services.
Sam Newman discusses these principles in his book „Building Microservices“. Vaughn Vernon focuses on this area even more deeply in „Implementing Domain-Driven Design“.
Service Size
Service sizing is a common and confusing topic in the microservices community. The overarching goal of determining the right size for a microservice is to avoid creating a monolith.
The Single Responsibility Principle is a driving force when considering the right service size in a microservice system. Some practitioners advocate for as small a service size as possible for independent operation and testing. Microservices should have a small code base because they are then easier to maintain and update.
Architects need to be especially careful when creating large domains, such as „products“ in an e-commerce system, as these are potentially monolithic designs that are prone to a lot of variation; e.g. there could be different types of products. For each type of product there could be different business logic. Encapsulating all of these variations could become overwhelming. One way could be to draw sharper boundaries in the product domain and thus derive multiple microservices from it.
Another consideration is the idea of replaceability. If the time it takes to replace a particular microservice with a new implementation or technology is too long (relative to the cycle time of the project), then it is definitely a service that needs further rework of its size.
Test
Let’s look at some operational aspects of gradually migrating the monolithic system to a microservice-based system. Testability is a common problem: As microservices are developed, teams need to perform integration tests of the services with the monolithic system. The idea, of course, is to ensure that the business processes that span the existing monolithic system and the new microservices continue to work
One option here is for the system to provide some consumer-based „contracts“ or interfaces that can be translated into test cases for the new microservices. This approach to automated testing helps ensure that the microservice always conforms to the interface specification. The developers of the system would provide a specification that contains sample requirements and expected microservice responses. This specification is then used to create relevant mocks and use them as the basis for an automated test suite.
Pact, a consumer-driven contract testing library, is a good reference for this approach. Creating a reusable test environment that can provide a test copy of the entire system.
This eliminates potential roadblocks for these teams and improves the feedback loop for the project as a whole. One way to achieve this is to package the entire system in the form of Docker containers orchestrated by an automation tool such as Docker Compose. This allows for rapid deployment of a test infrastructure of the system and gives the team the ability to perform integration tests locally.
Service Discovery
A service needs to know about other services in the system when performing a business function. A service discovery system enables this, with each service pointing to an external registry that holds all the endpoints of the other services. This can be implemented through environment variables when dealing with a small number of services; Etcd, Consul and Apache Zookeeper are examples of more sophisticated systems commonly used for service discovery.
Deployment
Each microservice should be deployable on its own, either on a runtime container or by embedding a container within itself. For example, a JVM-based microservice could embed a Tomcat container within itself, eliminating the need for a standalone web application server. At any given point in time, there could be a number of microservices of the same type (i.e., x-axis scaling according to the scale cube) to enable more reliable processing of requests. Most implementations also include a software load balancer, which can also act as a service registry like Netflix Eureka. This implementation also allows for failover and transparent balancing of requests.
Build & Release Pipelines
Additional considerations when implementing microservices are the continuous integration and continuous deployment pipeline. The idea is to provide a separate pipeline for each microservice. This reduces the cost of building and releasing the application as a whole.
Release practices must also incorporate the concept of rolling upgrades or blue-green deployments. This means that at any point in a new build and release cycle, concurrent versions of the same microservice can exist in the production environment. A percentage of active user traffic can be directed to the new microservice version to test its operation before slowly phasing out the old version. This helps ensure that a failed change in a microservice does not cripple the system. In the event of a failure, the active load can be directed back to the old version of the same service.
Feature flags
Another common pattern is to allow feature flags. A feature flag, a configuration parameter, can be added to the system to allow a feature to be turned on and off. Implementing this pattern in the system would allow us to trigger the use of the relevant microservice for the feature when the feature is turned on. This allows for easy A/B testing of features migrated from the monolithic system to microservices. If the existing version of a feature and the new microservice that replicates the feature can coexist in the production environment, a traffic routing implementation along with the feature flag can help delivery teams build the end system faster.
Developer productivity during microservices adoption
Monolithic architectures are attractive because they allow for rapid creation of new business features on a tight schedule – WHEN the overall system is small. However, this becomes a development and operations nightmare as the system grows.
If working with the monolith was always so painful, you would probably not use such a system. Rather, systems become monoliths because it is easy to add features quickly in the beginning.
Technical debt is built up and at some point it has to be paid back. There is nothing wrong with adding features even on tight schedules, but the system must be architecturally designed for it.
Give developers the opportunity to take a „microservices first“ approach when building a new feature or system. This requires strong discipline around architecture and automation, which in turn helps create an environment that enables teams to build microservices quickly and cleanly.
One approach to building this developer infrastructure is to create a standard boilerplate project template that encapsulates the core principles of microservice design, including project structure, test automation, integration with instrumentation and monitoring infrastructure, patterns such as circuit breakers and timeouts, API frameworks and documentation hooks, etc.
Project templates allow teams to focus less on standard structures and glue code, and more on building business functionality in a distributed microservice environment. Projects like Dropwizard, Spring Boot and Netflix Karyon are interesting approaches to solving this. The right choice between these frameworks depends on the architecture and developer qualifications.
Monitoring and operation
Coexistence of monolithic systems and microservices requires comprehensive monitoring of performance, systems and resources. This is more pronounced when a specific feature of the system is replicated by a microservice. Collecting statistics for performance and load also allows a comparison between the monolithic implementation and the microservice-based implementation. This allows a better view of the „wins“ that the new implementation brings and increases confidence in moving forward with the migration.
Organizational considerations
The most challenging aspects of migrating from monolithic systems to microservices are the organizational changes required, such as building service teams that have self-determination over all aspects of their microservices. This requires the creation of multidisciplinary units that include developers, testers, and operations staff, among others.
Conclusion
Most of the ideas presented in this article are already being practiced or have already delivered results in organizations of all sizes. However, these are not set in stone. Therefore, it is important to keep an eye on the evolution of architectural patterns and their adaptations. As more organizations move from monolithic systems to microservices, we still have a lot to learn on this journey.