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
Onion Architecture places domain policy at the center and makes outer details depend inward. In this example, the domain owns IDiscountPolicy because applying a discount is part of the Invoice's business calculation. An outer FixedDiscount implementation provides one policy, and the application Checkout coordinates the operation. This is a deliberate contract-placement choice, not a rule that every interface in every architecture must live in Domain. The portable program demonstrates the collaboration; the external solution enforces its dependency direction with separate assemblies and checked ProjectReference files rather than an onion-shaped namespace alone.
View full size ↗Read the diagram
The domain forms the center and contains business rules. Application code uses those rules to carry out use cases. Infrastructure adapts databases, networks and other external mechanisms to contracts owned by the inner layers.
The arrows show compile-time references from an outer layer toward an inner layer. They describe which project may reference another project, rather than the order in which methods execute. The composition root can wire implementations at the boundary.
This lesson includes actual Domain, Application, Infrastructure and Host projects plus a reference checker. Moving files into folders alone does not enforce the dependency rule. Introducing a forbidden project reference should fail the graph check.
Illustration created with ImageGen for SharpRune.
Walk through the portable program
The host supplies a discount amount of two and asks Checkout for a subtotal of ten. Checkout constructs an Invoice and delegates the domain calculation. Invoice validates the subtotal, obtains the policy's amount, and rejects a discount outside zero through subtotal. The valid baseline subtracts two from ten and prints due:8. The domain therefore retains responsibility for a nonnegative resulting total even though the outer policy supplies data. A policy implementation cannot make an invalid discount acceptable simply because it satisfies the interface's method signature.
Build real physical dependencies
The external Domain assembly contains both Invoice and the discount capability it consumes. Application depends on Domain for Checkout, while Infrastructure implements the domain capability. Host chooses the concrete implementation. The runtime call from Invoice to that supplied implementation is compatible with inward compile-time references because the declaration belongs to the center. ArchitectureCheck verifies the actual project files. Its successful output establishes this graph policy for these files, not a universal definition of every Onion variant or proof that the external discount source behaves correctly in production.
Pitfalls and unproved guarantees
Avoid assuming an interface automatically guarantees meaningful domain behavior. A provider can return a negative or excessive amount, so Invoice checks the invariant at its own boundary. Also avoid drawing a database as the conceptual center merely because it stores important business data; storage technology and domain policy have different reasons to change. The example uses an integer subtotal to keep results deterministic and does not implement a full money model. Currency, rounding, taxation, time-dependent discounts, and external lookup failures require additional explicit contracts rather than being solved by an inward arrow.
Practice one: isolate behavior
First supply a discount equal to the subtotal and predict a valid zero due. Then supply eleven against subtotal ten and predict InvalidOperationException. Hint: the upper boundary is inclusive, and the invariant is checked by Invoice after the provider responds. Add a negative discount as a separate rejected case. Keep expectations tied to the stated domain range, not to what a convenient implementation happens to return. These cases distinguish valid policy variation from a provider that violates the meaning of the capability.
Practice two: test a boundary
For another exercise, implement a different policy that returns one for small subtotals and two for larger ones, with clear boundary cases. Hint: preserve Invoice's final validation rather than trusting every new provider forever. Compare behavior while leaving Checkout unchanged. In a separate external copy, attempt to move an infrastructure-specific type into a Domain method signature. Explain the new compile-time dependency it would require and verify that the project policy rejects it. Do not treat the ability to put all classes in one namespace as an equivalent dependency check.
Review the evidence
Review an Onion design by asking what the central model owns, what varies outside it, and where each invariant is enforced. The sample's subtraction is small, but its dependency and validation decisions are concrete. The external assemblies show one enforceable organization, while the portable example remains useful for predicting behavior. Onion, Clean, and Hexagonal approaches share important inward-boundary ideas without making their terminology identical in every codebase. Choose names and contract locations deliberately, and document the particular policy your build checks rather than claiming a diagram alone establishes architectural correctness.
Onion policy at the center — 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 interface IDiscountPolicy { int Amount(int subtotal); }
public sealed class Invoice(int subtotal)
{
public int Total(IDiscountPolicy policy)
{
if (subtotal < 0) throw new ArgumentOutOfRangeException(nameof(subtotal));
int discount = policy.Amount(subtotal);
if (discount < 0 || discount > subtotal) throw new InvalidOperationException("Invalid discount");
return subtotal - discount;
}
}
Application/Model.cs
Download file ↗using System;
using System.Collections.Generic;
using System.Linq;
using BoundaryLab.Domain;
namespace BoundaryLab.Application;
public sealed class Checkout(IDiscountPolicy policy) { public int Total(int subtotal) => new Invoice(subtotal).Total(policy); }
Infrastructure/Model.cs
Download file ↗using System;
using System.Collections.Generic;
using System.Linq;
using BoundaryLab.Domain;
using BoundaryLab.Application;
namespace BoundaryLab.Infrastructure;
public sealed class FixedDiscount(int amount) : IDiscountPolicy { public int Amount(int subtotal) => amount; }
Host/Program.cs
Download file ↗using System;
using System.Collections.Generic;
using System.Linq;
using BoundaryLab.Domain;
using BoundaryLab.Application;
using BoundaryLab.Infrastructure;
var service = new Checkout(new FixedDiscount(2));
Console.WriteLine($"due:{service.Total(10)}");
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: due:8 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 service = new Checkout(new FixedDiscount(2));
Console.WriteLine($"due:{service.Total(10)}");
public interface IDiscountPolicy { int Amount(int subtotal); }
public sealed class Invoice(int subtotal)
{
public int Total(IDiscountPolicy policy)
{
if (subtotal < 0) throw new ArgumentOutOfRangeException(nameof(subtotal));
int discount = policy.Amount(subtotal);
if (discount < 0 || discount > subtotal) throw new InvalidOperationException("Invalid discount");
return subtotal - discount;
}
}
public sealed class Checkout(IDiscountPolicy policy) { public int Total(int subtotal) => new Invoice(subtotal).Total(policy); }
public sealed class FixedDiscount(int amount) : IDiscountPolicy { public int Amount(int subtotal) => amount; }
C# practice output
due:8
Supply a discount larger than the subtotal and predict domain rejection. The innermost policy remains responsible for the valid-total invariant.