Modular Monolith Solution Structure in .NET: Module Projects, internal and Module Registration
Each module gets two projects: a small Contracts project other modules can see and a Core project where everything is internal; the Host is just the composition root calling AddXModule() and MapXEndpoints(). We go from an empty solution to a running three-module Host, show with real CS0234/CS0122 errors where project references and internal protect the boundary and where they don't, and list concretely what may and may not go into the Shared Kernel.
Version note: The code in this post was built and run with C# 14 and .NET 10.
Guess First
The developer writing Bookstore's Ordering module is in a hurry. The order screen needs a book's list price, and Catalog's contract has no method for it yet. They see the BookStore class inside Catalog that holds the prices and add a project reference from Bookstore.Ordering.Core to Bookstore.Catalog.Core. The dotnet add reference command runs without complaint.
Three questions. Does the code compile? If not, with which error? And the real one: what happens if the colleague who wrote Catalog says "no problem" and adds InternalsVisibleTo Include="Bookstore.Ordering.Core" to Catalog's project? What does the compiler say then, and what happens to the boundary?
Two Projects per Module: Contracts and Core
The most common and easiest-to-understand way to turn a module into .NET is to give each module two projects. Bookstore.Ordering.Contracts holds everything the module promises to the outside: the interfaces other modules may call, the records and id types those interfaces use, and later the integration events. Bookstore.Ordering.Core is the rest of the module: entities, business rules, data access, endpoints. Almost everything in Core is internal. Other modules may only reference Contracts.
.NET's own libraries do this everywhere. Logging's contract and its implementation live in separate assemblies:
using System.Reflection;
// Contract (Abstractions) and implementation live in separate assemblies
string[] assemblies =
[
"Microsoft.Extensions.Logging.Abstractions",
"Microsoft.Extensions.Logging",
"Microsoft.Extensions.Logging.Console"
];
foreach (string name in assemblies)
{
Assembly assembly = Assembly.Load(name);
IEnumerable<string?> refs = assembly.GetReferencedAssemblies()
.Select(r => r.Name).Where(n => n!.StartsWith("Microsoft.Extensions.Logging")).Order();
int publicTypes = assembly.GetExportedTypes().Length;
Console.WriteLine($"{name,-42} public types: {publicTypes,3} -> [{string.Join(", ", refs)}]");
}
Microsoft.Extensions.Logging.Abstractions public types: 24 -> []
Microsoft.Extensions.Logging public types: 8 -> [Microsoft.Extensions.Logging.Abstractions]
Microsoft.Extensions.Logging.Console public types: 14 -> [Microsoft.Extensions.Logging.Abstractions, Microsoft.Extensions.Logging.Configuration]
Contracts such as ILogger and ILoggerFactory live in the Abstractions assembly, and that assembly knows no other logging assembly. A library can write logs by depending only on Abstractions; the application's composition root decides which implementation runs. The interesting line is the last one: even the Console provider knows only Abstractions, not the implementation. In Bookstore, Ordering's relationship with Catalog will work the same way.
Some teams create four projects per module. Kamil Grzybek's modular-monolith-with-ddd sample on GitHub splits each module into Domain, Application, Infrastructure and IntegrationEvents assemblies, and its README itself notes that the first three could be merged into one. How you layer the inside of a module is a separate decision and the subject of the Clean/Onion/Hexagonal and Vertical Slice series. The only thing that matters for the module boundary is that the public part and the inside are compiled separately.
Starting from an Empty Solution
We created the three-module skeleton with these commands:
dotnet new sln -n Bookstore
dotnet new web -n Bookstore.Host -o src/Bookstore.Host
dotnet new classlib -n Bookstore.SharedKernel -o src/Bookstore.SharedKernel
dotnet new classlib -n Bookstore.BuildingBlocks -o src/Bookstore.BuildingBlocks
for m in Catalog Ordering Inventory; do
dotnet new classlib -n Bookstore.$m.Contracts -o src/Modules/$m/Bookstore.$m.Contracts
dotnet new classlib -n Bookstore.$m.Core -o src/Modules/$m/Bookstore.$m.Core
dotnet add src/Modules/$m/Bookstore.$m.Contracts reference src/Bookstore.SharedKernel
dotnet add src/Modules/$m/Bookstore.$m.Core reference src/Modules/$m/Bookstore.$m.Contracts src/Bookstore.BuildingBlocks
dotnet add src/Bookstore.Host reference src/Modules/$m/Bookstore.$m.Core
done
# Inventory knows a book by the BookId that Catalog publishes
dotnet add src/Modules/Inventory/Bookstore.Inventory.Contracts reference src/Modules/Catalog/Bookstore.Catalog.Contracts
# Ordering only sees the other modules' Contracts projects
dotnet add src/Modules/Ordering/Bookstore.Ordering.Core reference \
src/Modules/Catalog/Bookstore.Catalog.Contracts src/Modules/Inventory/Bookstore.Inventory.Contracts
dotnet new console -n Bookstore.Ordering.Tests -o tests/Bookstore.Ordering.Tests
dotnet add tests/Bookstore.Ordering.Tests package xunit.v3 --version 4.0.2
dotnet add tests/Bookstore.Ordering.Tests reference src/Modules/Ordering/Bookstore.Ordering.Core
With this SDK (Software Development Kit) version (10.0.402) dotnet new adds the project it creates to the solution in the folder by itself; older SDKs need a dotnet sln add line at the end. The solution file is now .slnx by default as well. To keep shared settings out of every project file, there is a Directory.Build.props at the solution root; MSBuild searches up the folder tree for this file and imports it into every project automatically:
<Project>
<!-- Every project in the solution inherits these settings -->
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>14</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>
The resulting reference graph is the rule we will protect for the rest of this series:

