As we start thinking of the pizza ordering issue we identify the different capabilities required in our application to fulfill that need. We'll need to manage a list of the different pizzas we can make, allow customers to pick one or many pizzas, handle the payment, schedule the delivery, and so on. We may decide that letting customers create an account will facilitate re-ordering the next time they use Pizzup. After talking to the first users, we might realize that live-tracking of the delivery and mobile support will give us an advantage over the competition.
What was a simple need in the beginning quickly turned into a list of new features.
Microservices work well when you have a solid grasp of the different services required by your system. However, microservices are much more difficult to handle if the core requirements of an application are not well defined. It's quite costly to redefine service interactions, APIs, and data structures in microservices since there are typically many more moving parts that need to be coordinated. Our advice is to keep things simple until you have collected enough user feedback to give you confidence that the basic needs of your customers are understood and planned for.
A bit of caution: building a monolith can quickly lead to complicated code that is challenging to break down into smaller pieces. It’s best to have clear modules identified so that you can extract them later out of the monolith. You can also start by separating the logic from the web UI and ensuring that it interacts with your backend via a RESTful API over HTTP. This makes the transition to microservices easier when you move API resources to different services.
Step 2: Organize your teams the right way
Up until now, it might seem that building microservices is mostly a technical affair. You need to split a codebase into multiple services, implement the right patterns to fail gracefully and recover from network issues, deal with data consistency, monitor service load, etc. There will be numerous new concepts to grasp. But arguably the most but one thing that must not be ignored is that you'll need to restructure the way your teams are organized.
Conway's law is real and can be observed in all types of teams. If a software team is organized with a backend team, a frontend team, and an operations team working independently, it will deliver separate frontend and backend monoliths that get thrown over the wall to the operations team to deliver into production. This type of team structure isn’t a good fit for microservices, since each service should be treated like its own product that needs to be shipped independently of the others.
Instead, you should create smaller, DevOps teams that have all the competencies required to develop and maintain the services they're in charge of. There are great benefits to arranging your teams this way. First of all your developers have a better understanding of the impact of their code in production, which helps them produce better releases and reduce the risk of seeing issues released to customers. Secondly, deployments become second nature for each team since they work together on improvements to the code as well as the automation of the deployment pipeline.
Step 3: Split the monolith to build a microservices architecture
When you've identified the boundaries of your services and you've figured out how to restructure your teams, you can start splitting your monolith to build microservices. The following are the key points to think about at that time.
Keep communication between services simple with a RESTful API
If you're not already using a RESTful API now would be a good time to adopt it. As Martin Fowler explains, you want to have "smart endpoints and dumb pipes". This means that the communication protocol between your services should be as simple as possible and only in charge of transmitting data without transforming it. The magic happens in the endpoints themselves – they receive a request, process it, and emit a response in return.
Microservice architectures strive to keep things as straightforward as possible to avoid the tight coupling of components. In some cases, you might find yourself using an event-driven architecture with asynchronous message-based communications. But once again you should look into basic message queue services like RabbitMQ and avoid adding complexity to the messages transmitted over the network.
Divide data into bounded contexts or data domains
Monolith applications use a single database for all business features of the application. As a monolith is broken into microservices, this singular database may no longer make sense. A central database can become a bottleneck for traffic scaling. If a particular service accesses the database with high load, it may interrupt the database access of other services. Additionally, a singular database can become a collaboration bottleneck for multiple teams trying to simultaneously modify the schema. This may call for the database to be split up or additional data storage tools added to support microservice data needs.
Refactoring a monolithic database schema can be a delicate operation. It's important to clearly identify which datasets each service needs and any overlaps. This schema planning can be done by using bounded contexts, which are a pattern from Domain Driven Design. A bounded context defines a self contained system, including what can enter and exit that system.
In this system, when a user accesses an order you can view customer information in the table, which can also be used to populate the invoice managed by the billing system. This may seem logical and simple but with microservices the services should be decoupled so that invoices can be accessed even if the ordering system is down. Also, it allows you to optimize or evolve the invoice table independent of others. Each service might end up having its own datastore to access the data it needs.
This introduces new problems since some data will be duplicated in different databases. Bounded contexts can identify the best strategy to handle shared or duplicate data. You may adopt an event-driven architecture to help syncing data across multiple services. For instance, your billing and delivery tracking services might listen for events emitted by the account service when customers update their personal information. Upon reception of the event, these services will update their datastore accordingly. This event-driven architecture allows the account service logic to be kept simple as it doesn't need to know the other dependent services. It simply tells the system what it did and other services listen and act accordingly.
You can also choose to keep all customer information in the account service and only keep a foreign key reference in your billing and delivery service. These services then interact with the account service to get relevant customer data instead of duplicating existing records. Since there isn't a universal solution for these problems, you'll need to look into each specific case to determine the best approach.
Build your microservices architecture for failure
We've seen how microservices can provide you with great benefits over a monolithic architecture. They're smaller in size and specialized, which makes them easy to understand. They're decoupled, which means that you can refactor a service without having to fear breaking the other components of the system, or slowing down the development of the other teams. They also give more flexibility to your developers as they can pick different technologies if required without being constrained by the needs of other services.
In short, having a microservice architecture makes developing and maintaining each business capability easier. But things become more complicated when you look at all the services together and how they need to interact to complete actions. Your system is now distributed with multiple points of failure and you need to cater for that. You need to take into account not only cases where a service is not responding, but also be able to deal with slower network responses. Recovering from a failure can also be tricky at times since you need to make sure that services that get back online do not get flooded by pending messages.
As you start extracting capabilities out of your monolithic systems, make sure that your designs are built for failure from the beginning.
Emphasize monitoring to ease microservices testing
Testing is another drawback of microservices compared to a monolithic system. An application that is built as a single codebase doesn't need much to have a test environment up and running. In most cases you'll have to start a backend server coupled with a database to be able to run your test suite.
In the world of microservices things are not as easy. When it comes to unit tests it will still be quite similar to the monolith and you shouldn't feel more pain at that level. However when it comes to integration and system testing things will become much more difficult. You might have to start several services together, have different datastores up and running, and your setup might need to include message queues that you did not need with your monolith. In this situation it becomes much more costly to run functional tests and the increasing number of moving parts makes it very difficult to predict the different types of failures that can happen.
Monitoring can identify issues early and allow you to react accordingly. You need to understand the baselines of different services and react not only when they go down, but also when they behave unexpectedly. One advantage of adopting a microservice architecture is that your system should be resilient to partial failure, so if you start to see anomalies in the delivery tracking service of our Pizzup application it won't be as bad as if it were a monolithic system. Our application should be designed so that all the other services respond properly and let our customers order pizzas while we restore live-tracking.
Embrace continuous delivery to reduce deployment friction
Releasing a monolithic system to production manually is a tedious and risky effort but it can be done. Of course we do not recommend this approach and encourage every software team to embrace continuous delivery for all types of development, but at the beginning of a project you might do your first deployments yourself via the command line.
This approach is not sustainable when you have an increasing number of services that need to be deployed multiple times a day. So, as part of your transition to microservices it is critical that you embrace continuous delivery to reduce the risks of release failure, as well as ensure your team is focused on building and running the application, rather than being stuck deploying it. Practicing continuous delivery also means that your service passed acceptance tests before going to production. Of course, bugs will occur but over time you will build a robust test suite that should increase the confidence of your team in the quality of the releases.
Running microservices is not a sprint
Microservices are a popular and widely adopted industry best practice. For complex projects, they offer greater flexibility for building and deploying software. They also help identify and formalize the business components of your system, which comes in handy when you have several teams working on the same application. But there are also some clear drawbacks to managing distributed systems, and splitting a monolithic architecture should only be done when there's a clear understanding of the service boundaries.
Building microservices should be seen as a journey rather than the immediate goal for a team. Start small to understand the technical requirements of a distributed system, how to fail gracefully, and scale individual components. Then you can gradually extract more services as you gain experience and knowledge.
The migration to a microservices architecture does not need to be accomplished in one holistic effort. An iterative strategy to sequentially migrate smaller components to microservices is a safer bet. Identify the most well-defined service boundaries within an established monolith application and iteratively work to decouple them into their own microservice.
To recap, microservices is a strategy that is beneficial to both the raw technical code development process and overall business organization strategy. Microservices help organize teams into units that focus on developing and owning specific business functions. This granular focus improves overall business communication and efficiency. There are tradeoffs for the benefits of microservices. It is important that service boundaries are clearly defined before migrating to a microservice architecture.
While a microservice architecture has numerous benefits, it also increases complexity. Atlassian developed Compass to help companies manage the complexities of distributed architectures as they scale. It’s an extensible developer experience platform that brings disconnected information about all of the engineering output and team collaboration together in a central, searchable location.