Testing Modular Monoliths: System Integration Testing in .NET 10
A friendly .NET 10 guide to system integration testing for modular monoliths: test module boundaries, cross-module flows, real databases with Testcontainers.
The railway station analogy
Think of a big railway station like the ones in Mumbai or Chennai. It has a ticket counter, a luggage room, a platform announcement system, and a train scheduling office. Each part has its own staff and its own job. The ticket clerk can sell tickets all day. The announcer can read names all day. Each one works fine on their own.
But a passenger journey is not one counter. A passenger buys a ticket, the system books a seat, the luggage room tags the bags, and the announcer calls the right platform at the right time. The real question is not "can the ticket clerk sell a ticket?" It is "when a passenger buys a ticket, does the seat get booked, the luggage tagged, and the platform announced, all in the correct order?"
A modular monolith is that station. Each counter is a module. The counters are separate and tidy, but they still serve one journey together. System integration testing is sending one pretend passenger through the whole station and checking that every counter did its part.
This guide shows you how to write those tests in .NET 10, in plain steps a beginner can follow.
What a modular monolith looks like
A modular monolith is one app you deploy as a single unit. Inside, it is split into modules. Each module keeps its code together, owns its own data, and hides its insides. Other modules can only talk to it through a small public door.
The key rule: a module never reaches into another module's tables or internal classes. It asks through a public interface or reacts to an event. This is what keeps the monolith clean and what makes it possible to split into microservices later if you ever need to.
Because everything runs in one process, testing is actually easier than with microservices. There is no network to mock and no flaky service to wait for. You can start the whole app and watch modules cooperate in real time.
Where system integration tests fit
You still want the test pyramid. Lots of small, fast unit tests at the bottom. Some module-level integration tests in the middle. A smaller number of system integration tests at the top that cross module boundaries.
Here is the difference in one table.
| Test type | What it checks | Speed | Uses a real DB? |
|---|---|---|---|
| Unit test | One class or method on its own | Very fast | No |
| Module integration test | One module end to end | Medium | Yes (Testcontainers) |
| System integration test | Two or more modules working together | Slower | Yes (Testcontainers) |
System integration tests are the focus here. They are the ones that catch the bug where Sales thinks it told Inventory to reduce stock, but Inventory never heard the message.
The tools you need
Two tools do most of the heavy lifting in .NET 10.
WebApplicationFactory<T> starts your whole app in memory inside the test process. It gives you an HttpClient that talks straight to your app, no network port needed. You can also swap services before the app starts, which is how you point the database at a test container.
Testcontainers spins up real dependencies (PostgreSQL, Redis, RabbitMQ) inside Docker just for your tests. When the tests finish, the containers are thrown away. You get a clean, real database every run.
How a system integration test runs
Steps
Start container
Testcontainers starts a real PostgreSQL
Boot app
WebApplicationFactory starts the app, pointed at the container
Send request
HttpClient calls one module's endpoint
Check side effect
Poll another module to see it reacted
Tear down
Stop container, get a clean slate
A quick note on libraries. Some popular .NET messaging and mediator libraries, namely MediatR and MassTransit, moved to commercial licensing in their newer versions. That does not change how you test. If your modules talk through one of these, or through a hand-rolled event bus, your system integration tests still work the same way: trigger one module, then verify the other module changed.
Setting up the test factory
Start by building a factory that boots your app and points it at a Testcontainers database. Implement IAsyncLifetime so the container starts and stops cleanly around the whole test run.
using DotNet.Testcontainers.Builders;
using Microsoft.AspNetCore.Mvc.Testing;
using Testcontainers.PostgreSql;
public sealed class ShopAppFactory
: WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly PostgreSqlContainer _db = new PostgreSqlBuilder()
.WithImage("postgres:17")
.Build();
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
// Pass the container connection string in as a setting.
// Do not hardcode it.
builder.UseSetting(
"ConnectionStrings:Shop",
_db.GetConnectionString());
}
public async Task InitializeAsync()
{
await _db.StartAsync();
}
public new async Task DisposeAsync()
{
await _db.StopAsync();
}
}Notice we use UseSetting to inject the connection string instead of hardcoding it. This is the clean way: the app reads its config exactly like it would in production, and the test just feeds it a different value.
Writing your first cross-module test
Now the fun part. We will test a real flow that crosses two modules:
- A customer places an order in the Sales module.
- The Inventory module should reduce the stock for that product.
In a modular monolith, Sales does not write to Inventory's tables. It publishes an event, and Inventory handles it. That handling might happen a moment later, so we poll until the system becomes consistent instead of checking instantly.
public class PlaceOrderTests : IClassFixture<ShopAppFactory>
{
private readonly HttpClient _client;
private readonly ShopAppFactory _factory;
public PlaceOrderTests(ShopAppFactory factory)
{
_factory = factory;
_client = factory.CreateClient();
}
[Fact]
public async Task Placing_an_order_reduces_inventory_stock()
{
// Arrange: product P-100 starts with 10 units in stock.
var productId = "P-100";
// Act: Sales module receives a new order for 3 units.
var order = new { ProductId = productId, Quantity = 3 };
var response = await _client.PostAsJsonAsync("/sales/orders", order);
response.EnsureSuccessStatusCode();
// Assert: the Inventory module should eventually show 7 left.
var stock = await PollUntilStockIs(productId, expected: 7);
Assert.Equal(7, stock);
}
}The order endpoint belongs to Sales. The stock check hits Inventory. Neither test code nor either module pokes at the other's database. That is exactly what a healthy modular monolith looks like, and the test proves the wiring between them works.
Polling for eventual consistency
When modules talk through events, the second module reacts a little after the first. So we keep asking "are you done yet?" for a short while before giving up.
private async Task<int> PollUntilStockIs(string productId, int expected)
{
var deadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < deadline)
{
var stock = await _client.GetFromJsonAsync<StockDto>(
$"/inventory/stock/{productId}");
if (stock is not null && stock.Quantity == expected)
{
return stock.Quantity;
}
await Task.Delay(100);
}
throw new TimeoutException(
$"Stock for {productId} never reached {expected}.");
}
public record StockDto(string ProductId, int Quantity);Note the route uses curly braces, so in prose I would write it as GET /inventory/stock/{productId} inside backticks. Inside the fenced code block above, the braces are fine. This polling pattern is the heart of testing event-driven module communication. It waits patiently, then fails loudly with a clear message if the side effect never happens.
Keeping tests from stepping on each other
Integration tests share one database container, so they can trip over each other if you are not careful. Give each test a clean, known starting point.
There are two common ways to stay clean:
- Reset the data between tests (for example with the Respawn library, which deletes rows from your tables).
- Use unique data per test, so two tests never read the same record.
| Problem | What goes wrong | Simple fix |
|---|---|---|
| Shared mutable state | One test changes data another test reads | Reset DB or use unique IDs |
| Test order dependence | Tests pass only in a certain order | Make each test self-contained |
| Parallel DB writes | Two tests write at once and clash | Put DB tests in one xUnit collection |
| Leftover containers | Old containers slow the machine | Stop them in DisposeAsync |
Run your database-touching tests in a single xUnit collection so they do not run in parallel and fight over the same rows. It is a small change that removes a whole class of flaky failures.
A clean test seam for fakes
Sometimes a module talks to the outside world, like sending an email or charging a card. You do not want a real email going out during a test. Swap the real service for a fake one in the factory.
Swapping a real service for a fake in tests
Steps
App boots
WebApplicationFactory starts the app
Find real service
Locate the real IEmailSender registration
Remove it
Take the real one out of the container
Add fake
Register a fake that records calls
Run test
Assert the fake was asked to send
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseSetting("ConnectionStrings:Shop", _db.GetConnectionString());
builder.ConfigureTestServices(services =>
{
// Remove the real email sender.
services.RemoveAll<IEmailSender>();
// Add a fake that just records what it was asked to send.
services.AddSingleton<IEmailSender, FakeEmailSender>();
});
}The trick is to swap as little as possible. Keep the database real. Keep the modules real. Only fake the things you truly cannot run in a test, like a payment provider or an SMS gateway. The more of the real system you keep, the more your test actually proves.
A simple state view of a test run
It helps to picture the test as a tiny machine with a few states. It moves from setup, to action, to checking, and finally to cleanup.
Tips that keep these tests healthy
A few habits make a big difference over the life of a project.
Keep one factory per app, shared across a test class with IClassFixture. Starting a container is slow, so you do not want to do it for every single test. Share it.
Test behaviour, not internals. A good system integration test reads like a user story: "place an order, expect stock to drop." It should not care which class did the work inside Inventory. That way, you can refactor a module's insides freely and the test still passes.
Give failures a clear message. When a poll times out, say what you expected and what you got. Future you will thank present you at 11pm when a test goes red.
Run them in CI on every pull request. Testcontainers works in GitHub Actions and most CI systems because they have Docker available. Catching a broken module boundary before merge is far cheaper than catching it in production.
Quick recap
- A modular monolith is one deployable app split into separate modules. Each module owns its data and hides its insides behind a public door.
- System integration testing checks that modules work together, not just that each one works alone. This is where modular monoliths most often break.
- Use
WebApplicationFactory<T>to boot the whole app in memory, and Testcontainers to run a real database in Docker just for the test. - Inject the container connection string with
UseSettinginstead of hardcoding it. - For event-driven flows, poll until the second module catches up, then assert. Fail loudly with a clear message on timeout.
- Keep tests clean: reset data or use unique IDs, run DB tests in one collection, and stop containers in
DisposeAsync. - Only fake what you truly cannot run, like email or payments. Keep everything else real so the test proves more.
- Run these tests in CI so a broken module boundary is caught before it reaches production.
References and further reading
- Integration tests in ASP.NET Core — Microsoft Learn
- Testing Modular Monoliths: System Integration Testing — Milan Jovanović
- Testcontainers for .NET — official docs
- Modular Monolith Architecture in .NET — Milan Jovanović
- Modular Monolith with DDD (sample repo) — kgrzybek on GitHub
Related Posts
ASP.NET Core Integration Testing Best Practices (.NET 10)
A friendly .NET 10 guide to ASP.NET Core integration testing: WebApplicationFactory, real databases with Testcontainers, clean test isolation, and CI tips.
5 Architecture Tests You Should Add to Your .NET Projects
Five simple architecture tests for .NET using NetArchTest. Protect layers, naming, and dependencies with code that fails the build when rules break.
Enforcing Software Architecture With Architecture Tests in .NET
Learn how to enforce software architecture in .NET using architecture tests with NetArchTest and ArchUnitNET, so your layers and rules stay safe over time.
Testcontainers: Integration Testing Using Docker in .NET
A friendly .NET 10 guide to Testcontainers: spin up real databases in Docker for trustworthy integration tests, with xUnit, WebApplicationFactory, and clean cleanup.
Testcontainers Best Practices for .NET Integration Testing (.NET 10)
A friendly .NET 10 guide to Testcontainers: real databases in Docker, shared container fixtures, clean test isolation, faster CI, and the mistakes to avoid.
How to Test API Integrations Using WireMock.Net in .NET 10
A beginner-friendly .NET 10 guide to testing API integrations with WireMock.Net: stub HTTP responses, simulate errors and delays, and write reliable tests.