Module 1 · Boundaries and Communication · Lesson 1 of 16
Modular Monolith vs Microservices: Boundaries, Data Ownership and When to Split
Learning outcome
You can explain what a service boundary is (a unit of data ownership and independent deployment, not a folder of controllers), argue for a modular monolith as the default starting point, name the signals that justify extracting a service, and show how modules inside one process can already follow microservice rules: private data, public contracts and communication through events. Every example in this course runs on .NET 10. Messaging examples use MassTransit 8 on its in-memory transport, EF Core 10 on SQLite, and Confluent.Kafka without a broker, so you can run all of them on a laptop with no Docker.
Intuition
The hard part of microservices is not HTTP or queues. It is deciding who owns which data and accepting that independently committed changes usually agree eventually. A modular monolith makes the same decisions about ownership, but keeps one process and one deployment. You keep the cheap parts (in-process calls, one transaction when you really need it, one deploy pipeline) until a real force (team autonomy, scaling profile, release cadence, fault isolation) pays for the network.
flowchart LR
subgraph Monolith["Modular monolith (one process)"]
O["Ordering module<br/>ordering_* tables"] -- "OrderPlaced (public contract)" --> B["Billing module<br/>billing_* tables"]
end
subgraph Services["Microservices (many processes)"]
OS["Ordering service + its DB"] -- "OrderPlaced via broker" --> BS["Billing service + its DB"]
end
Monolith -- "extract when a force justifies it" --> ServicesDeep dive
- A boundary is about data. A module or service owns its tables. Nobody else reads or writes them directly. Other parts get data through an API call, a published event, or a copy they maintain themselves.
- Find boundaries with the domain, not the layers. Bounded contexts from domain-driven design are the usual tool: where the same word ("Order", "Customer") means different things to different teams, you probably have two contexts. Splitting into "API service", "business service" and "data service" is the classic wrong cut: every feature then needs all three to change and deploy together.
- Modular monolith rules that keep the option to extract later:
- one schema (or table prefix) per module, with a separate
DbContextper module; - modules expose a small public surface (contracts, events) and keep everything else
internal; - cross-module reads go through the owning module's API, never through its tables;
- architecture tests (reflection, NetArchTest-style rules) fail the build when a module reaches into another.
- one schema (or table prefix) per module, with a separate
- Reasons to extract a service (each one must be real, not hypothetical):
- a team needs to release on its own schedule without coordinating a shared deploy;
- a part has a very different scaling or resource profile (CPU-heavy image processing next to a light CRUD API);
- fault isolation: a crash or memory leak in one part must not take down checkout;
- a different technology or compliance boundary (PCI scope for payments).
- What you pay when you split: network latency and partial failure on every call, no automatic cross-service ACID transaction (Lesson 4 onwards), duplicated and eventually consistent data, distributed tracing (Lesson 12), versioned contracts (Lesson 3), and more pipelines and on-call surface.
- The distributed monolith is the worst of both: services that must deploy together, share a database, or call each other synchronously in long chains. If one service cannot be released without another, the boundary is wrong.
- Strangler fig migration: put a routing layer in front of the monolith and move one capability at a time behind it, instead of a big-bang rewrite.
Code example: two modules, one process, microservice rules
// Modular monolith: two modules, one process, one database file, separate schemas (table prefixes) and DbContexts.
// Modules talk only through a public contract (an integration event) on an in-process bus.
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Shop.Contracts;
using Shop.Ordering;
using Shop.Billing;
using var conn = new SqliteConnection("Data Source=:memory:");
conn.Open();
var bus = new InProcessBus();
var ordering = new OrderingModule(conn, bus);
var billing = new BillingModule(conn);
bus.Subscribe<OrderPlaced>(billing.Handle);
ordering.Init(); billing.Init();
await ordering.PlaceOrderAsync(customer: "alice", total: 120m);
await ordering.PlaceOrderAsync(customer: "bob", total: 75.5m);
Console.WriteLine($"orders (ordering_orders) : {ordering.Count()}");
Console.WriteLine($"invoices(billing_invoices) : {billing.Count()}");
foreach (var i in billing.All()) Console.WriteLine($" invoice #{i.Id}: amount {i.Amount}");
// Architecture test: no Billing type may reference an Ordering type (and vice versa).
static IEnumerable<string> Violations(string from, string to) =>
typeof(Program).Assembly.GetTypes().Where(t => t.Namespace == from)
.SelectMany(t => t.GetFields(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public)
.Select(f => f.FieldType).Concat(t.GetMethods().SelectMany(m => m.GetParameters().Select(p => p.ParameterType)))
.Where(ft => ft.Namespace == to).Select(ft => $"{t.Name} -> {ft.Name}"));
Console.WriteLine($"boundary check Billing->Ordering: {Violations("Shop.Billing", "Shop.Ordering").Count()} violations");
Console.WriteLine($"boundary check Ordering->Billing: {Violations("Shop.Ordering", "Shop.Billing").Count()} violations");
namespace Shop.Contracts
{
public sealed record OrderPlaced(Guid OrderId, string Customer, decimal Total);
public sealed class InProcessBus
{
readonly Dictionary<Type, List<Func<object, Task>>> _h = new();
public void Subscribe<T>(Func<T, Task> h) => (_h.TryGetValue(typeof(T), out var l) ? l : _h[typeof(T)] = new()).Add(o => h((T)o));
public async Task PublishAsync<T>(T e) { foreach (var h in _h.GetValueOrDefault(typeof(T)) ?? new()) await h(e!); }
}
}
namespace Shop.Ordering
{
using Shop.Contracts;
class Order { public Guid Id { get; set; } public string Customer { get; set; } = ""; public decimal Total { get; set; } }
class OrderingDb(DbContextOptions<OrderingDb> o) : DbContext(o)
{ public DbSet<Order> Orders => Set<Order>(); protected override void OnModelCreating(ModelBuilder b) => b.Entity<Order>().ToTable("ordering_orders"); }
public sealed class OrderingModule(SqliteConnection c, InProcessBus bus)
{
OrderingDb Db() => new(new DbContextOptionsBuilder<OrderingDb>().UseSqlite(c).Options);
public void Init() { using var db = Db(); db.Database.ExecuteSqlRaw("CREATE TABLE ordering_orders (Id TEXT PRIMARY KEY, Customer TEXT NOT NULL, Total TEXT NOT NULL)"); }
public async Task PlaceOrderAsync(string customer, decimal total)
{
using var db = Db();
var o = new Order { Id = Guid.NewGuid(), Customer = customer, Total = total };
db.Orders.Add(o); await db.SaveChangesAsync();
await bus.PublishAsync(new OrderPlaced(o.Id, customer, total));
}
public int Count() { using var db = Db(); return db.Orders.Count(); }
}
}
namespace Shop.Billing
{
using Shop.Contracts;
public class Invoice { public int Id { get; set; } public Guid OrderId { get; set; } public decimal Amount { get; set; } }
class BillingDb(DbContextOptions<BillingDb> o) : DbContext(o)
{ public DbSet<Invoice> Invoices => Set<Invoice>(); protected override void OnModelCreating(ModelBuilder b) => b.Entity<Invoice>().ToTable("billing_invoices"); }
public sealed class BillingModule(SqliteConnection c)
{
BillingDb Db() => new(new DbContextOptionsBuilder<BillingDb>().UseSqlite(c).Options);
public void Init() { using var db = Db(); db.Database.ExecuteSqlRaw("CREATE TABLE billing_invoices (Id INTEGER PRIMARY KEY AUTOINCREMENT, OrderId TEXT NOT NULL UNIQUE, Amount TEXT NOT NULL)"); }
public async Task Handle(OrderPlaced e) { using var db = Db(); db.Invoices.Add(new Invoice { OrderId = e.OrderId, Amount = e.Total }); await db.SaveChangesAsync(); }
public int Count() { using var db = Db(); return db.Invoices.Count(); }
public List<Invoice> All() { using var db = Db(); return db.Invoices.OrderBy(i => i.Id).ToList(); }
}
}Output (.NET 10, EF Core 10.0.12, SQLite in memory)
orders (ordering_orders) : 2 invoices(billing_invoices) : 2 invoice #1: amount 120.0 invoice #2: amount 75.5 boundary check Billing->Ordering: 0 violations boundary check Ordering->Billing: 0 violations
Code walkthrough
- Each module has its own
DbContextand its own tables (ordering_orders,billing_invoices). They happen to share one SQLite connection, which is the modular-monolith shortcut. Extraction later means giving Billing its own database without changing its code. - Ordering never calls Billing. It publishes
OrderPlaced, a record in the sharedShop.Contractsnamespace, and Billing subscribes. Both orders produced an invoice: 2 orders, 2 invoices. - The two boundary checks scan fields and method parameters with reflection and found 0 violations in each direction. In a real solution you put modules in separate projects and make the rule a unit test, so a forbidden reference fails CI.
- The in-process bus calls the handler inline. That is deliberate here, and it hides a problem you will meet in Lesson 4: the order is saved, then the event is published. If the process dies between the two, Billing never hears about the order.
Failure modes
- Splitting by technical layer (UI service, logic service, data service) instead of by business capability.
- Shared database between "services": a schema change in one breaks the others, so they must deploy together.
- Long synchronous call chains (A calls B calls C calls D): availability multiplies down (four independently available services at 99.9% each give about 99.6% for the chain) and latency adds up.
- Extracting services before the domain is understood, then paying for every boundary mistake with a cross-service migration.
- "Nano-services": one service per entity, where every use case needs five network calls.
Interview angle
"Would you start this system with microservices?" A strong answer: "Usually not. I would start with a modular monolith with strict module boundaries: own schema, internal types, events between modules, architecture tests. I would extract a module when there is a concrete force: an independent team and release cadence, a different scaling profile, or fault isolation. The boundary is about data ownership; if two services share tables they are one service with extra network hops."
Practice questions
- What makes a boundary a service boundary?
Answer: Exclusive ownership of its data plus independent deployability. Code organization alone is not enough.
- Two services read and write the same
Customerstable. What is the problem?
Answer: Neither owns the data, so schema changes couple their releases and invariants can be broken by either side. It is a distributed monolith. One must own the table and the other must use its API or events.
- Name two signals that justify extracting a module into a service.
Answer: Any two of: independent team release cadence, different scaling/resource profile, fault isolation, different technology or compliance boundary.
- How can you enforce module boundaries inside a monolith?
Answer: Separate projects with internal types, one DbContext and schema per module, communication via public contracts or events, and architecture tests that fail the build on forbidden references.
Revision checklist
- Boundary = data ownership + independent deployment.
- Default to a modular monolith; extract for team, scale, fault isolation or compliance reasons.
- No shared tables; no long synchronous chains.
- Strangler fig for migration, not a rewrite.
Related on TechInterviewNotes
- Course lesson: System Design for .NET Developers: representative architectures
- Course lesson: System Design Fundamentals: reference architecture map
- Course: ASP.NET Core Internals (the host each service runs in)
- Mock: Mock Interview: System Design for .NET
Analogy
Departments in one building vs separate companies
A modular monolith is one company with departments. Each department keeps its own files and you ask them for information, but you share a building, a payroll system and one annual plan. Microservices are separate companies that sign contracts with each other. They can move faster on their own, but every request now goes through a contract, a delivery and the chance that the other company is closed today.
Quick reference
Boundary rules
- One owner per table; others use API, events or their own copy.
- One
DbContext(and schema) per module/service. - Public contracts small and versioned; everything else
internal. - Architecture tests in CI.
- Extract only for a named force: team cadence, scaling, fault isolation, compliance.