Undeniably, there is a growing interest in microservices as we see more organisations, big and small, evaluating and implementing this emerging approach. Despite its apparent novelty, most concepts and principles underpinning microservices are not exactly new – they are simply proven and commonsense software development practices that now need to be applied holistically and at a wider scale, rather than at the scale of a single program or machine. These principles include separation of concerns, loose coupling, scalability and fault-tolerance.
However, given the lack of a precise and universal definition for microservices, it becomes clear that there is no single recipe for implementing a microservices system successfully.
Additionally, certain engineering practices that can be essential to a viable microservices implementation, such as continuous delivery and configuration management, are frequently perceived as optional add-ons, whereas they should be considered an integral part of a microservices architecture.
Thus understanding how to approach microservices remains a challenge; but equally important, how not to approach microservices.
We encountered a number of anti-patterns on a number of projects we worked on, building systems that can be characterised as ‘microservices’. Given their pervasive nature, they should be considered and understood, ideally in the early stages of a project, in order to be mitigated and avoided. Those are:
1. Building the wrong thing
Given the lack of specificity surrounding microservices, we can easily but mistakenly assume that every stakeholder in the project’s team on a new microservices implementation share the same vision, goals and expectations. In practice, it is not that obvious.
Similarly, it is easy to overestimate the level of sophistication and flexibility that need to be provided by a given microservices implementation in terms what features are offered and what languages, platforms and supported.
The risk in this case is to invest time and effort into activities that turn out to be inessential. Lack of clarity in the goals and scope of the project can therefore lead to increased complexity and loss of focus in the development effort.
What you should do instead: Questions about what actually needs to be built should be explicitly addressed during the early phases of the project e.g. who is going to develop and maintain the new services? Who are the principal users of the system? What languages, platforms and technologies do we need to support? What tools will developers need to create and deploy new services?
It is essential to be explicit about the goals of the project but also about the non-goals in order to avoid unneeded complexity being introduced into the project.
2. Failing to adopt a contract-first design approach
A microservices architecture facilitates manipulating services individually to achieve operational flexibility, scalability and fault-tolerance. Nevertheless, building useful applications is essentially achieved by composing multiple services in meaningful ways.
In this context, a well defined service contract plays a number of essential roles:
- Abstraction: a service contract allows us to think in terms of what a service does as opposed to how it is implemented.
- Encapsulation: contracts hide away implementation details hence reducing tight coupling between services and consumers. A good contract also facilitates evolution in a controlled way by maintaining a clear separation between the intent of the service and its implementation.
- Composition: services have to be composed in order to be useful, at least through simple invocation from a single consumer. As the system evolves, services end up being used and composed a variety of new ways.
One common pitfall when implementing a service is focusing primarily on the implementation at the expense of the service’s contract. If the a service’s API (contract) is not well thought out, there is a risk that we expose internal implementation details to consumers, making the service hard to consume and to evolve when new requirements arise.
What you should do instead: Avoid contracts that are generated automatically and that are likely to expose internal application structures. Start working on each new service by defining its contract first. A resource-oriented approach to service design can help in building simple service that can evolve well when required.
3. Assuming the wrong communication protocols
It is quite common for microservices to make use of simple communication protocols, such as http, or in some cases lightweight message brokers, to communicate. Messages exchanged can be encoded in a variety of ways, from human-readable formats (JSON, YAML), to serialised binary objects.
It goes without saying that each approach has its pros and cons and
that we need to understand what communication patterns, features and guarantees we need to support in order to choose the right approach and toolset.
Less obvious though is that this choice does not need to be restricted to a single protocol or approach across the whole system; there are situations where mixing different communication styles and protocols is needed and justified.
What you should do instead: Avoid committing to any communication protocol before getting a good understanding of the capabilities of service consumers you need to support. One useful distinction to make is between external and internal services. External services often need to provide an http interface to be widely accessible, while internal services can sometimes benefit from richer capabilities provided by message brokers for example.
4. Introducing a shared domain model
In a traditional monolithic architecture, it is fairly common to create a centralised domain model, that is then used to implement all sort of functionality from input validation, to business logic and persisting business entities to the database.
The fundamental assumption behind a shared domain model is that one application is providing the full boundaries and context within which the domain model is valid. Therefore sharing the same domain model is in this context is safe and has clear advantages.
In a microservices architecture, things are very different. An application is no more one single entity with rigidly defined boundaries; it is instead an aggregation of a number of any number of services that should be loosely coupled and independent. Sharing the same domain across services creates tight coupling and should be considered as a potential indication that one logical service is being split across a number of deployable units.
From an operational perspective, sharing a single domain creates dependencies between components that are otherwise independent through sharing of common binary artefacts. Every minor update to the shared domain model will require every services that depends on it to be updated and redeployed, that is if we truly wish to maintain a single shared domain. At scale, this can cause real operational challenges.
What you should do instead: To maintain encapsulation and separation of concerns, each service should have its own domain, that it is able evolve independently from other services. The domain of an application should be expressed through service interaction, not through a shared object model.
Avoid creating shared dependencies on artefacts that are under constant pressure to evolve such as the domain model.
5. Defining inappropriate service boundaries
Another common issue resulting from applying familiar development practices indiscriminately to a microservices architecture is to create new services directly from internal application components without considering carefully the boundaries of each service.
Internal components, even with a well-defined interface, do not always make good standalone services: business-tier services can have inter-dependencies or can expose operations at a granularity that is awkward for remote invocation. Other internal components, such as data repositories, are often too technical and do not expose business operations that are self-contained and meaningful to the business domain.
The risk in this situation is that you might build a so-called distributed monolith, which is an application with a monolithic architecture but that is also required to deal with issues related to remote service invocation such as latency and cascading failures.
What you should do instead: Design services that are truly self-contained and independent. In a microservices architecture, service boundaries should enforce separation of concerns at a business level, as opposed to separation of concerns along technical layers, which is common for monolithic applications.
6. Neglecting DevOps and Testing Concerns
One of the appeals of microservices is to develop, build and deploy small and simple parts of the system, that is services, individually and independently from each other. As we add more services and combine them in different ways, the complexity of the system inevitably increases. At some point, it becomes impractical to manage the system in an ad-hoc way, while maintaining speed of development and stability at the same time.
Achieving a high level of automation efficiency though is not everything; we need to guarantee that the system remains sound and functional after each small change is implemented, including changes coming potentially from other parts of the business, for example another team located in a different country.
Failing to implement serious DevOps and automated testing practices from the start will produce a system that is brittle, unwieldy and ultimately unviable.
What you should do instead: Introduce proven DevOps practices, such as continuous delivery and configuration management from the start. A continuous delivery pipeline should be an integral part of a microservices implementation, reducing engineering complexities handled traditionally by the services themselves.
Automate acceptance, regression and performance testing at an early stage as well. The hardest thing in a microservices architecture is not testing services individually – it is rather making sure that the whole system remains functional and coherent after every change.
7. Disregarding the Human Factor
While microservices can bring simplicity and focus to the development process, the flip side of the coin is that developers are required to increase their understanding of the bigger picture and to have deeper understanding of software engineering concepts related to how services behave and interact at runtime. These include remote invocation, scalability and fault-tolerance. These skills, whilst always essential, were not traditionally seen as must-haves for developers working on run-of-the-mill enterprise applications.
Unsurprisingly, developers who lack good familiarity these concepts and techniques are very likely to hit a number of pitfalls when first confronted with a microservices system.
At a higher scale, organisations where silos are rife and where collaboration is impeded by political obstacles are unlikely to benefit from a microservices approach that primarily relies on wide-scale collaboration between all stakeholders to be successful.
Under the circumstances adopting a microservices architecture can backfire and result in a complex and inefficient system that will fail to deliver on its promise.
What you should do instead: Microservices are not a silver bullet. Invest in your developers and encourage collaboration across the organisation to build systems that are sustainable and evolvable.