In my previous article I mentioned a confidential personal project, that Angular workspace with several applications. It's still confidential (don't worry, I'll reveal what it is soon), but today I want to open up the part I can open without spoiling the surprise: the architecture. The idea is to show how I structure a real system and, above all, the why behind each choice. No business rules, just the structure.
And the central choice of this project was: neither monolith nor microservices. But rather macroservices. If you've never heard the term, that's fine, it really is rare. And that's exactly why I wanted to write this article: in the simplest way I can, like the first one. If you've never programmed, the idea is that you'll understand it anyway.
First: what does "choosing an architecture" mean?
Fancy name aside, software architecture is about answering two questions: how many parts is the system divided into and how do those parts talk to each other. That's it. It sounds like little, but that decision affects everything: development speed, server costs, what happens when something breaks, and even how many people you need on the team.
To make it easier, I'll use an analogy that will follow me through the whole article: the system is a restaurant kitchen. Orders come in (the users' requests), dishes go out (the responses). What changes from one architecture to another is how you organize that kitchen.
Option 1: Monolith
What it is: the entire system is a single application. One codebase, one deploy, one database. In the analogy: a single kitchen, everyone working together in the same space, from the sauce to the dessert.
Where it shines: simplicity. It's fast to develop, easy to test, easy to put live. If an operation needs to touch three things at once, one database transaction solves it and that's it: either everything is saved or nothing is. Martin Fowler (one of the biggest references in architecture) even advocates an idea called "Monolith First": almost every system should start as a monolith, because at the beginning you still don't know where your business boundaries are. Keep that one in mind, because I'll come back to it later.
Where it hurts: everything together means everything together, period. If only one part of the system needs more machine, you pay for machine for the whole system. If one piece hangs, it takes the rest down with it. And there's the long-term effect: without a lot of discipline, any code starts calling any code, everything depends on everything, and the project becomes what the literature affectionately calls a "big ball of mud". Then every deploy becomes a risk event, because touching anything can break anything else.
Option 2: Modular monolith
What it is: it's still a single application, a single deploy, but inside, the code is divided into modules with well-defined boundaries: the payments module can't poke around in the internal classes of the users module, each one exposes only an official "front door". In the analogy: the same kitchen, but now with separate counters and each cook respecting the other's space.
Where it shines: you get the organization without paying the network cost (I'll explain that cost in a moment). For many companies, honestly, it's the best cost-benefit there is: tidy code, simple deploy, a single infrastructure to operate.
Where it hurts: the boundary between modules is a promise, not a wall. Nothing physically stops a rushed dev (or me, at 2 a.m.) from finding a shortcut and calling the other module's internal class directly. You can watch for it with code analysis tooling, but that's what it is: watching. Besides, everything still scales together, goes down together and gets deployed together. The organization got better, but the monolith's operational limits are all still there.
Option 3: Microservices
What it is: the opposite extreme. The system becomes dozens (sometimes hundreds) of tiny services, each with its own code, its own deploy, its own database. They talk to each other over the network. In the analogy: each cook gets their own kitchen, with their own stove and their own power bill, and the dishes travel by courier between them.
Where it shines: in a big company, with many teams. Each team takes care of its own service, deploys whenever it wants without asking anyone's permission, scales only what needs scaling. There's even a famous "law" about this, Conway's Law: a system's architecture tends to mirror the structure of the organization that builds it. Microservices solve, first and foremost, a people problem: how to make 50 teams work without running each other over.
Where it hurts: the network gets in the middle of everything, and the network charges dearly. A call that used to be just a function becomes an HTTP request, and that brings latency, partial failure (service A answered, B didn't), retries, contract versioning between services. That database transaction that solved everything in the monolith? Forget it: now consistency is distributed and you need much more complex patterns to guarantee that an operation involving three services doesn't end up half-done. Fowler calls this cost the "microservice premium": a complexity tax that's only worth paying when the system (and the team) is big enough. And there's the worst scenario of all: if you split in the wrong places, you get a distributed monolith, where everything still depends on everything, except now with a network in between. All the pains of the monolith plus all the pains of distribution.
Other names that show up around this subject
Before getting to what I chose, three quick mentions, just so you don't get lost if these names cross your path:
- SOA is the grandfather of microservices, from the 2000s: the same idea of separate services, but with the heavy tools of that era. The concept evolved and became what we have today.
- Serverless is functions that run on demand in the cloud: you don't manage any server, you pay per execution. Great for one-off tasks (resizing an image, sending an email), complicated as the main body of a system that needs state and continuous processing.
- Nanoservices is what a microservice taken too far came to be called: one service per operation, hundreds of tiny deploys. It's considered an anti-pattern, the overhead swallows any benefit.
The path I chose: macroservices
Now we get to it. A macroservice is a middle ground: few services, large ones, divided by domain. Domain here means "business subject", not feature. Instead of 50 tiny services, half a dozen robust services, each one owning an entire subject, its own data and its own deploy. In the restaurant analogy: neither a single kitchen, nor one kitchen per cook. A kitchen divided into sections: the pasta section, the meat section, the dessert section. Each section is big, fends for itself and handles its subject from start to finish.
Remember Fowler's "Monolith First", the idea of starting everything as a monolith? Well, here I diverged from him, and it was on purpose. His argument is that at the beginning you don't know your business boundaries. But in this project I did: before the first line of code I sat down, studied the business and documented the architecture decisions, and the subjects were already clear on paper. When the boundaries are known from the planning stage, starting as a monolith would just postpone a split I already knew I would need. So the project was born divided by subject, with low segmentation since the MVP. Today there are seven services: authentication, user profiles, administration, financial operations, a processing engine that does the heavy lifting, the product's main API and the public site's API. Each with its own data, its own deploy and its own responsibility. What each one does inside I can't tell yet, or the surprise is over.
And why this middle ground, and not the other options?
Why not a monolith: this product needs real isolation. If the financial service has a problem, the processing engine can't even find out about it, it keeps working. And the parts have completely different profiles: one does continuous, heavy work, another only answers a query now and then. Scaling both together would be paying for servers for nothing.
Why not microservices: the "microservice premium" only pays off when you have many teams. I don't have 50 teams. Every new service is one more deploy to maintain, one more monitoring to watch, one more contract to version. Multiplying that by 50 without need isn't architecture, it's an expensive hobby.
Why not a modular monolith: this was the runner-up, and I confess it almost won. Two things made me discard it. First, independent deploys: I want to update the public site without even touching the processing engine. Second, and more important: in a macroservice the boundary is physical. For one service to talk to another, only through the network, through the official door. There's no 2 a.m. shortcut, the wall is there. The network, which was the villain of the previous chapter, here becomes the boundary enforcer, and with half a dozen services its cost is manageable.
And there's a detail that ties it all together: each boundary follows the bounded contexts of DDD (Domain-Driven Design, the practice of designing software mirroring the business's subjects). Translation: each service corresponds to a subject that actually exists in the business, not an arbitrary cut. That means if a macroservice grows too much one day, it can be safely split in two, because the seam is already in the right place. Today's decision doesn't lock me in forever.
Inside, every service looks the same
Choosing macroservices solves the "how many parts". But each part also needs internal organization, otherwise all you did was distribute the mess. This is where Clean Architecture comes in, and all seven services follow exactly the same structure. In practice that means opening any of them feels like home: I know where everything lives before I even open the folder.
Clean Architecture organizes the code in layers, like an onion:
- Domain, the core: the pure business rules. This layer doesn't know what a database is, doesn't know what HTTP is, doesn't know the internet exists. Just rules.
- Application: the use cases. It orchestrates the domain to carry out the operations the user asks for.
- Infrastructure: the real world. Database, calls to other services, everything technical.
- Api: the front door. It receives requests, validates them and hands them to Application.
There's only one golden rule: dependencies always point inward. The Api knows the Application, which knows the Domain. Never the other way around. The core doesn't know who is on the outside.
And this isn't for show, it has a direct practical consequence. In the project's current phase, several services run with in-memory repositories: the data lives in RAM, with no real database, because in the MVP phase that makes development and testing much faster. When the time for the definitive database comes, I swap the Infrastructure layer and the rest of the service doesn't even notice. In messy code, where business rules are mixed with database access, that swap would be a rewrite.
Three other patterns complete the internal design:
Critical rules in one place only. A rule I made mandatory in the project: critical business rules live in a single domain library, and the APIs act only as a delivery and orchestration layer. No service reimplements a rule on its own. If the rule changes, it changes in one place.
CQRS. An ugly acronym for a simple idea: separating the write path from the read path. Taking down an order and browsing the menu are very different activities: writes go through heavy validation and generate events, reads just need to answer fast. By separating the two paths, each one is optimized for its job without getting in the other's way.
Ports & Adapters. Every external provider (third-party services, data providers) sits behind an adapter. The domain defines the shape of the socket (the "port") and each provider gets an adapter that fits into it. Need to switch providers? Write another adapter. The core doesn't change a single line.
How the services talk
That leaves the second question from the beginning: how the parts talk. Three decisions here.
Centralized authentication (SSO). Think of a gated community's front desk: a single place issues the badge, and each building only checks the badge at the entrance. In the project, a single service knows how to authenticate users and issue tokens (the digital badge). All the others only validate the token, using a shared library, and none of them even has the issuing code. Security concentrated in one place: one place to protect, one place to update when something changes.
HTTP with resilience. The services talk over synchronous HTTP (question and answer), but never naively, because networks fail. Every call between services goes through three protections: retry with backoff (an error happened, wait a bit and try again, waiting longer between attempts each time), timeout (nobody waits for an answer forever) and circuit breaker, which works like your house's breaker: after many consecutive failures, it trips, stops insisting and gives the struggling service time to recover, instead of hammering a service that is already down.
No messaging, for now. The natural evolution would be for the services to talk through a message broker (an asynchronous mailman: you drop off the message and move on with your life, the recipient picks it up when they can). It's in the plan. But the current flows are all question-and-answer, and here comes a principle I take seriously, YAGNI: you aren't gonna need it, don't build for a problem you don't have yet. Setting up messaging today would be building a huge structure to sit there unused. When asynchronous becomes a real need, the boundary is already ready to receive it.
Conclusion
Recapping the whole reasoning:
- A monolith is great to start with, but it scales, falls and deploys as one, and this product needs isolation
- A modular monolith organizes the inside, but the boundary is a promise, not a wall
- Microservices solve a big-company, many-teams problem, and charge a proportional complexity tax
- Macroservices: few services, divided by business subject, each with Clean Architecture inside. The isolation the product needs, without the tax it can't afford
- And since the boundaries follow DDD, splitting further in the future is an option, not a rewrite
In the end, the right question is never "which architecture is the best?", because that question has no answer. The right question is "how much separation do this product, this team and this moment need?". For this project, with this team size and this phase of life, the answer was: seven large services, with physical boundaries where the business asks for them, and not one more.
And the confidential project? Still confidential, but less and less so: soon I'll tell you what it is. For now, here's the X-ray of how it was built.
Sources
- Martin Fowler, Monolith First
- Martin Fowler, Microservices
- Martin Fowler, Microservice Premium
- Martin Fowler, Bounded Context (DDD)
- Robert C. Martin, The Clean Architecture
- Alistair Cockburn, Hexagonal Architecture (Ports & Adapters)
- Martin Fowler, CQRS
- Mel Conway, How Do Committees Invent? (Conway's Law)