Mert Özen Further With Every Line
Software Architecture Introduction to Software Architecture — #06

Understanding the Monolith: Single-Process, Modular, and Distributed Monoliths

Mert Özen Nov 7, 2026 14 min 4 views
Understanding the Monolith: Single-Process, Modular, and Distributed Monoliths

A monolith is not bad code but a single deployment unit; the real problem is the Big Ball of Mud, which forms in one process just as well as in twenty services. The difference between single-process, modular and distributed monoliths, module boundaries guarded by the compiler with internal, how a five-service synchronous chain misses a 99.9% target, and the Monolith First debate, all through Bookstore.

Version note: The code in this post was built and run with C# 14 and .NET 10.

Guess First

At the Bookstore team's weekly meeting someone brings a proposal: "The monolith is outdated. Let's split checkout into five services: Ordering, Inventory, Payment, Shipping, Notification. Each one its own process, HTTP (Hypertext Transfer Protocol) calls between them. If each service has 99.9% availability, checkout will have 99.9% too."

The checkout request goes through these five services in sequence and synchronously; if one does not answer, the order cannot be completed. Assume each service on its own is up 99.9% of the time. What do you think checkout's availability will be? Guess how many orders per month will fail. And think about this too: if all five services connect to the same database and every release requires deploying all five together, do we really have microservices, or something else?

We will answer with a simulation. But first we need to pin down what the word "monolith" means, because the proposal at the meeting uses it as a synonym for "bad code", and that sends the decision in the wrong direction from the start.

What Is a Monolith: A Single Deployment Unit

