Architecture & Boundaries · Lesson 13

Modular monolith contracts and private state

Expose a module facade while hiding its implementation behind assembly access rules.

External .NET 10 SDK; five real projects with explicit ProjectReference items. Not executed by the single-file .NET 9 playground.

Read or download the complete SDK project, then follow its setup on your computer. The separate C# companion practice below runs in SharpRune’s .NET 9 console environment.

The architectural boundary

A modular monolith keeps multiple cohesive modules inside one deployed application while making their contracts and private implementations deliberate. CheckoutModule consumes an IInventoryModule capability, and InventoryFacade exposes that capability while hiding InventoryState. The portable program demonstrates the collaboration in one compilation. The external solution adds a real assembly access boundary: the inventory implementation is internal to Infrastructure and cannot be named by Host. This is one concrete modular arrangement, not a requirement that every module use these technical project names or that a monolith must later be split into network services.

Walk through the portable program

The inventory facade is constructed with four free units. Checkout's first request allocates two, so it returns true and leaves two. The next request asks for three, exceeds the remaining amount, and returns false without reducing it. The lines are first:True, second:False, and remaining:2. Checkout knows the module capability rather than the writable inventory object. The public Remaining query is a deliberately exposed observation, while the internal state remains behind the facade. This distinguishes a meaningful module API from simply exposing every implementation member to convenient callers.

Build real physical dependencies

In the external solution, the shared capability is in Domain, the checkout consumer is in Application, and the inventory facade plus internal implementation are in Infrastructure. Host composes the modules through their public surface. The internal modifier is enforced by assembly identity, not by namespace spelling. The physical project checker adds a separate reference-direction policy. These are complementary mechanisms: allowing a project reference does not automatically make an internal implementation public, and moving a class into a different namespace within the same assembly does not create an equivalent privacy boundary.

Pitfalls and unproved guarantees

A public facade can still leak mutable state or make overly broad promises. This one returns a scalar count and delegates a bounded operation, without exposing its internal object for callers to edit. Its state is sequential and in memory; it is not durable or safe under arbitrary concurrent access. A modular monolith also does not guarantee low coupling merely because everything is deployed together. Shared database tables, unrestricted internal visibility, and cross-module object graphs can undermine the intended boundary. Identify ownership and allowed conversations before deciding whether separate deployment would improve anything.

Practice one: isolate behavior

First allocate exactly the remaining two after the baseline sequence and predict true with Remaining zero. Then request one more and predict false with zero preserved. Hint: observe module state through its supported public query rather than reaching into InventoryState. Add a zero request on a fresh facade to verify the positive-quantity rule independently. Keep a separate constructor case for negative capacity, which the public facade rejects before any module operation is possible.

Practice two: test a boundary

For another exercise, add a second checkout-like consumer that depends only on IInventoryModule and verify that it cannot bypass the module's allocation rule. Hint: sharing a contract does not require sharing a writable implementation reference. In a separate external source copy, try constructing InventoryState directly from Host and observe the compiler's access error. Compare this with the portable file, where all types belong to one assembly and internal has a different reach. Do not advertise the single-file version as proof of the external assembly restriction.

Review the evidence

Review modular boundaries by identifying each module's public contract, data ownership, allowed calls, and private implementation. Check assembly access and project-reference policy independently, then inspect any shared storage that source-level isolation cannot govern. The example remains one process with explicit composition and no remote calls. That is a valid architectural choice when it fits the application. Additional deployment units introduce real operational costs and consistency boundaries, so choose them for concrete needs rather than treating modularity as a disguised mandate to create microservices.

External .NET 10 SDK; five real projects with explicit ProjectReference items. Not executed by the single-file .NET 9 playground.

Modular monolith contracts and private state — physical dependency lab

Set up the SDK project

  1. Save every file at its exact relative path under a fresh directory.
  2. Use a .NET 10 SDK. Run dotnet build ArchitectureLab.slnx.
  3. Run dotnet run --project Host/Host.csproj --no-build.
  4. From the solution directory run dotnet run --project ArchitectureCheck/ArchitectureCheck.csproj --no-build -- .

Download complete SDK project ↗

Project files

ArchitectureLab.slnxDownload file ↗
<Solution>
  <Project Path="Domain/Domain.csproj" />
  <Project Path="Application/Application.csproj" />
  <Project Path="Infrastructure/Infrastructure.csproj" />
  <Project Path="Host/Host.csproj" />
  <Project Path="ArchitectureCheck/ArchitectureCheck.csproj" />
</Solution>
Domain/Domain.csprojDownload file ↗
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <LangVersion>14.0</LangVersion>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
</Project>
Application/Application.csprojDownload file ↗
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <LangVersion>14.0</LangVersion>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
  <ItemGroup>
    <ProjectReference Include="../Domain/Domain.csproj" />
  </ItemGroup>
</Project>
Infrastructure/Infrastructure.csprojDownload file ↗
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <LangVersion>14.0</LangVersion>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
  <ItemGroup>
    <ProjectReference Include="../Domain/Domain.csproj" />
    <ProjectReference Include="../Application/Application.csproj" />
  </ItemGroup>
