Start with a modular monolith
Microservices solve problems most new systems don't have yet. A modular monolith gives you clean boundaries now and an easy path to split later, if you ever need to.
When a new system is being designed, someone usually suggests microservices. The appeal is real: independent deployment, independent scaling, teams that don't step on each other. So are the costs: network calls where there used to be method calls, distributed transactions, eventual consistency, a deployment pipeline per service and a lot more to monitor.
Those costs arrive on day one. The benefits mostly show up when you have several teams and parts of the system with very different scaling needs. Many systems never get there.
What a modular monolith is
One deployable application, split internally into modules with hard boundaries. Each module:
- owns a business capability, such as Orders, Billing or Shipping
- owns its data, ideally in its own database schema, and no other module touches those tables
- exposes a small public contract and keeps everything else
internal - talks to other modules only through that contract or through in-process events
In a .NET solution that often looks like one project per module, plus a host project that wires them together:
src/
Host/ ASP.NET Core app, references every module
Modules/
Orders/ Orders.csproj (domain, data, endpoints)
Orders.Contracts/ public interfaces and events
Billing/
Billing.Contracts/
Billing may reference Orders.Contracts, never Orders itself.
Keep the boundaries honest
Boundaries erode one "quick" cross-module query at a time. A few habits stop that:
- Mark implementation types
internal. The compiler then enforces most of the rules for you. - Separate schemas per module. A join across modules becomes an obvious violation rather than a convenient shortcut.
- Write architecture tests. Libraries such as NetArchTest or ArchUnitNET fail the build when one module references another's internals.
Why it's a good starting point
- Refactoring is cheap. If you drew a boundary in the wrong place, moving code between modules is an IDE refactor, not a migration between two services.
- Transactions still work. A use case that spans two modules can still run in one database transaction while you learn whether it should.
- One thing to deploy and monitor. Your observability, CI and hosting stay simple.
Splitting out later
If one module eventually needs to scale on its own, or a separate team takes it over, its boundary is already defined. Replace its in-process contract with an HTTP API or messages, move its schema to its own database, and deploy it separately. The rest of the system barely notices.
Takeaway
Start with one deployable application and strict internal boundaries. You get most of the design benefits of microservices without paying their operational cost up front, and you keep the option to split when you have a concrete reason.