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

THE DOMAIN ON DAY ONE Sales orders, pricing, customers, probably Billing Warehouse Shipping Returns
Day one of a greenfield domain. The flashlight shows one context. The rest of the room is a guess.

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.

LOOSELY COUPLED MONOLITH · ONE PROCESS · ONE DEPLOYMENT Sales module, internal classes Warehouse module, internal classes Billing module, internal classes owns owns owns schema: sales schema: warehouse schema: billing no cross-schema joins no cross-schema joins one database server, three schemas, three DbContexts, three migration histories
Three bounded contexts in one process. Each module owns its schema. The only thing they share is the server.

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, warehouse and billing schemas live on the same server, each with its own DbContext and 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.
src/ Sales/ Sales.csproj DbContext, migrations, schema "sales" Warehouse/ Warehouse.csproj DbContext, migrations, schema "warehouse" Billing/ Billing.csproj DbContext, migrations, schema "billing" Contracts/ Contracts.csproj message types only, no logic Host/ Host.csproj references all modules, one deployment

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

MONOLITH PROCESS no project references between modules, no shared tables Sales schema: sales Warehouse schema: warehouse Billing schema: billing publishes OrderPlaced consumes consumes message broker RabbitMQ, Azure Service Bus, Kafka outside the process, shared by every module
Modules never reference each other. Sales publishes to a broker outside the process, and Warehouse and Billing consume from it inside the same process.

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

MONOLITH, YEAR ONE Sales Warehouse Billing team grew, Sales needs twenty times the capacity move one project to a new host, deploy Sales service own deployment, scaled alone Monolith: Warehouse, Billing unchanged, still one deployment OrderPlaced same consumer, different process message broker
The split is a deployment change. Sales moves to its own host, the consumers in the monolith do not change a line, and the broker was already there.

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.
Not a reason: conference talks, the CV of whoever is designing it, or the feeling that a real system should have a service mesh. Microservices are a solution to organizational and scaling problems. A team of three with no users has neither.

The Rule One monolith, real boundaries

Do not start greenfield work with microservices. Spend the early months discovering where the boundaries are, because that is the hard part and nothing else matters until it is done.

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.


More posts
← All posts  ·  RSS © 2026 DotNET Leet