</Project>
Host/Host.csprojDownload file ↗
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <LangVersion>14.0</LangVersion>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
  <ItemGroup>
    <ProjectReference Include="../Domain/Domain.csproj" />
    <ProjectReference Include="../Application/Application.csproj" />
    <ProjectReference Include="../Infrastructure/Infrastructure.csproj" />
  </ItemGroup>
</Project>
ArchitectureCheck/ArchitectureCheck.csprojDownload file ↗
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <LangVersion>14.0</LangVersion>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
</Project>
Domain/Model.csDownload file ↗
using System;
using System.Collections.Generic;
using System.Linq;

namespace BoundaryLab.Domain;

public interface IInventoryModule { bool Allocate(int units); }
Application/Model.csDownload file ↗
using System;
using System.Collections.Generic;
using System.Linq;
using BoundaryLab.Domain;

namespace BoundaryLab.Application;

public sealed class CheckoutModule(IInventoryModule inventory) { public bool Execute(int units) => inventory.Allocate(units); }
Infrastructure/Model.csDownload file ↗
using System;
using System.Collections.Generic;
using System.Linq;
using BoundaryLab.Domain;
using BoundaryLab.Application;

namespace BoundaryLab.Infrastructure;

internal sealed class InventoryState(int capacity)
{
    public int Free { get; private set; } = capacity;
    public bool Allocate(int units) { if (units < 1 || units > Free) return false; Free -= units; return true; }
}
public sealed class InventoryFacade : IInventoryModule
{
    private readonly InventoryState state;
    public InventoryFacade(int capacity) { if (capacity < 0) throw new ArgumentOutOfRangeException(nameof(capacity)); state = new InventoryState(capacity); }
    public int Remaining => state.Free;
    public bool Allocate(int units) => state.Allocate(units);
}
Host/Program.csDownload file ↗
using System;
using System.Collections.Generic;
using System.Linq;
using BoundaryLab.Domain;
using BoundaryLab.Application;
using BoundaryLab.Infrastructure;

var inventory = new InventoryFacade(4);
var checkout = new CheckoutModule(inventory);
Console.WriteLine($"first:{checkout.Execute(2)}");
Console.WriteLine($"second:{checkout.Execute(3)}");
Console.WriteLine($"remaining:{inventory.Remaining}");
ArchitectureCheck/Program.csDownload file ↗
using System.Xml.Linq;
string root = Path.GetFullPath(args.Length == 0 ? "." : args[0]);
var allowed = new Dictionary<string, HashSet<string>>(StringComparer.Ordinal)
{
    ["Domain"] = new(),
    ["Application"] = new() { "Domain" },
    ["Infrastructure"] = new() { "Domain", "Application" },
    ["Host"] = new() { "Domain", "Application", "Infrastructure" }
};
int violations = 0;
foreach (var item in allowed)
{
    string path = Path.Combine(root, item.Key, item.Key + ".csproj");
    var document = XDocument.Load(path);
    foreach (var reference in document.Descendants().Where(element => element.Name.LocalName == "ProjectReference"))
    {
        string include = (string?)reference.Attribute("Include") ?? throw new InvalidOperationException("Missing Include");
        string targetPath = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(path)!, include));
        string target = Path.GetFileNameWithoutExtension(targetPath);
        string expectedPath = Path.Combine(root, target, target + ".csproj");
        if (!item.Value.Contains(target) || targetPath != expectedPath)
        {
            violations++;
            Console.WriteLine($"violation:{item.Key}->{target}");
        }
    }
}
Console.WriteLine($"projects:{allowed.Count}");
Console.WriteLine($"violations:{violations}");
return violations == 0 ? 0 : 1;

Expected result

Host output: first:True second:False remaining:2 Physical ProjectReference policy check output: projects:4 violations:0 Both commands exit zero. The checker inspects the four business-project files; its own tooling project is outside that policy. It checks declared project-reference direction, not all runtime behavior or package dependencies.

Use the listed SDK and dependencies for this project. Open C# practice runs the separate portable exercise.

C# companion practice

using System;
using System.Collections.Generic;
using System.Linq;

var inventory = new InventoryFacade(4);
var checkout = new CheckoutModule(inventory);
Console.WriteLine($"first:{checkout.Execute(2)}");
Console.WriteLine($"second:{checkout.Execute(3)}");
Console.WriteLine($"remaining:{inventory.Remaining}");

public interface IInventoryModule { bool Allocate(int units); }

public sealed class CheckoutModule(IInventoryModule inventory) { public bool Execute(int units) => inventory.Allocate(units); }

internal sealed class InventoryState(int capacity)
{
    public int Free { get; private set; } = capacity;
    public bool Allocate(int units) { if (units < 1 || units > Free) return false; Free -= units; return true; }
}
public sealed class InventoryFacade : IInventoryModule
{
    private readonly InventoryState state;
    public InventoryFacade(int capacity) { if (capacity < 0) throw new ArgumentOutOfRangeException(nameof(capacity)); state = new InventoryState(capacity); }
    public int Remaining => state.Free;
    public bool Allocate(int units) => state.Allocate(units);
}
Open C# practice

C# practice output

first:True
second:False
remaining:2
Try this
In the external Host, try accessing internal InventoryState in a separate invalid copy. A namespace alone would not enforce this assembly access restriction.

Further reading