It’s not that hard
I think software is pretty easy to build. Not trivial — but a long way easier than our industry makes it look. In two decades of walking into other people’s codebases, the ones that were hard were almost never hard because the problem was hard. They were hard because somebody had invented their own way of structuring the thing, and everyone who came after had to learn it before they could change a line.
The truth is that a well-behaved application only needs about six kinds of class. Each has one job, a short list of rules, and an obvious way to test it. Pick the right abstraction for each, keep the dependencies pointing the right way (I’ve banged on about that before), and most of the rest of the design takes care of itself. Everything else is ceremony.
Most hard codebases aren’t solving hard problems. They’re just hard to read.
Here’s the list, top to bottom, with where the tests start and where the mocks stop for each. The names are mine; you’ll have your own, and that’s fine, as long as everyone on the team agrees what they mean.

The entry point
Program.cs, or whatever your platform calls it. It has one job: build the host, pull in configuration, register everything else, and check that the resources the application needs are actually reachable before it claims to be up. If the database isn’t there, I want to know at startup, not on the first request.
var builder = WebApplication.CreateBuilder(args);builder.Services.AddHttpClient<IPaymentGatewayClient, PaymentGatewayClient>(c => c.BaseAddress = new Uri(builder.Configuration["Payments:BaseUrl"]!));builder.Services.AddDbContext<OrdersDbContext>(o => o.UseSqlServer(builder.Configuration.GetConnectionString("Orders")));builder.Services.AddSingleton<IServiceBusPublisher, ServiceBusPublisher>();builder.Services.AddScoped<IOrderRepository, OrderRepository>();builder.Services.AddScoped<IPricingManager, PricingManager>();builder.Services.AddScoped<IOrderProcessor, OrderProcessor>();var app = builder.Build();// Fail fast: if we can't reach what we depend on, don't start.using (var scope = app.Services.CreateScope()){ await scope.ServiceProvider.GetRequiredService<OrdersDbContext>().Database.CanConnectAsync() ? Task.CompletedTask : throw new InvalidOperationException("Orders database is not reachable.");}app.MapControllers();app.Run();
Test coverage here is low, and I’m comfortable with that. If the wiring is wrong the application won’t start, and you’ll know in seconds. The integration tests further down exercise it anyway.
If the only way to find a bug is to start the app, that’s not a gap in coverage. It’s the cheapest test you’ve got.
Domain services
Managers, Processors, Services — call them what you like, but this is the only place domain logic belongs. If you find a business rule anywhere else on this list, something has leaked.
Everything a domain service needs arrives by constructor: high-level clients, repositories, wrappers, and occasionally another domain service. That makes the test boundary obvious. Mock every injected dependency — including the other domain service — and test the logic in isolation. The integration tests will prove the pieces compose; the unit tests prove each piece is right. Aim for 100% here, because this is where the bugs that matter live.
The other rule: don’t throw for expected outcomes. A declined payment isn’t exceptional, it’s Tuesday. Handle it, log it, and return a rich result the caller can act on. Exceptions are for the things you didn’t plan for.
public class OrderProcessor( IPricingManager pricing, IPaymentGatewayClient payments, IOrderRepository orders, IServiceBusPublisher bus, ILogger<OrderProcessor> log) : IOrderProcessor{ public async Task<PlaceOrderResult> PlaceAsync(PlaceOrderRequest request) { var quote = pricing.Quote(request.Lines); var payment = await payments.AuthoriseAsync(request.CardToken, quote.Total); if (payment.Outcome == PaymentOutcome.Declined) { log.LogInformation("Payment declined for {Customer}", request.CustomerId); return PlaceOrderResult.Declined(payment.Reason); } if (payment.Outcome == PaymentOutcome.Unavailable) { log.LogWarning("Payment gateway unavailable"); return PlaceOrderResult.TryLater(); } var record = new OrderRecord(request.CustomerId, quote.Total, payment.AuthorisationId); var id = await orders.InsertAsync(record); await bus.PublishAsync(new OrderPlaced(id, request.CustomerId)); return PlaceOrderResult.Placed(id); }}
And the test that goes with it — every dependency mocked, assertion on the result, not on an exception:
[Fact]public async Task Declined_payment_returns_declined_result_and_stores_nothing(){ var payments = Substitute.For<IPaymentGatewayClient>(); payments.AuthoriseAsync(Arg.Any<string>(), Arg.Any<decimal>()) .Returns(new PaymentResponse(PaymentOutcome.Declined, Reason: "Insufficient funds")); var orders = Substitute.For<IOrderRepository>(); var sut = new OrderProcessor( Substitute.For<IPricingManager>(), payments, orders, Substitute.For<IServiceBusPublisher>(), NullLogger<OrderProcessor>.Instance); var result = await sut.PlaceAsync(SomeRequest()); result.Status.Should().Be(PlaceOrderStatus.Declined); await orders.DidNotReceive().InsertAsync(Arg.Any<OrderRecord>());}
A domain service that throws for an expected outcome is making its caller write the domain logic for it.
High-level clients
A high-level client represents one external resource that needs a network hop to reach — a REST API, say. It takes a low-level client (HttpClient) by constructor, and it contains no domain logic at all.
Its real job is translation of failure, not of concepts. The low-level client will throw HttpRequestException, time out, or hand you a 503. None of those mean anything to the domain. The client maps them into results that describe the resource: the payment was declined, the gateway is unavailable. The transport stays the client’s secret.
The signatures, though, should mirror the downstream resource, not the caller. If the gateway thinks in authorisations and captures, the client exposes authorisations and captures. Turning those into “place an order” is domain logic, and it belongs in the service that calls it.
public class PaymentGatewayClient(HttpClient http) : IPaymentGatewayClient{ public async Task<PaymentResponse> AuthoriseAsync(string cardToken, decimal amount) { try { var response = await http.PostAsJsonAsync("/v1/authorisations", new { cardToken, amount }); if (response.StatusCode == HttpStatusCode.PaymentRequired) return new PaymentResponse(PaymentOutcome.Declined, Reason: await response.Content.ReadAsStringAsync()); response.EnsureSuccessStatusCode(); var body = await response.Content.ReadFromJsonAsync<AuthorisationDto>(); return new PaymentResponse(PaymentOutcome.Authorised, AuthorisationId: body!.Id); } catch (Exception e) when (e is HttpRequestException or TaskCanceledException) { // The transport failed. The domain only needs to know the gateway isn't there. return new PaymentResponse(PaymentOutcome.Unavailable); } }}
Unit test it as far as you can, with the low-level client mocked — for HttpClient that means a fake HttpMessageHandler returning whatever status you want to provoke. Then, in everyone else’s tests, the high-level client is the thing being mocked.
A high-level client speaks the resource’s language. Translating it into yours is the domain service’s job.
Repositories
A repository is a high-level client whose external resource happens to be storage. All the same rules apply: low-level client in by constructor, no domain logic, failures mapped to something meaningful.
One distinction worth being strict about: repository models represent what’s stored. If that sounds like a table, that’s because it usually is. The textbook says otherwise — a repository should hand back domain objects and keep the domain “persistence ignorant” — and I’ll admit I don’t do that. Mapping between storage and domain is logic, and putting it in the repository puts it in the one class I’ve just said doesn’t get unit tests. It also drags the schema along behind every domain change. So I keep the repository dumb and let the domain service do any mapping, where it’s covered by the same tests as everything else.
In practice the mapping is often nothing at all. At least to begin with, why wouldn’t the stored data be shaped like the domain? The service holds the record, changes it, and hands it back. The two shapes only drift apart later, for storage reasons — a normalised table, a denormalised read model, an event stream, a legacy schema — and when they do, the repository owns the stored shape and the domain service owns the translation. Nothing above the service ever knows what the table looks like.
public class OrderRepository(OrdersDbContext db) : IOrderRepository{ public async Task<Guid> InsertAsync(OrderRecord record) { db.Orders.Add(record); await db.SaveChangesAsync(); return record.Id; }}
Testing is where repositories differ from other clients. I rarely see a reason to unit test one. There’s no logic to isolate — it’s a thin layer over a query — so mocking the DbContext proves very little. A simple integration test that writes a payload to a real database and reads it back verifies everything that matters, and it’s essential once the query code is dynamic rather than strongly typed. Spin up a container, run the test, assert on what came back. In the domain service tests, the repository is mocked like everything else.
A repository that maps aggregates to rows is logic hiding in the one class you promised not to test.
Third-party wrappers
Some vendor SDKs — messaging is the classic — expose a vast, enormously flexible API. Your team needs about ten per cent of it, configured the same way every time. So wrap it: hide the rest, bake in the standard settings once, and expose the one or two methods people actually need.
public class ServiceBusPublisher(ServiceBusClient client) : IServiceBusPublisher{ private readonly ServiceBusSender _sender = client.CreateSender("orders"); public Task PublishAsync<T>(T message) where T : IEvent => _sender.SendMessageAsync(new ServiceBusMessage(JsonSerializer.Serialize(message)) { ContentType = "application/json", Subject = typeof(T).Name, MessageId = Guid.NewGuid().ToString(), });}
Thin by design, no domain logic, and nobody else on the team needs to open the vendor’s documentation. In tests it mostly shows up as a mock in other classes’ constructors. Give it tests of its own if it earns them; most don’t.
Nobody on the team should need to know the vendor SDK. That’s the whole point of the wrapper.
Request handlers
Controllers, message consumers, function triggers. These exist only to define how an external system interacts with your application. No business logic. Take the request, hand it to a domain service, then translate the result into whatever the outside world expects: a status code, problem details, a useful message.
[ApiController, Route("orders")]public class OrdersController(IOrderProcessor processor) : ControllerBase{ [HttpPost] public async Task<IActionResult> Place(PlaceOrderRequest request) { var result = await processor.PlaceAsync(request); return result.Status switch { PlaceOrderStatus.Placed => CreatedAtAction(nameof(Get), new { id = result.OrderId }, result), PlaceOrderStatus.Declined => Problem(result.Reason, statusCode: StatusCodes.Status402PaymentRequired), PlaceOrderStatus.TryLater => Problem("Payments are temporarily unavailable", statusCode: 503), _ => throw new UnreachableException(), }; }}
I don’t unit test these. Integration tests enter here, run the real stack, and only reach for test doubles at the process edge — a stubbed API, a database in a container, an emulator. If the controller translates the wrong result to the wrong status code, that test fails, and it fails for the right reason.
A controller is a translator, not a decision-maker.
Why this works
Look at the diagram again and notice what isn’t there. There’s no clever pattern, no framework, no architecture with a name. Every arrow points downward, towards something that changes more slowly — which is dependency inversion in everyday clothes. Every class has exactly one reason to change, so the test boundary for each is never a debate: the class, plus mocks for whatever it was handed. And a new starter can orient themselves in a day, because there are six names to learn and nothing else.
Finally
These six aren’t a framework, and they certainly aren’t an architecture. They’re just the minimum set of names needed to describe what any application has to do: start, accept a request, decide something, talk to the outside world, remember, and hide the vendors’ mess. A lot of the complexity we live with comes from trying to be too clever, and straying from the obvious path. What do you think?



Leave a Reply