In an architectural context, monolith describes not how tidy the code is but how many pieces it is deployed as. Sam Newman, in Monolith to Microservices (O'Reilly, 2019), defines the monolith in terms of the deployment unit: if all the functionality of the system has to be deployed together as one unit, you have a monolith. Mark Richards and Neal Ford's FoSA (Fundamentals of Software Architecture, 2nd edition, O'Reilly, 2025) also splits styles along this axis first: monolithic styles with a single deployment unit and distributed styles with several.

There is no value judgment in that definition. An application running in a single process can be very clean or very messy. Bookstore today is a monolith: one ASP.NET Core application, one SQL (Structured Query Language) Server database, a deploy once a week. Let's see this with a small experiment. In the solution below, Catalog and Ordering are separate projects, but they all compile into one host (in the real Bookstore that host is Bookstore.Web; for the experiment we used a console application):

dotnet publish src/Bookstore.Host -c Release -o out
ls out | grep -E "^Bookstore\..*dll$"
Bookstore.Catalog.dll
Bookstore.Host.dll
Bookstore.Ordering.dll

Three DLLs (Dynamic Link Libraries), one publish folder, one process. Projects organize code; they do not change the deployment unit. That is why "we split the project into ten projects, so we are no longer a monolith" is wrong: if ten projects run in one process, you are still in a monolith.

Newman's distinction highlights three kinds. The single-process monolith is the classic form, where all the code is deployed as one process. The modular monolith is a subtype of it: still one process, but with modules separated inside and explicit boundaries between them. The distributed monolith is a system made of several services that all have to be deployed together. The book also mentions third-party black-box systems, whose source code you do not own, under this heading; we leave those out of this post.

06-monolith-types-en.png

Big Ball of Mud: How a Monolith Turns into Mud

The reason the monolith earned its bad name is really another pattern: Big Ball of Mud. Brian Foote and Joseph Yoder gave it that name in the paper they presented at the PLoP (Pattern Languages of Programs) conference in 1997. The system they describe is a codebase whose structure grew without a plan, whose organization reflects the expediency of the day more than design, and where information is shared haphazardly between distant parts. The paper's striking claim is that this is the most common architecture in practice.

Foote and Yoder list the forces that produce the mud one by one: time pressure, the fact that architecture costs now and pays later, a team that does not yet know the domain, differences in skill, the invisibility of architecture from the outside, the inherent complexity of the domain, unforeseen changes and scale. None of them is bad intent. In Bookstore the process goes like this: campaign week is coming, checkout needs a discounted price, and the developer writing OrderService adds a direct join to Catalog's price table. The next month an Inventory report reads the Orders table, and Notification fetches customer email addresses from the Customers table itself. Each step is reasonable on its own; taken together, nobody knows who depends on what.

The other patterns in the paper describe ways of living with the mud. Piecemeal Growth says that growing the system in small steps is unavoidable, but you have to tidy up after each step. Shearing Layers suggests separating parts that change at different rates. Sweeping It Under the Rug means hiding a messy area you cannot fix yet behind a clean interface; this is exactly what the Facade Pattern post in the Design Patterns series describes. Reconstruction means rewriting a system that cannot be saved.

The first lesson: Big Ball of Mud is not a deployment decision but a structural problem. Mud forms in a single process just as well as in a system spread across twenty services. If you split a muddy monolith into microservices, you usually end up with mud that talks over the network. That is why we will get to the distributed monolith shortly.

Technical and Domain Partitioning

The fastest way to understand the inside of a monolith is to look at what the code is split by. FoSA calls this distinction technical versus domain partitioning. The Layered Architecture from the previous post is the typical example of technical partitioning: code is separated by technical role, such as presentation, business and data.

src/
  Bookstore.Web/
    Controllers/   BooksController, OrdersController, ShipmentsController
  Bookstore.Business/
    Services/      CatalogService, OrderService, StockService, PaymentService
  Bookstore.Data/
    Repositories/  BookRepository, OrderRepository, StockRepository

In domain partitioning the top-level split follows business areas, and the technical roles move inside each module:

src/
  Bookstore.Catalog/     Book, Author, search, price display
  Bookstore.Ordering/    Order, OrderItem, checkout
  Bookstore.Inventory/   StockItem, reservation
  Bookstore.Payments/    Payment, provider integration
  Bookstore.Shipping/    Shipment, tracking

The difference shows most clearly in a real change request. "Add an order note" touches three projects under technical partitioning: a field in the controller, a rule in the service, a column in the repository. Under domain partitioning the change stays inside one module. Conversely, a technical change like "move all data access to Dapper" touches one project under technical partitioning and every module under domain partitioning. Which kind of change you make more often tells you which split fits you. In Bookstore most requests come from the business side: a search filter, partial cancellation, a shipment tracking page. That is a strong signal to move toward domain partitioning as the system grows.

Partitioning is also directly tied to team structure. If you split the code by layer and build teams by layer too, every feature needs three teams to coordinate. We will measure this with Conway's Law in the eighth post.

Modular Monolith: Let the Compiler Guard the Boundary

A modular monolith means taking domain partitioning seriously inside a single deployment unit. Each module exposes a small public contract and hides everything else. Modules use each other only through that contract, with no network, through an ordinary method call. In C# the cheapest tool for this is the internal access modifier:

using Microsoft.Extensions.DependencyInjection;

namespace Bookstore.Catalog;

// The module's public contract: only these are public
public interface ICatalogApi
{
    BookSummary? FindBook(string isbn);
}

public sealed record BookSummary(string Isbn, string Title, decimal Price);

public static class CatalogModuleRegistration
{
    public static IServiceCollection AddCatalogModule(this IServiceCollection services) =>
        services.AddSingleton<ICatalogApi, CatalogApi>();
}

// Inside the module: entity and implementation are internal
internal sealed class Book(string isbn, string title, string author, decimal price)
{
    public string Isbn { get; } = isbn;
    public string Title { get; } = title;
    public string Author { get; } = author;
    public decimal Price { get; } = price;
}

internal sealed class CatalogApi : ICatalogApi
{
    private readonly Dictionary<string, Book> _books = new()
    {
        ["978-0441013593"] = new("978-0441013593", "Dune", "Frank Herbert", 12.50m),
        ["978-0141439518"] = new("978-0141439518", "Emma", "Jane Austen", 6.00m)
    };

    public BookSummary? FindBook(string isbn) =>
        _books.TryGetValue(isbn, out Book? book) ? new BookSummary(book.Isbn, book.Title, book.Price) : null;
}

The Ordering module knows Catalog only through ICatalogApi:

using Bookstore.Catalog;
using Microsoft.Extensions.DependencyInjection;

namespace Bookstore.Ordering;

public sealed record OrderConfirmation(Guid OrderId, string Title, decimal Total);

public interface IOrderService
{
    OrderConfirmation PlaceOrder(string customerId, string isbn, int quantity);
}

public static class OrderingModuleRegistration
{
    public static IServiceCollection AddOrderingModule(this IServiceCollection services) =>
        services.AddSingleton<IOrderService, OrderService>();
}

// Reaches Catalog only through its public contract, with an in-process method call
internal sealed class OrderService(ICatalogApi catalog) : IOrderService
{
    public OrderConfirmation PlaceOrder(string customerId, string isbn, int quantity)
    {
        BookSummary book = catalog.FindBook(isbn)
            ?? throw new InvalidOperationException($"Unknown book {isbn}");
        return new OrderConfirmation(Guid.CreateVersion7(), book.Title, book.Price * quantity);
    }
}

The host registers both modules and places an order:

using System.Globalization;
using Bookstore.Catalog;
using Bookstore.Ordering;
using Microsoft.Extensions.DependencyInjection;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

ServiceCollection services = new();
services.AddCatalogModule().AddOrderingModule();
using ServiceProvider provider = services.BuildServiceProvider();

IOrderService orders = provider.GetRequiredService<IOrderService>();
OrderConfirmation confirmation = orders.PlaceOrder("customer-42", "978-0441013593", 2);
Console.WriteLine($"{confirmation.Title} x2 = {confirmation.Total}");

IEnumerable<string?> modules = AppDomain.CurrentDomain.GetAssemblies()
    .Select(a => a.GetName().Name)
    .Where(name => name!.StartsWith("Bookstore."))
    .Order();
Console.WriteLine($"Loaded modules: {string.Join(", ", modules)}");
Dune x2 = 25.00
Loaded modules: Bookstore.Catalog, Bookstore.Host, Bookstore.Ordering

The real value appears when you try to break the rule. Imagine someone in campaign week reaching straight for Catalog's entity "because it is faster":

using Bookstore.Catalog;

namespace Bookstore.Ordering;

internal static class Shortcut
{
    // Reaching straight into Catalog's entity "because it is faster"
    public static decimal PriceOf(string isbn) => new Book(isbn, "?", "?", 0m).Price;
}
src/Bookstore.Ordering/Shortcut.cs(8,55): error CS0122: 'Book' is inaccessible due to its protection level

The small step that starts a Big Ball of Mud does not compile here. You handed the boundary to the compiler instead of a code review comment. That has a cost too: every piece of information Ordering needs requires widening Catalog's contract, which means asking "what am I exposing?" every time. A good module contract stays small and stable; the "Component Principles: Splitting a .NET Solution into Projects" post in the SOLID (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion) and Design Principles series covers the measures of that balance.

internal alone is not enough. The InternalsVisibleTo attribute can open a module's internals to another assembly, and once it is opened for tests it starts getting used for other purposes. More importantly there is the data: if Ordering can reach Catalog's tables directly with SQL, the boundary at the C# level exists only on paper. Schema per module, integration events between modules and protecting boundaries with tests are the subject of the upcoming Modular Monolith series; we will take the first step toward protecting boundaries with automated tests in the last post of this series.

Distributed Monolith: Paying the Cost Without Getting the Benefit

A distributed monolith is a system split into several services that cannot be deployed independently of one another. The test Newman often repeats is simple: if changing one service forces you to deploy the others at the same time, you have built a distributed monolith, not microservices. What microservices promise is independent deployability; without it, all that is left is the cost of the network.

The symptoms usually show up together:

  • On release day all services are deployed at the same time and in a specific order.
  • Services share the same database, even the same tables; one column change breaks several services.
  • There is a shared NuGet package carrying the domain model, and when its version changes everyone redeploys.
  • A user request passes synchronously through several services and every link in the chain decides the request's fate.
  • An error or slowdown in one service shows up as timeouts in the others.

Back to the opening question. We simulated checkout going synchronously through five services with one million requests. We assumed each hop succeeds 99.9% of the time and usually responds in 15–25 ms, but with a 2% chance takes 200–300 ms:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

// The checkout request goes synchronously through 5 services in order
string[] chain = ["Ordering", "Inventory", "Payment", "Shipping", "Notification"];
const double hopAvailability = 0.999;   // each service alone is 99.9%
const int requests = 1_000_000;
Random random = new(42);

int succeeded = 0;
List<double> latencies = new(requests);
for (int i = 0; i < requests; i++)
{
    double total = 0;
    bool ok = true;
    foreach (string _ in chain)
    {
        total += Hop(random);
        if (random.NextDouble() > hopAvailability) { ok = false; break; }
    }
    if (ok) { succeeded++; latencies.Add(total); }
}

latencies.Sort();
List<double> single = Enumerable.Range(0, requests).Select(_ => Hop(random)).Order().ToList();

Console.WriteLine($"Expected availability   : {Math.Pow(hopAvailability, chain.Length):P2}");
Console.WriteLine($"Observed availability   : {(double)succeeded / requests:P2}");
Console.WriteLine($"Failed checkouts        : {requests - succeeded:N0} of {requests:N0}");
Console.WriteLine($"Single hop  p50/p90/p99 : {P(single, 0.50):F0} / {P(single, 0.90):F0} / {P(single, 0.99):F0} ms");
Console.WriteLine($"Whole chain p50/p90/p99 : {P(latencies, 0.50):F0} / {P(latencies, 0.90):F0} / {P(latencies, 0.99):F0} ms");

// One hop: mostly 15-25 ms, with a 2% chance of a slow 200-300 ms response
static double Hop(Random r) => r.NextDouble() < 0.02 ? 200 + r.NextDouble() * 100 : 15 + r.NextDouble() * 10;

static double P(List<double> sorted, double q) => sorted[(int)Math.Ceiling(q * sorted.Count) - 1];
Expected availability   : 99.50 %
Observed availability   : 99.51 %
Failed checkouts        : 4,894 of 1,000,000
Single hop  p50/p90/p99 : 20 / 24 / 250 ms
Whole chain p50/p90/p99 : 101 / 116 / 374 ms

In a synchronous chain availabilities multiply: 0.999 to the power of 5 is about 0.995. Bookstore's monthly 99.9% target for checkout is missed even though each service meets that target on its own; about 4,900 failures per million checkouts, five times what the target allows. Latency tells a similar story. A single hop is slow with a 2% chance, but in a five-hop chain about 10% of requests (1 − 0.98⁵) hit at least one slow hop. Slowness that a single service's p90 never sees has moved right up to the edge of the chain's p90, and p99 went from 250 ms to 374 ms.

If the same five parts were in-process method calls in a modular monolith, none of these multiplications would happen: the process is either up or it is not. That does not mean distributed systems are bad. You should pay the cost of the network only if you get something in return: independent deployment, independent scaling, fault isolation. A distributed monolith pays the cost and gets none of these. Tools like timeouts, retries and circuit breakers soften the chain, but the problem itself is in the architecture; those tools will be covered in the System Design series, and independent deployability and service boundaries in the Microservices series.

Monolith First and the Counterargument

In his MonolithFirst article from June 2015, Martin Fowler shares two observations: almost all the successful microservice stories he heard started with a monolith that grew and was split, and almost all systems built as microservices from scratch ran into serious trouble. His reasoning has two legs. The first is speed and uncertainty: while you do not yet know whether the product gives users value, the overhead of microservices slows you down. The second is boundaries: drawing service boundaries correctly up front is very hard, and moving a wrongly drawn boundary between services is much more expensive than moving it inside a monolith. Fowler also accepts that the monolith may eventually be thrown away completely, and treats it as a deliberate "sacrificial architecture".

The counterargument comes from Stefan Tilkov in "Don't start with a monolith", published six days later, also on martinfowler.com. In Tilkov's view the easiest moment to split a system is when you design it from scratch; module boundaries inside a monolith are rarely kept in practice, shared domain objects, shared transactions and a shared persistence model tie everything together over time, and splitting it later often becomes nearly impossible. He has an important condition, though: he recommends this approach when you know the domain very well, ideally when you are writing the second version of an existing system.

The two articles do not exclude each other. Fowler's worry is the wrong boundary; Tilkov's is the unprotected boundary. Read together, the practical conclusion is this: think about boundaries early, but keep them where they are cheap to change, inside a single deployment unit, and automate their protection. The modular monolith is exactly that middle road.

That is why Bookstore's first form is a monolith. The team is 3 developers and a part-time ops person; a weekly deploy is enough; the domain's boundaries (is partial refund Ordering's job or Payment's?) are still settling, and independent deployability was deliberately marked "not for now" among the priority characteristics. Under these conditions there is nobody to carry the deployment, monitoring and network overhead five services would bring. Nor is the decision irreversible: if module boundaries are kept, extracting a module into a service later remains possible.

Common Pitfalls

Equating the monolith with bad code. The problem is not the number of deployment units but the internal structure. Splitting a muddy monolith moves the mud onto the network.

Confusing project count with modularity. If everything in a fifty-project solution is public and every project references the others, you have one module spread over fifty folders. Modularity is decided by what stays hidden, not by the number of projects.

Forgetting the database. A boundary protected with internal on the C# side is breached the moment a reporting query joins every table. Without schema per module, or at least a "who writes to this table" list, a modular monolith is only half done.

Growing the shared kernel. First Money goes into a project called "Common", then Customer, then OrderStatus; after a while every module is tied to every other through the shared project. Keep shared code small and independent of the domain.

Mistaking a distributed monolith for microservices. Services are not independent just because they run in separate containers. The criterion is whether one service can be deployed without touching the others.

Reading Monolith First as "start without modules". Fowler is not proposing a monolith without boundaries; on the contrary, he says a well-modularized monolith makes splitting later easier.

When Should You Leave the Monolith?

The reason to leave a monolith should not be the monolith itself but a concrete characteristic it cannot meet. The real signals are these: different teams are slowed down waiting for each other's releases, and it shows up in a measurable lead time; one part's load is very different from the rest and scaling it separately makes a real cost difference (like catalog search during Bookstore's campaign periods); one part crashing takes the whole system down and that isolation matters to the business; or one part needs a completely different technology.

If these signals are absent, "Netflix uses microservices too" is not a reason. Even when the signals are present, the first step is usually to sharpen module boundaries and then extract a single module, usually the most independent one. The techniques for that move, patterns like Strangler Fig and Branch by Abstraction, will be covered in the last post of the Microservices series.

Be careful in the other direction too: some systems never leave the monolith, and there is nothing wrong with that. For an application maintained by one team, whose load fits on one server and which is deployed once a week, a modular monolith is often the least worst option.

A Small Experiment

★☆☆ Look at the dotnet publish output of a solution you work on. How many deployment units are there? In how many of the projects does the internal keyword never appear? What do those two numbers tell you about modularity?

★★☆ Add an Inventory module next to the Catalog and Ordering modules in this post. Have OrderService check stock before placing an order, but only through Inventory's public contract. Then try to reach an internal type of Inventory from Ordering and see the CS0122 error. What information did you have to add to the contract?

★★★ Change the simulation: take the Notification call out of the synchronous chain and run it only after successful checkouts, as "fire and forget" without waiting for the result. How do availability and p99 change? Then raise Notification's error rate to 1%; is checkout affected? What does this change cost (lost emails, the need for retries)?

In this post we talked about the three kinds of monolith and the lines between them. In the next post we widen the map: next to layered and the modular monolith come pipeline, microkernel, event-driven, space-based, service-based and microservices. We will gather in one table which constraints define each, which characteristics they are good or bad at in the Bookstore context, and which series goes deeper into each.