Architecture & Boundaries · Lesson 2

Clean entities use cases and presenters

Keep domain arithmetic separate from orchestration and text presentation.

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

Clean Architecture separates stable domain behavior, application orchestration, and outer presentation or infrastructure details. This sample uses an Order entity for quantity and arithmetic, QuoteUseCase for the application operation, and an IQuotePresenter output port for a typed result. TextPresenter is one outer implementation of that port. The pattern is about responsibility and inward dependencies, not a requirement to install a particular framework or create four projects for every simple function. The portable example makes the roles inspectable, while its complete .NET 10 solution demonstrates actual assembly references and a real console composition root.

Walk through the portable program

Order is constructed with two units, which satisfies its positive-unit constructor rule. QuoteUseCase asks that Order for a total at rate three and creates a QuoteResult containing the units and total. It passes that typed result to the presenter. TextPresenter formats the total and retains the text for the host to print, producing total:6. No domain object writes to Console, and the use case does not depend on a concrete text presenter. Following the typed result through the operation explains where calculation ends and representation begins.

Build real physical dependencies

In the external solution, Order is compiled into Domain, the use case and presenter interface into Application, and TextPresenter into Infrastructure. Host references the public roles needed for composition, but Application has no reference to the concrete presenter assembly. A runtime callback through IQuotePresenter does not reverse that compile-time rule. The supplied ArchitectureCheck program reads the four business project files and verifies their allowed edges. A successful graph result complements the expected host text; it does not demonstrate that the presenter is suitable for every UI or transport contract.

Pitfalls and unproved guarantees

Do not turn a presenter into an excuse to shuttle mutable domain entities directly into a view that can change them. QuoteResult is a small immutable value boundary, and formatting does not mutate Order. Also distinguish an entity's invariant from an input parser's syntax checks: a positive typed quantity can be valid even though an earlier representation needed different validation. The example's fixed rate is deliberately bounded. Real pricing, authorization, persistence, and external failures would need explicit rules; architectural role names do not supply those missing behaviors automatically.

Practice one: isolate behavior

First implement a second presenter that records both units and total without formatting a string. Predict units two and total six. Hint: keep QuoteUseCase unchanged and supply the new presenter through its existing interface. Then alter only the text presenter to include a different label and verify that the typed result remains identical. These scenarios distinguish domain and use-case correctness from a representation change. Keep an independent numeric expectation so two presenters sharing the same wrong calculation cannot make the output-port abstraction look more correct than it is.

Practice two: test a boundary

For another exercise, test an invalid Order construction with zero units and predict rejection before the use case can run. Then add a valid three-unit scenario and predict total nine. Hint: arrange separate inputs so a failed constructor does not prevent the valid scenario from being observed. In the external solution, deliberately try naming TextPresenter inside Application without adding a forbidden reference. The compiler should reject unavailable infrastructure types; adding that reference would then violate the graph policy. Keep both failure experiments outside the runnable baseline.

Review the evidence

Review this design by locating the entity invariant, application operation, output data, presenter contract, and host composition. Decide whether each separation makes a real change easier to contain. The sample does not prescribe a framework, mediator, web controller, or number of deployment units. Its external projects show one concrete way to enforce the chosen dependencies, while the portable source teaches the same small behavior in one compilation. Use those two forms honestly: a working single file is behavioral evidence, and a physical reference check is evidence about a particular assembly graph.

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

Clean entities use cases and presenters — 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 sealed class Order
{
    public int Units { get; }
    public Order(int units) { if (units < 1) throw new ArgumentOutOfRangeException(nameof(units)); Units = units; }
    public int Total(int rate) => checked(Units * rate);
}
Application/Model.csDownload file ↗
using System;
using System.Collections.Generic;
using System.Linq;
using BoundaryLab.Domain;

namespace BoundaryLab.Application;

public record QuoteResult(int Units, int Total);
public interface IQuotePresenter { void Present(QuoteResult result); }
public sealed class QuoteUseCase(IQuotePresenter presenter)
{
    public void Execute(Order order) => presenter.Present(new QuoteResult(order.Units, order.Total(3)));
}
Infrastructure/Model.csDownload file ↗
using System;
using System.Collections.Generic;
using System.Linq;
using BoundaryLab.Domain;
using BoundaryLab.Application;

namespace BoundaryLab.Infrastructure;

public sealed class TextPresenter : IQuotePresenter
{
    public string Text { get; private set; } = "";
    public void Present(QuoteResult result) => Text = "total:" + result.Total;
}
Host/Program.csDownload file ↗
using System;
using System.Collections.Generic;
using System.Linq;
using BoundaryLab.Domain;
using BoundaryLab.Application;
using BoundaryLab.Infrastructure;

var presenter = new TextPresenter();
new QuoteUseCase(presenter).Execute(new Order(2));
Console.WriteLine(presenter.Text);
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: total:6 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 presenter = new TextPresenter();
new QuoteUseCase(presenter).Execute(new Order(2));
Console.WriteLine(presenter.Text);

public sealed class Order
{
    public int Units { get; }
    public Order(int units) { if (units < 1) throw new ArgumentOutOfRangeException(nameof(units)); Units = units; }
    public int Total(int rate) => checked(Units * rate);
}

public record QuoteResult(int Units, int Total);
public interface IQuotePresenter { void Present(QuoteResult result); }
public sealed class QuoteUseCase(IQuotePresenter presenter)
{
    public void Execute(Order order) => presenter.Present(new QuoteResult(order.Units, order.Total(3)));
}

public sealed class TextPresenter : IQuotePresenter
{
    public string Text { get; private set; } = "";
    public void Present(QuoteResult result) => Text = "total:" + result.Total;
}
Open C# practice

C# practice output

total:6
Try this
Add a different presenter that receives the same typed result. Do not put display formatting into Order to make one host easier to write.

Further reading