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;
internalis the right default. - Nullable on, warnings as errors, and one composition root that stays boring.