Start With a Loosely Coupled Monolith
Greenfield project, small team, no microservices on day one. How to structure the monolith so that splitting it later is a deployment decision and not a rewrite.
The most expensive mistake a small team can make on a greenfield project is to start with microservices. You are entering a business domain with no existing system, no data, and no users. You do not know where the boundaries are. Nobody does. Cutting a system into services means committing to boundaries, and committing to boundaries you have not discovered yet is guessing with the most expensive kind of chips.
The Problem You are in a dark room
A new domain is a dark room and you have a flashlight. You point it at one corner and see the outline of something. You move, point it somewhere else, and your picture of the room changes. After six months of shipping and customer feedback, the picture changes again.
- You do not know the business capabilities the system will need.
- You do not know which data belongs together and which only looks like it does.
- Your first idea of the structure will be wrong. Not maybe. Will.
Cut the room into ten services on day one and you will draw the walls in the wrong places. Moving a wall between two microservices means two repositories, two deployments, API versioning, backward compatibility and a migration of data across a network boundary. That is not refactoring. That is a project.
The Misconception Monolith does not mean big ball of mud
Most developers hear "monolith" and picture the system they left: three hundred tables, every class referencing every other class, one change breaking five unrelated modules. That is a big ball of mud. It is a failure of discipline, not a property of the deployment model.
A loosely coupled monolith applies the rules of microservices inside one application and one deployment:
-
One module per bounded context. Sales, Warehouse and Billing are separate
projects in the solution. A module exposes a small public surface and keeps everything else
internal. The compiler enforces the boundary. - Each module owns its tables. Billing never queries or joins a Sales table. Ever. If Billing needs order data, it gets it through a message or through the Sales module's public API, never through SQL.
-
One database server, one schema per module. The
sales,warehouseandbillingschemas live on the same server, each with its ownDbContextand its own migrations. Cost and operations of one database, isolation of three. If a module ever needs its own physical database, it is one connection string away.
The project references are the rulebook. Sales, Warehouse and Billing reference Contracts. None of them references each other. The Host references all of them and nothing else.
Communication Modules talk through messages
Since modules cannot share tables and cannot reference each other, they communicate the way
services do: asynchronously, through messages. Sales completes an order and publishes
OrderPlaced. Billing and Warehouse, in the same process, consume it and do their
part. Nobody knows about anybody. They know about contracts.
// Contracts project. The only thing modules share.
public record OrderPlaced(Guid OrderId, Guid CustomerId, decimal Total, DateTime PlacedUtc);
// Sales module
public sealed class PlaceOrderHandler(SalesDbContext db, IPublishEndpoint bus)
{
public async Task Handle(PlaceOrderCommand cmd, CancellationToken ct)
{
var order = Order.Place(cmd.CustomerId, cmd.Lines);
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
await bus.Publish(new OrderPlaced(order.Id, order.CustomerId, order.Total, order.PlacedUtc), ct);
}
}
// Billing module. Same process, different schema, no reference to Sales.
public sealed class CreateInvoiceOnOrderPlaced(BillingDbContext db) : IConsumer<OrderPlaced>
{
public async Task Consume(ConsumeContext<OrderPlaced> ctx)
{
db.Invoices.Add(Invoice.From(ctx.Message));
await db.SaveChangesAsync(ctx.CancellationToken);
}
}
The broker sits outside the process. That is deliberate. The day Billing becomes its own service, the consumer above does not change a line. It just runs in a different process.
The Payoff Being wrong is cheap
You will get a boundary wrong. A rule you put in Sales turns out to belong to Billing. A table you put in Warehouse is really two tables in two modules. This is not a risk. It is the plan.
| Moving a rule from Sales to Billing | Loosely coupled monolith | Microservices |
|---|---|---|
| Code | Move the files to another project, fix the namespace | Two repositories, two pull requests, two reviews |
| Data | One migration moving the table between schemas | Cross-database migration, dual writes or backfill |
| Contract | Change the record, the compiler lists every caller | Version the API, keep the old one alive, coordinate consumers |
| Release | One deployment | Ordered deployments, and a window where versions disagree in production |
| Time | An afternoon | A sprint, if nothing goes wrong |
Every correction to the model is a local change in one codebase, checked by one compiler, shipped in one deployment. That is the whole argument. The monolith is not the cheap option because it has less architecture. It is the cheap option because it lets you fix the architecture.
The Split When to extract a service
Build it as described and the extraction is mechanical: the module already has its own schema, its own contracts and its own consumers. You move the project into a new host, point it at the same broker, and deploy. There are exactly two reasons to do that.
- Team scale. Three engineers became thirty, and they block each other on the same deployment pipeline. Extract the module the busiest team owns.
- Uneven load. Sales takes a hundred thousand requests per second and Billing takes ten. Scaling the whole process for Sales wastes money. Extract Sales, scale it alone.
The Rule One monolith, real boundaries
Write clean module boundaries inside one application. Give every module its own schema. Let modules talk only through messages. When the team or the load outgrows a single deployment, pulling a well-defined module into its own service is a few days of infrastructure work. Doing the same to a system that started as ten badly cut services is years of refactoring, and most teams never finish it.
- When a DTO Is Worth Writing: Composition, Boundaries and CQRS with MediatR
- 5 Rules for Writing Better DTOs in .NET (and the Most Common Traps)
- Concurrency and Locking in .NET: The Series