Contracts: Interfaces, Records and Ids
Catalog's contract is small:
using Bookstore.SharedKernel;
namespace Bookstore.Catalog.Contracts;
public readonly record struct BookId(Guid Value);
public sealed record BookSummary(BookId Id, string Isbn, string Title, Money ListPrice);
// The only door Catalog opens to other modules
public interface ICatalogModuleApi
{
Task<BookSummary?> GetBookAsync(BookId id, CancellationToken ct = default);
}
The answer to "what belongs in Contracts" is "the least the other module needs to know". BookSummary is a record: no behaviour, a snapshot that Catalog's Book entity shows to the outside. BookId is a readonly record struct; it lets the compiler catch Guids being mixed up. Contracts must not contain EF (Entity Framework) Core attributes, a DbContext, entities, repository interfaces or a field added "because we might need it". The moment one of these enters Contracts, Catalog's internal model has leaked into Ordering's compilation.
Inventory's contract also identifies a book by Catalog's BookId, so Inventory.Contracts references Catalog.Contracts. That is not a leak: Catalog publishes the book's identity as a published language that everyone uses, and Inventory conforms to it. A reference from Contracts to Contracts is reasonable; a reference from Core to another module's Core is not.
Core: Everything Is internal
Inside Catalog:
using Bookstore.SharedKernel;
namespace Bookstore.Catalog.Core;
// Inside the module: the entity is invisible from outside
internal sealed class Book(Guid id, string isbn, string title, string author, Money listPrice)
{
public Guid Id { get; } = id;
public string Isbn { get; } = isbn;
public string Title { get; } = title;
public string Author { get; } = author;
public Money ListPrice { get; private set; } = listPrice;
public void ChangePrice(Money newPrice) => ListPrice = newPrice;
}
internal sealed class BookStore
{
public static readonly Guid Dune = Guid.Parse("0199b1a0-0000-7000-8000-000000000001");
public static readonly Guid Emma = Guid.Parse("0199b1a0-0000-7000-8000-000000000002");
private readonly Dictionary<Guid, Book> _books = new()
{
[Dune] = new(Dune, "978-0441013593", "Dune", "Frank Herbert", Money.Eur(12.50m)),
[Emma] = new(Emma, "978-0141439587", "Emma", "Jane Austen", Money.Eur(6.00m))
};
public IEnumerable<Book> All => _books.Values;
public Book? Find(Guid id) => _books.GetValueOrDefault(id);
}
using Bookstore.Catalog.Contracts;
namespace Bookstore.Catalog.Core;
internal sealed class CatalogModuleApi(BookStore store) : ICatalogModuleApi
{
public Task<BookSummary?> GetBookAsync(BookId id, CancellationToken ct = default) =>
Task.FromResult(store.Find(id.Value) is { } book
? new BookSummary(new BookId(book.Id), book.Isbn, book.Title, book.ListPrice)
: null);
}
Book, BookStore and CatalogModuleApi are internal. Someone looking from outside can only see ICatalogModuleApi and request it from the DI (Dependency Injection) container. If Catalog moves BookStore to EF Core, Dapper or a search engine tomorrow, not a single line in Ordering changes.
Module Registration and Endpoint Groups
Each module exposes two extension methods: one registers its services, the other maps its endpoints. That is ASP.NET Core's own habit: AddAuthentication/UseAuthentication, AddHealthChecks/MapHealthChecks. We discussed lifetimes in the ASP.NET Core Web API (Application Programming Interface) series post "Dependency Injection in ASP.NET Core: Lifetimes and the Captive Dependency Trap"; here each module makes its own lifetime decisions internally.
using Bookstore.Catalog.Contracts;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
namespace Bookstore.Catalog.Core;
// The module's public face: registration and endpoints, nothing else
public static class CatalogModule
{
public static IServiceCollection AddCatalogModule(this IServiceCollection services) =>
services
.AddSingleton<BookStore>()
.AddScoped<ICatalogModuleApi, CatalogModuleApi>();
public static IEndpointRouteBuilder MapCatalogEndpoints(this IEndpointRouteBuilder app)
{
RouteGroupBuilder group = app.MapGroup("/api/catalog").WithTags("Catalog");
group.MapGet("/books", (BookStore store) =>
store.All.Select(b => new BookResponse(b.Id, b.Title, b.ListPrice.Amount, b.ListPrice.Currency)));
group.MapGet("/books/{id:guid}", (Guid id, BookStore store) =>
store.Find(id) is { } b
? Results.Ok(new BookResponse(b.Id, b.Title, b.ListPrice.Amount, b.ListPrice.Currency))
: Results.NotFound());
return app;
}
}
internal sealed record BookResponse(Guid Id, string Title, decimal Price, string Currency);
MapGroup gathers all of the module's endpoints under one route prefix, and shared settings such as filters, authorization or an OpenAPI tag are added to the group once. The BookResponse returned by the endpoint is internal too: a DTO (Data Transfer Object) on the HTTP (Hypertext Transfer Protocol) surface and a contract between modules are different things and may change at different speeds.
Ordering's handler knows the other modules only through their contracts:
using Bookstore.BuildingBlocks;
using Bookstore.Catalog.Contracts;
using Bookstore.Inventory.Contracts;
namespace Bookstore.Ordering.Core;
internal sealed record PlaceOrderRequest(Guid BookId, int Quantity);
internal sealed record OrderConfirmation(Guid OrderId, string Title, int Quantity, decimal Total, string Currency);
// Knows the other modules only through their Contracts
internal sealed class PlaceOrderHandler(ICatalogModuleApi catalog, IInventoryModuleApi inventory, OrderStore orders)
{
public async Task<Result<OrderConfirmation>> HandleAsync(PlaceOrderRequest request, CancellationToken ct)
{
BookSummary? book = await catalog.GetBookAsync(new BookId(request.BookId), ct);
if (book is null)
return new Error("order.unknown-book", $"Book {request.BookId} does not exist");
int available = await inventory.GetAvailableAsync(book.Id, ct);
if (available < request.Quantity)
return new Error("order.out-of-stock", $"Only {available} of '{book.Title}' available");
Order order = new(Guid.CreateVersion7(), [new OrderItem(book.Id.Value, book.Title, book.ListPrice, request.Quantity)]);
orders.Add(order);
return new OrderConfirmation(order.Id, book.Title, request.Quantity, order.Total.Amount, order.Total.Currency);
}
}
using Bookstore.BuildingBlocks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
namespace Bookstore.Ordering.Core;
public static class OrderingModule
{
public static IServiceCollection AddOrderingModule(this IServiceCollection services) =>
services
.AddSingleton<OrderStore>()
.AddScoped<PlaceOrderHandler>();
public static IEndpointRouteBuilder MapOrderingEndpoints(this IEndpointRouteBuilder app)
{
RouteGroupBuilder group = app.MapGroup("/api/ordering").WithTags("Ordering");
group.MapPost("/orders", async (PlaceOrderRequest request, PlaceOrderHandler handler, CancellationToken ct) =>
{
Result<OrderConfirmation> result = await handler.HandleAsync(request, ct);
return result.IsSuccess
? Results.Created($"/api/ordering/orders/{result.Value!.OrderId}", result.Value)
: Results.Problem(title: result.Error!.Code, detail: result.Error.Message, statusCode: 409);
});
return app;
}
}
Host: Only the Composition Root
The Host project has no business rules. Its only job is to put the modules together:
using Bookstore.Catalog.Core;
using Bookstore.Inventory.Core;
using Bookstore.Ordering.Core;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
// The Host is only the composition root: it registers modules and maps their endpoints
builder.Services
.AddCatalogModule()
.AddInventoryModule()
.AddOrderingModule();
WebApplication app = builder.Build();
app.MapCatalogEndpoints();
app.MapInventoryEndpoints();
app.MapOrderingEndpoints();
app.Run();
We ran it and tried it out:
$ curl -s localhost:5103/api/catalog/books
[{"id":"0199b1a0-0000-7000-8000-000000000001","title":"Dune","price":12.50,"currency":"EUR"},{"id":"0199b1a0-0000-7000-8000-000000000002","title":"Emma","price":6.00,"currency":"EUR"}]
$ curl -s -X POST localhost:5103/api/ordering/orders -H "Content-Type: application/json" -d '{"bookId":"0199b1a0-0000-7000-8000-000000000001","quantity":2}'
{"orderId":"01a1b875-1a67-7008-95f7-a983ef6b5ca0","title":"Dune","quantity":2,"total":25.00,"currency":"EUR"}
$ curl -s -X POST localhost:5103/api/ordering/orders -H "Content-Type: application/json" -d '{"bookId":"0199b1a0-0000-7000-8000-000000000002","quantity":1}'
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.10","title":"order.out-of-stock","status":409,"detail":"Only 0 of 'Emma' available"}
One copy of Emma is in the warehouse but reserved for another order; Inventory's contract says "0 available" and Ordering reports it with a 409. That information reached Ordering through IInventoryModuleApi, not from Inventory's table.
Keeping the Host thin is a precondition for the "extract a module into a service" step in post 08. When you move Payments into a separate process one day, that process's Host will make the same AddPaymentsModule() call.
internal and InternalsVisibleTo: Tests Only
Back to the opening question. We added a shortcut to Ordering that reads the price from inside Catalog:
using Bookstore.Catalog.Core;
namespace Bookstore.Ordering.Core;
// Reaching into Catalog because "adding a method to the contract takes too long"
internal static class PriceShortcut
{
public static decimal PriceOf(BookStore store, Guid id) => store.Find(id)!.ListPrice.Amount;
}
Without a project reference the compiler can't even find the namespace:
src/Modules/Ordering/Bookstore.Ordering.Core/PriceShortcut.cs(1,25): error CS0234: The type or namespace name 'Core' does not exist in the namespace 'Bookstore.Catalog' (are you missing an assembly reference?)
src/Modules/Ordering/Bookstore.Ordering.Core/PriceShortcut.cs(8,35): error CS0246: The type or namespace name 'BookStore' could not be found (are you missing a using directive or an assembly reference?)
After adding Catalog.Core with dotnet add reference, the error changes but doesn't go away:
src/Modules/Ordering/Bookstore.Ordering.Core/PriceShortcut.cs(8,35): error CS0122: 'BookStore' is inaccessible due to its protection level
The first line of defence is the project reference, the second is internal. But note: adding the project reference produced no error at all. Catalog's types that have to stay public, such as CatalogModule, can now be called from Ordering, and on the day InternalsVisibleTo Include="Bookstore.Ordering.Core" is added to Catalog, the second line falls too; the code compiles and the boundary silently disappears. The compiler does not protect you from the rule "Ordering may not reference Catalog.Core"; we will enforce that rule in post 07 with architecture tests and an MSBuild check.
The only legitimate use of InternalsVisibleTo is the module's own test project. Since .NET 5 you can write it as an MSBuild item instead of an attribute:
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Bookstore.Ordering.Contracts\Bookstore.Ordering.Contracts.csproj" />
<ProjectReference Include="..\..\..\Bookstore.BuildingBlocks\Bookstore.BuildingBlocks.csproj" />
<ProjectReference Include="..\..\Catalog\Bookstore.Catalog.Contracts\Bookstore.Catalog.Contracts.csproj" />
<ProjectReference Include="..\..\Inventory\Bookstore.Inventory.Contracts\Bookstore.Inventory.Contracts.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Bookstore.Ordering.Tests" />
</ItemGroup>
</Project>
The tests represent other modules with fake implementations of their contracts:
using Bookstore.BuildingBlocks;
using Bookstore.Catalog.Contracts;
using Bookstore.Inventory.Contracts;
using Bookstore.Ordering.Core;
using Bookstore.SharedKernel;
using Xunit;
namespace Bookstore.Ordering.Tests;
public sealed class PlaceOrderHandlerTests
{
private static readonly BookId Dune = new(Guid.CreateVersion7());
// Other modules are represented by fake implementations of their contracts
private sealed class FakeCatalog : ICatalogModuleApi
{
public Task<BookSummary?> GetBookAsync(BookId id, CancellationToken ct = default) =>
Task.FromResult<BookSummary?>(id == Dune ? new BookSummary(Dune, "978-0441013593", "Dune", Money.Eur(12.50m)) : null);
}
private sealed class FakeInventory(int available) : IInventoryModuleApi
{
public Task<int> GetAvailableAsync(BookId bookId, CancellationToken ct = default) => Task.FromResult(available);
}
[Fact]
public async Task Places_order_with_catalog_price()
{
PlaceOrderHandler handler = new(new FakeCatalog(), new FakeInventory(3), new OrderStore());
Result<OrderConfirmation> result = await handler.HandleAsync(new PlaceOrderRequest(Dune.Value, 2), TestContext.Current.CancellationToken);
Assert.Equal(25.00m, result.Value!.Total);
}
[Fact]
public async Task Rejects_order_when_stock_is_short()
{
PlaceOrderHandler handler = new(new FakeCatalog(), new FakeInventory(1), new OrderStore());
Result<OrderConfirmation> result = await handler.HandleAsync(new PlaceOrderRequest(Dune.Value, 2), TestContext.Current.CancellationToken);
Assert.Equal("order.out-of-stock", result.Error!.Code);
}
}
Test run summary: Passed!
total: 2
failed: 0
succeeded: 2
skipped: 0
duration: 763ms
In the first post we looked at the InternalsVisibleTo list of ASP.NET Core's routing assembly: everything on it was a test, a benchmark or a mock proxy. The rule for Bookstore is the same: open a module's insides only to its own tests.
Shared Kernel: What Belongs and What Doesn't
Every modular monolith has a "shared" project and that is where the biggest risk lives. The Shared Kernel in Eric Evans's DDD (Domain-Driven Design) book is a small piece of model that two teams own together and can only change together. In Bookstore, the context map in the DDD series has exactly one between Ordering and Payments: Money.
using System.Globalization;
namespace Bookstore.SharedKernel;
// The only domain concept Ordering and Payments use with the same meaning
public readonly record struct Money(decimal Amount, string Currency)
{
public static Money Eur(decimal amount) => new(amount, "EUR");
public static Money operator +(Money left, Money right) =>
left.Currency == right.Currency
? new(left.Amount + right.Amount, left.Currency)
: throw new InvalidOperationException($"Cannot add {left.Currency} to {right.Currency}");
public static Money operator *(Money price, int quantity) => new(price.Amount * quantity, price.Currency);
public override string ToString() => string.Create(CultureInfo.InvariantCulture, $"{Amount:0.00} {Currency}");
}
Technical building blocks that are independent of the domain live in a separate project, Bookstore.BuildingBlocks. A small Result type that lets contract methods return expected failures without throwing is one of them:
namespace Bookstore.BuildingBlocks;
public sealed record Error(string Code, string Message);
// Technical building block: lets module contracts return expected failures without exceptions
public sealed class Result<T>
{
private Result(T? value, Error? error) => (Value, Error) = (value, error);
public T? Value { get; }
public Error? Error { get; }
public bool IsSuccess => Error is null;
public static implicit operator Result<T>(T value) => new(value, null);
public static implicit operator Result<T>(Error error) => new(default, error);
}
The rule for telling them apart is simple:
- May go in: value objects such as
Moneythat mean exactly the same thing in several modules and rarely change; technical types with no domain knowledge such asResult,Erroror the common interface for integration events. - May not go in: entities (
Customer,Book), aDbContext, repositories, module-specific enums (OrderStatus), DTOs moved there because "two modules use them", helpers containing business rules.
You can answer "may it go in?" with one test: when this type changes, must every module that uses it change at the same time and for the same reason? If yes, it is a Shared Kernel candidate; if not, copy it.
Common Pitfalls
One Infrastructure project shared by every module
A single BookstoreDbContext, all repositories and all external integrations inside Bookstore.Infrastructure. The modules look clean among themselves, but all of them can reach each other's tables through the same project. Infrastructure belongs to each module; only technical building blocks are shared (reading a connection string, the schema of the outbox table and so on). A DbContext per module is the subject of post 06.
Leaking entities into the Contracts project
"Ordering uses Book as well, let's move it to Contracts." With this one-line change Catalog's internal model becomes a public contract, and Catalog now has to think about Ordering even when it adds a property to Book. What goes into Contracts is not the entity but the snapshot of it needed for that use.
Using InternalsVisibleTo between modules
The opening question. It silences the compiler and removes the boundary. If one module needs another module's internals, either the contract is missing something or the two modules are really one.
Putting business logic in the Host
"Let's write the post-order email in Program.cs, it sees both modules." Because the Host sees every module, every line written there crosses all the boundaries. The Host should contain only registration, configuration and endpoint mapping.
A Small Experiment
★☆☆ Add the Payments module to the solution: Bookstore.Payments.Contracts and Bookstore.Payments.Core, with AddPaymentsModule() and MapPaymentsEndpoints(). Confirm that adding two lines to the Host is enough.
★★☆ Accidentally add a reference from Bookstore.Catalog.Contracts to Bookstore.Catalog.Core. What does the build say? How would you read that error as a natural guard for the rule that Contracts must never depend on Core?
★★★ Next to Directory.Build.props, write a Directory.Build.targets that stops the build with an error message when a *.Core project references another module's *.Core project. We will write the same rule together in post 07; compare it with your solution.
In this post we poured the modules into a .NET solution and met the boundary's first two guards, the project reference and internal. In the next post we focus on how modules should call each other: the shape of the contract, the direction of calls, the cost of chatty communication and the most common mistake, injecting another module's DbContext.