Arranging a C# Application

Article · 15:00

An application that stays easy to change keeps its rules in one place, its I/O at the edges and its dependencies pointing inward. Plain C# for the domain, interfaces the domain owns rather than the database, internal by default, and one composition root.

Arranging a C# Application

An application that stays easy to change keeps its rules in one place, its I/O at the edges, and its dependencies pointing inward: the domain knows nothing about the database, ASP.NET, or the JSON serialiser.

The domain is plain C#

No framework, no attributes, nothing that needs a host to test.

var basket = new Basket([new Money(1999), new Money(2500), new Money(1200)]);
Console.WriteLine(basket.Total);
Console.WriteLine($"free delivery: {basket.QualifiesForFreeDelivery()}");

readonly record struct Money(long Pence)
{
    public static Money operator +(Money a, Money b) => new(a.Pence + b.Pence);
    public bool IsMoreThan(Money other) => Pence > other.Pence;
    public override string ToString() => $"{Pence / 100.0:F2}";
}

sealed class Basket
{
    private readonly IReadOnlyList<Money> _lines;

    // A defensive copy: without ToList() the caller keeps a handle on the list
    // inside this object and can change it afterwards.
    public Basket(IEnumerable<Money> lines) => _lines = lines.ToList();

    public Money Total => _lines.Aggregate(new Money(0), (sum, line) => sum + line);

    public bool QualifiesForFreeDelivery() => Total.IsMoreThan(new Money(5000));
}

Every rule there is testable with a constructor call and an assertion - no database, no mocks, no WebApplicationFactory.

Dependencies point inward

The domain declares what it needs as an interface it owns. The implementation lives further out and is handed in through the constructor.

ICourseRepository repository = new InMemoryCourses(new()
{
    ["csharp"] = new Course("csharp", "C#: From Basics to Expert"),
});

var useCase = new ViewCourse(repository);

foreach (string slug in new[] { "csharp", "cobol" })
{
    string message = useCase.Handle(slug) switch
    {
        Found(var course) => $"found {course.Title}",
        NotFound(var missing) => $"no course called {missing}",
        _ => "unreachable",
    };
    Console.WriteLine(message);
}

// --- domain ---------------------------------------------------------------
record Course(string Slug, string Title);

// The port: owned by the domain, implemented at the edge. The arrow points in.
interface ICourseRepository
{
    Course? FindBySlug(string slug);
}

abstract record Outcome;
sealed record Found(Course Course) : Outcome;
sealed record NotFound(string Slug) : Outcome;

sealed class ViewCourse(ICourseRepository courses)
{
    public Outcome Handle(string slug)
        => courses.FindBySlug(slug) is { } course ? new Found(course) : new NotFound(slug);
}

// --- edge -----------------------------------------------------------------
// In production this is EF Core or Dapper. In a test it is three lines.
sealed class InMemoryCourses(Dictionary<string, Course> rows) : ICourseRepository
{
    public Course? FindBySlug(string slug) => rows.GetValueOrDefault(slug);
}

Three things are doing work. The repository interface is declared with the rules, so the database depends on the domain rather than the other way round. The dependency arrives through the primary constructor, so a ViewCourse cannot exist in an unusable state. And the result is a type the caller must inspect, rather than a null or an exception it may ignore.

Projects, namespaces and what to make public

In .NET the compilation boundary is the project, and internal - the default for a type - means "visible inside this assembly". That makes the project the real unit of design.

src/
  Omniacad.Catalogue/              rules and ports. References nothing.
    Course.cs                      public
    ICourseRepository.cs           public
    ViewCourse.cs                  public
  Omniacad.Catalogue.Sql/          an adapter. References Catalogue.
    SqlCourseRepository.cs         internal, plus one public registration method
  Omniacad.Web/                    the host. References both.
    Program.cs                     the composition root
tests/
  Omniacad.Catalogue.Tests/        fast, no database

The dependency arrows are enforced by the project references: Catalogue cannot accidentally use EF Core, because it does not reference it. That is worth more than any amount of documentation about layering.

One composition root, deliberately dull

// In an ASP.NET application this is Program.cs and the container does the
// wiring; the shape is the same either way, and so is the rule: this is the
// only file that knows every concrete type.
var services = new Dictionary<Type, object>();

IClock clock = new FixedClock(1_700_000_000);
IMailer mailer = new ConsoleMailer();
services[typeof(Notifier)] = new Notifier(clock, mailer);

var notifier = (Notifier)services[typeof(Notifier)];
notifier.Welcome("ada@example.com");

interface IClock { long Now(); }
interface IMailer { void Send(string to, string subject); }

sealed class FixedClock(long now) : IClock { public long Now() => now; }
sealed class ConsoleMailer : IMailer
{
    public void Send(string to, string subject) => Console.WriteLine($"to {to}: {subject}");
}

sealed class Notifier(IClock clock, IMailer mailer)
{
    public void Welcome(string address) => mailer.Send(address, $"Welcome, at {clock.Now()}");
}

Anything clever in the composition root is something that cannot be tested.

What the build should enforce

Turn on <Nullable>enable</Nullable>, <TreatWarningsAsErrors>true</TreatWarningsAsErrors> and <AnalysisLevel>latest</AnalysisLevel> in Directory.Build.props, and add an .editorconfig. A team that argues about style in review is spending review on the wrong thing - and a nullable warning ignored today is a NullReferenceException in production later.

// A test against a plain domain needs no setup and runs in a millisecond.
// In a project this is xUnit; the assertions are the same shape.
AssertEqual(new Money(300), new Money(100) + new Money(200));
AssertEqual(new Money(0), new Money(0) + new Money(0));
Console.WriteLine("2 assertions passed");

static void AssertEqual<T>(T expected, T actual)
{
    if (!Equals(expected, actual))
    {
        throw new InvalidOperationException($"expected {expected} but was {actual}");
    }
}

readonly record struct Money(long Pence)
{
    public static Money operator +(Money a, Money b) => new(a.Pence + b.Pence);
}

What to take away

  • Domain rules in plain C#; I/O at the edges; dependencies pointing inward.
  • The domain owns the interface; the adapter implements it and is handed in.
  • Project references enforce the layering - the domain cannot use what it does not reference.
  • Defensive copies keep a collection genuinely owned by the object that holds it.
  • A result type makes the caller handle the outcome; internal is the right default.
  • Nullable on, warnings as errors, and one composition root that stays boring.