When architects and developers embrace microservices, one of the most challenging questions is: “How big should each service be?” While true, the obvious answer – “it depends” – needs unpacking. Based on guidance I developed for internal use at Monday.com, this post explores the key factors and tradeoffs for service granularity decisions.
Deciding on service granularity isn’t a one-time thing. What starts as a well-sized service can evolve into a monolith as functionality grows. Fortunately, architectures can evolve over time (also see my articles on evolvable architectures), and we can split/unite services as we go along.
At its core, the decision to break apart or combine services revolves around one fundamental concept: friction.
Some forces drive us to break services into smaller ones
- Different functionality
- Different quality of service (QoS) needs
- Different access restrictions
- Different scalability needs (maybe even resulting in different implementation languages)
And some forces push us to keep services big or even unite smaller services into larger ones.
- Overhead exceeds usefulness
- Transactional behavior/ Tight data dependency
- Change together to deliver business workflows
- Avoiding accidental complexity
Let’s look at them in more detail:
Break it down
As mentioned above, friction is the underlying motivator for deciding on granularity. If you worked with a monolith, some of the reasons that drove you to go to services were probably things like:
Common challenges that drive service decomposition:
- Build and Deploy Complexity: Even minor changes require complete codebase rebuilds and redeployments
- Coupled Change Cycles: Different teams become unnecessarily dependent on each other’s deployment schedules
- Difficult Modularization: Maintaining clean architecture becomes increasingly challenging
- Inflexible Scaling: Unable to scale components independently based on their specific needs
- Team Conflicts: Frequent code conflicts and unclear ownership boundaries
- Ambiguous Ownership: Areas of code without clear team responsibility
The same challenges can also occur within smaller services – and there are native forces that can serve as indicators that it is time to split a service.
Different Functionality – Different Reasons for Change
A service should be cohesive, i.e., the functionality that makes its business logic should belong together. A good sign of logic is cohesion is if it feels natural to couple it (while some degree of coupling is acceptable – this is something that you should watch and not let spiral out of control, but that’s another story).
At different abstraction levels, things can look cohesive or different. A guiding principle is how much code and how many developers are working on the functionality. It is good to be vigilant since, over time, seemingly related multiple functional areas in the same code base can evolve into a mini-monolith.
A better way to look at this force is to consider it a factor of cognitive load (or, as discussed in my previous post, AI context window). When a service does too much, it gets too hard to reason about or grasp what is going on, and breaking it up into multiple services can help with that. An important caveat is that this is a force, not a rule. Often, breaking functionality into a different class, library, or module is more than enough to solve the problem. Adding a service creates overhead that might not be worth it (more on that later in the “Avoiding Accidental Complexity” and “Overhead exceeds usefulness” sections below)
Different QoS Needs
Quality of Service (QoS) requirements represent the operational characteristics we expect from our services. These include:
- Availability: The percentage of time the service must be operational
- Response Time: Expected latency for service requests
- Throughput: Number of requests the service must handle
- Data Consistency: Required level of data accuracy and freshness
If we co-mingle different QoS needs in the same service, we are dragging the parts that need the lower QoS to the standards we must keep for the part that needs higher QoS. Higher quality is not a bad thing; quite the opposite. Higher quality, however, comes with costs. It also usually translated into slower release and feedback cycles for code that may be less well-defined, increased maintenance and operational efforts (where relatively non-important functionality causes problems for the more important one), etc. Again, it all boils down to the added friction we get for functionality that probably doesn’t need it.
Different Access Restrictions (Control Plane vs. Data Plane):
Security requirements are a good motivator for separating functionality into different services.
A common practice when designing systems is to separate the “control plane,” which is responsible for administrative functions, from the “data plane,” which handles routine operational requests
Another example can be differentiating between endpoints that are externally exposed and endpoints that are only for internal consumption. If both are presented by the same service, there’s an increased chance for configuration mismatch that would expose (usually, less-secure) internal APIs to the outside world. Also, there’s an increased chance of data leakage and other vulnerabilities.
Different Scalability Needs
In a way, this is a specialized case of bundling different functionalities in a single service – but it is a little more subtle as these might only be different aspects of the same core capability.
Sometimes, this particular problem can be worked around by using different deployment groups. However, that can also lead to confusion and resource waste, as each group can utilize resources it doesn’t need.
In past projects, I sometimes found that it is even worthwhile to break a service into smaller services that use different languages, where one caters more to flexibility and evolvability needs, and the other handles massive loads of requests.
Build it up
Breaking functionality into smaller services is the “natural” way to go (heck, it is in the name “micro-service architecture”). However, excessive fragmentation also has its problems and creates its own frictions. Let’s look at the forces that drive us to unite services into bigger ones
Overhead Exceeds Usefulness
The cardinal rule of architectural design is to maximize value while minimizing cost. When the operational burden of managing a service – encompassing deployment, monitoring, logging, and inter-service communication – surpasses its contribution to business value, consolidation becomes imperative. Services that are less useful than the overhead they incur are called nano-services. Nano services introduce problems such as:
- These are likely to have chatty interfaces, resulting in lots of network calls.
- An unmanageable explosion of services can result in a service operational nightmare and challenging governance.
- Hard to manage, monitor, and operate (esp. for smaller teams)
Transactional Behavior and data dependency
Maintaining data consistency across distributed systems introduces significant challenges. Distributed transactions impose performance penalties, necessitate complex error-handling mechanisms, and are very hard to get right. A related issue is excessive inter-service communication, particularly for retrieving essential data elements, which introduces latency and reduces system throughput. Tight data dependencies suggest that services are improperly delineated and that data co-location would enhance performance (note that an alternative here, at least sometimes, is to cache some of the other service data.
Change Together to Deliver Business Workflows
When we find out that changes in one service almost always lead to updates in other services, or any business flow always needs changes in the same chain of services, it is probably a good time to check if these are indeed independent services.
Remember that the whole point of going to services (e.g., breaking a monolith) is to allow teams the freedom to evolve functionality with minimal dependencies – if we still have all the dependencies and also need to deal with the complexities and performance overhead of distributed systems, we’re doing it wrong.
Avoiding Accidental Complexity
Accidental complexity (as opposed to essential complexity) is another important aspect. If you try to decompose a system into services before you fully understand the problem domain. It can easily result in over-engineered architectures with unnecessary complexity.
The key takeaway is to start simple and only introduce complexity when concrete business requirements and measurable improvements in performance, scalability, or resilience demonstrably justify it. Over-engineering is a form of accidental complexity and can lead to maintainability nightmares.
Remember Gall’s law: “A complex system that works is invariably found to have evolved from a simple system that worked. A complex system designed from scratch never works and cannot be patched to make it work. You have to start over with a working simple system.”
Conclusion
In this post, we went through the different forces that control service granularity tradeoffs. Just like other architectural decisions, deciding which tradeoffs to make is a balancing act. Splitting services can improve team autonomy, scalability, and security isolation; too many services can be just as detrimental to these goals, driving more dependencies and coordination between teams.
Remember these key principles when making granularity decisions:
- Start simple and evolve based on concrete needs
- Watch for signs of friction in either direction
- Consider the full operational cost of each service
- Focus on business value over technical purity
- Avoid premature optimization
The “right” size for a service today might not be right tomorrow, and that’s okay. Again, as mentioned in the previous post, AI coding assistance makes it easier than ever to create/remove the boilerplate when making such changes. The key is to maintain flexibility and willingness to adapt as your system and organization evolve.