I don't have really strong opinions about a preference between microservices and monoliths; of course, the internet has many wide-ranging opinions that feel rather binary. To me, the binary nature of discourse around architecture also feels kind of strange given that monoliths and microservices are about service architecture rather than systems architecture. I mention this distinction because I typically find myself designing systems, not individual services.
This post dives into how I conceptualize systems and why they often turn out as a collection of architectures.
1. I start with a monolith
I always conceptualize a system as a single system first. Even within a monolith there is often a language feature that facilitates breaking a large application down into components; it seems silly not to use it as the programming gods intended. Secondarily to my nod to the gods, modularizing a monolith keeps code in a state that's much easier to test in isolation.
Testing contracts
When I test, I test from two perspectives:
- Typical unit test scope
- Interface scope
Number two is where I establish some contracts (and examples) for how components of my monolith communicate. Without this kind of testing its easy for programmers who come after me to drift outside of the design considerations of the system; thereby the software would degrade prematurely.
Of course, if my monolith maintains some kind of transport layer then I test those contracts as well. For instance, in Go and Python web services I try to write as many tests as I can directly to the REST API I've built. By testing in this fashion I'm testing every contract I have between components and the contracts I have with my users.
Modularity
In languages like Go and Java you can control your API surface nicely at the package and module level using discreet and explicit controls respectively. In Python I achieve this kind of API surface shaping using classes. Regardless of the language or methodology, testing contracts between components helps me thinking about how my code will be used. By making myself a user I inherently build better experience for the users of my code.
2. I identify candidates for externalization
Often times software needs to run some tasks on a schedule. Monoliths attract, like moths to a flame, in-monolith solutions to this problem. This strategy is at odds with my goal of being able to horizontally scale my monolith.
Instead, this is where microservices and languages like Go come in. It's fairly straight-forward to write a job scheduler in Go or to build Go binaries and put them in a cron if you're leveraging monolithic system architecture. I tend to lean towards writing (or leveraging an existing) scheduler in Go because I can also write an API layer for the scheduler that allows me to signal from the monolith to the scheduler for some event-driven runs as well.
Cross module call parity in-monolith
This step also affects how I call aspects of my code that crosses modules. I tend to separate my monolithic code by transportation layer and data controllers at a minimum. If I have one module that calls another modules data then I direct it to use the underlying controller that is correlated to the transportation layer call. This ensures that, theoretically, if I ever turn a specific costly endpoint into a microservice in the future that the contracts are mostly the same. Migrating wouldn't be totally devoid of effort, but some rigor in this pattern results in far less work in the future.
3. Instrument the system as a whole first
Regardless of the final product orientation, whether it's a monolith, all microservices, or a mixture thereof, I monitor the system as a whole rather than as individual parts first and instrument individual components secondarily. The reason for this is because I frontload the work of my projects with testable contracts and monitoring gives me a glimpse into that contract performance - especially at the component level. When you introduce eventual consistency (like jobs) or network calls there's all kind of retries and exception handling that can occur where a contract is not necessarily violated but could be in degredation (or worse, increasing degredation over time).
Conclusion
This is the way I've been building systems for the last few years and it's worked out quite well in terms of stability and maintainability. Possibly most importantly it also leaves room for the natural evolution of a system over time without too much strife.