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 dark room on day one of a greenfield domain. A flashlight lights the Sales corner. Billing, Warehouse, Shipping and Returns sit in the dark.
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.

One process containing Sales, Warehouse and Billing modules. Each module owns its own schema on one shared database server. Joins between schemas are crossed out.
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

Sales, Warehouse and Billing modules inside one process with no references between them. Sales publishes OrderPlaced to a message broker outside the process. Warehouse and Billing consume it inside the same process.
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

The monolith at the start with Sales, Warehouse and Billing modules. Later, Sales is extracted into its own deployment while Warehouse and Billing stay in the monolith. Both sides keep talking through the same 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