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
An aggregate protects a coherent set of domain invariants through its allowed operations. Basket keeps its unit count behind a private setter and rejects additions outside its capacity. Shipping is a small domain service expressing a separate freight threshold from the basket's valid state. BasketService coordinates access without moving the invariant into presentation code. This example does not need a database, framework base class, or public mutable collection. The portable program shows the actual state transitions, while the external projects make Domain independent of the application wrapper and formatting adapter that consume its behavior.
Walk through the portable program
The basket is created with capacity five and begins with zero units. Adding three succeeds and sets Units to three. A second addition of three would exceed the remaining capacity two, so it returns false without changing the count. The freight rule requires at least four units and therefore returns false for the retained total of three. The output is added:True, excess:False, units:3, and freight:False. Observing Units after rejection is essential; the false return alone would not establish that the failed command preserved the aggregate's state.
Build real physical dependencies
The external Domain project contains Basket and Shipping, Application contains the coordinating service, and Infrastructure contains the text formatting helper. This is a concrete separation of business validity from how a host displays a decision. A domain service should represent meaningful domain behavior that does not naturally belong to one entity; its name alone is insufficient justification for moving every method into another class. Here freight classification depends on an already valid basket. The five-project solution and reference checker verify the chosen physical boundaries, not whether this tiny rule needs a more elaborate domain model.
Pitfalls and unproved guarantees
A private setter helps only when every exposed mutation path preserves the invariant. Returning a writable backing collection or adding an unchecked public setter later can bypass an otherwise careful method. Constructor validation matters too: this Basket rejects negative capacity before any operation is allowed. The sample's single-threaded updates do not provide concurrency control, persistence, or transactional isolation. If two requests can alter a stored aggregate, its loading and commit boundary must prevent lost updates as well as enforce local business rules. More layers do not automatically solve that coordination problem.
Practice one: isolate behavior
First add one unit after the baseline rejection and predict Units four and freight true. Then add the final available unit and predict Units five with another true freight result. Hint: the capacity boundary and freight boundary are different rules, so select cases that isolate each. Add zero as a rejected command and verify that Units remains unchanged. Keep direct expected counts before and after rejection rather than recomputing expectations through TryAdd or interpreting any false result as sufficient evidence of invariant preservation.
Practice two: test a boundary
For another exercise, add a removal operation with a stated positive-quantity and no-underflow contract. Test removing exactly all units, too many units, and zero on independent valid fixtures. Hint: keep state changes inside Basket rather than exposing Units for callers to repair. If a later shipping policy uses information from several aggregates, define what data a domain service may consume and how stale snapshots are handled. The current static freight method is a bounded rule over one object, not proof of consistency across independently stored entities.
Review the evidence
Review aggregate design by listing valid construction states, public mutations, rejected outcomes, and what callers can still modify through returned references. Locate each business rule where the model can enforce it consistently. The sample's deterministic observations show capacity preservation and a separate classification threshold, while the external build checks assembly direction. Avoid treating rich models as a mandate for every CRUD screen or treating domain services as a destination for arbitrary code. The useful structure is the one that protects real invariants and keeps their owners identifiable during change.
Aggregate invariants and domain services — physical dependency lab
Set up the SDK project
- Save every file at its exact relative path under a fresh directory.
- Use a .NET 10 SDK. Run dotnet build ArchitectureLab.slnx.
- Run dotnet run --project Host/Host.csproj --no-build.
- From the solution directory run dotnet run --project ArchitectureCheck/ArchitectureCheck.csproj --no-build -- .
Download complete SDK project ↗
Project files
ArchitectureLab.slnx
Download 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.csproj
Download 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.csproj
Download 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.csproj
Download 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.csproj
Download 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.csproj
Download 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.cs
Download file ↗using System;
using System.Collections.Generic;
using System.Linq;
namespace BoundaryLab.Domain;
public sealed class Basket
{
private readonly int capacity;
public Basket(int capacity) { if (capacity < 0) throw new ArgumentOutOfRangeException(nameof(capacity)); this.capacity = capacity; }
public int Units { get; private set; }
public bool TryAdd(int units)
{
if (units < 1 || units > capacity - Units) return false;
Units += units; return true;
}
}
public static class Shipping { public static bool NeedsFreight(Basket basket) => basket.Units >= 4; }
Application/Model.cs
Download file ↗using System;
using System.Collections.Generic;
using System.Linq;
using BoundaryLab.Domain;
namespace BoundaryLab.Application;
public sealed class BasketService(Basket basket) { public bool Add(int units) => basket.TryAdd(units); }
Infrastructure/Model.cs
Download file ↗using System;
using System.Collections.Generic;
using System.Linq;
using BoundaryLab.Domain;
using BoundaryLab.Application;
namespace BoundaryLab.Infrastructure;
public static class BasketText { public static string Freight(bool required) => "freight:" + required; }
Host/Program.cs
Download file ↗using System;
using System.Collections.Generic;
using System.Linq;
using BoundaryLab.Domain;
using BoundaryLab.Application;
using BoundaryLab.Infrastructure;
var basket = new Basket(5);
var service = new BasketService(basket);
Console.WriteLine($"added:{service.Add(3)}");
Console.WriteLine($"excess:{service.Add(3)}");
Console.WriteLine($"units:{basket.Units}");
Console.WriteLine(BasketText.Freight(Shipping.NeedsFreight(basket)));
ArchitectureCheck/Program.cs
Download 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: added:True excess:False units:3 freight:False 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 basket = new Basket(5);
var service = new BasketService(basket);
Console.WriteLine($"added:{service.Add(3)}");
Console.WriteLine($"excess:{service.Add(3)}");
Console.WriteLine($"units:{basket.Units}");
Console.WriteLine(BasketText.Freight(Shipping.NeedsFreight(basket)));
public sealed class Basket
{
private readonly int capacity;
public Basket(int capacity) { if (capacity < 0) throw new ArgumentOutOfRangeException(nameof(capacity)); this.capacity = capacity; }
public int Units { get; private set; }
public bool TryAdd(int units)
{
if (units < 1 || units > capacity - Units) return false;
Units += units; return true;
}
}
public static class Shipping { public static bool NeedsFreight(Basket basket) => basket.Units >= 4; }
public sealed class BasketService(Basket basket) { public bool Add(int units) => basket.TryAdd(units); }
public static class BasketText { public static string Freight(bool required) => "freight:" + required; }
C# practice output
added:True excess:False units:3 freight:False
Test that rejected additions preserve Units, then reach the freight boundary exactly. A service name alone does not protect a public mutable collection.