Check out this video on microservices
Welcome to a series of blog posts where we talk about how we build our application and infrastructure. In our first entry we'll discuss how we split our own application into different services, and benefits of monoliths vs micro-services.
Monolith vs micro-services
When we started to build our application we decided to use AWS as our platform. We're building in a serverless approach to allow our application to scale when needed, and not use resources unnecessarily when the utilisation is low. We're heavily using Lambdas, EventBridge and SQS to make it all work.
Early on in the development we had discussions about how we want to architect our application. Do we go with a monolithic approach or build micro-services? How you structure your application has a big impact on how fast the early development is but you can also easily end up with something that is hard and slow to maintain in the long run.
Building a healthy, maintainable software is not really about whether you build one monolithic app, or bunch of smaller micro-services. The main reason why people feel that monoliths are messy spaghetti is not really because it's a monolith, but because of too tightly coupling of modules within the codebase. Splitting your monolith into smaller services is not a guarantee to solve the issues with tightly coupling. Replacing a function call with a HTTP request to another service doesn't really do much else than add a new layer of complexity.
When you're considering to use micro-services, you should have a good idea what you want to achieve by splitting your application.
Do you want a clear ownership for teams for particular parts of your application?
Do you want to improve build or deploy times?
Do you want to make some parts of your application resilient to failures in other parts?
Do you want to use different technologies in different parts of your application?
Do you want to run something in isolation for security reasons?
Do you need to be able to scale a resource-heavy process independently to save costs?
Depending on your goals, how you architect micro-services can look very different from the guy next to you. Some of the problems you're trying to solve might not even need micro-services architecture, as there's really good tooling to deal with monolithic codebases nowadays. It really depends on your goals.
When you consider your requirements and goals, you should ask yourself what even is a micro-service?
Is it something that can be deployed on its own?
Is it something that has its own database?
Is it something that is owned by a team?
Is it its own repository in your version control?
Is it something that is loosely coupled from the rest of your application?
Is it all of the above?
To me, there is no right answer to this. Different people define micro-services differently based on their requirements. Most people agree that a micro-service is independently deployable and that it somehow groups some related functionality together, but other than that they can look very different between different use-cases.
There's also no right or wrong answer how you should split your services, and what is right for you depends on multiple factors as discussed. But regardless of how you structure your services, it's important to think about how you avoid too tightly coupling between the services. You don't want to end up in a situation where making a simple change is going to require coordination between multiple teams, or that you might blow up some unrelated part of the application because of a seemingly innocent change.
Micro-services at Theymes
At Theymes we don't really even call our services micro-services, we just call them services and we only have few of them. The term micro-service suggests that your service should be small (what even is considered as small?), but from my experience having a few bigger services is better than a lot of small services that are too tightly coupled.
There's nothing inherently wrong with a monolithic approach to building software, as long as you don't keep things too coupled. If you see a micro-service that needs to call 5 other micro-services to complete a simple API request, it is usually an indication that your micro-services are too granular. By introducing a new service you always also introduce some complexity to your architecture, so we don't want to do that lightly.
In our architecture a service handles some concrete business need, which often also makes it quite naturally self-contained. Our services have their own databases which allows a service to evolve freely without affecting other services.
We mainly communicate between services using event messaging with AWS EventBridge and avoid direct synchronous service-to-service calls so a service going down won't bring the whole application down (without complex caching-based fallbacks).
If we need to share data between services, we do so by storing a local copy of the needed data in the other service's database. We have a strict policy that a piece of data should always be owned by a single service and is only replicated to other services. This simplifies the overall architecture significantly and eliminates a whole class of race conditions with data.
That means that if we want to change data owned by another service, we need the other service to acknowledge that the data was updated correctly. In these situations we do allow direct service-to-service API calls. With our approach to micro-services, those situations are quite rare and they only affect mutations, so we're still very resilient to other services going down.
Closing thoughts
Don't blindly build your application in a certain way because someone told that is the best approach. You need to stop and think what are your requirements and goals, and choose the correct approach for your situation. If unsure, it's better to aim for simplicity than over-engineer an architecture that slows you down.
We like certain things from both a monolithic and micro-services architectures, and we try to build something that combines the best from both worlds. For example we embrace monorepo and share a lot of code between our services that way, while still benefiting from isolation provided by micro-services.
In our next article we'll go more in-detail about how our event-driven service architecture works with AWS EventBridge and SQS and how we achieve resilience through good de-coupling strategy.