Programming · C#/.NET

Modern .NET: DI, Minimal APIs, and Practical Architecture

A current view of building clean services in C#.

Reading time: ~8–12 min
Level: All levels
Updated:

“Modern .NET” today is less about frameworks and more about habits: using Dependency Injection (DI) correctly, keeping endpoints small with Minimal APIs, and choosing an architecture that stays readable after your third feature and fifth refactor. This post shows a practical, production-minded way to build clean services in C#—without turning your codebase into a ceremony factory.


Quickstart

If you want the highest-impact improvements first, do these in order. Each step is “small enough to do today” but compounds quickly when you ship more endpoints.

1) Treat Program.cs as the composition root

Your application should be assembled in one place: configuration, DI registrations, and HTTP pipeline. Keep business logic elsewhere.

  • Register services (lifetimes matter)
  • Configure middleware (logging, errors, auth, routing)
  • Map endpoints (but keep handlers thin)

2) Organize endpoints by feature, not by technical layer

“Controllers/Services/Repositories” can work, but feature-based grouping scales better for real APIs.

  • Route groups (e.g., /orders, /users)
  • One file/folder per feature slice
  • Handlers call application services—not EF directly

3) Fix DI lifetimes before you “optimize” anything

Incorrect lifetimes cause flaky bugs, memory leaks, and concurrency issues that look like “random production weirdness.”

  • Scoped for request-bound things (DbContext, unit-of-work)
  • Singleton for stateless, thread-safe caches/config
  • Transient for small pure services

4) Add two guardrails: errors + validation

Your API needs predictable error shapes and a consistent rule for “bad input,” otherwise clients and tests become brittle.

  • Centralize exception → problem details mapping
  • Validate request DTOs (fail fast)
  • Return consistent status codes (400/404/409/500)
A 60-second smell test

If an endpoint handler has more than ~10–20 lines of “real work” (branching, data access, complex mapping), it’s time to extract an application service and keep the HTTP layer thin.

Overview

ASP.NET Core makes it easy to start an API. The hard part is keeping it clean as it grows: more endpoints, more dependencies, more integration points, more “just one quick change” requests.

What this post covers

  • Dependency Injection (DI): lifetimes, registration patterns, and avoiding the service-locator trap
  • Minimal APIs: slim endpoints with route groups, typed responses, and predictable error handling
  • Practical architecture: boundaries that keep business logic testable and infrastructure replaceable
  • Production basics: observability, cancellation, and testing patterns that catch regressions early

The goal is not “perfect architecture.” It’s an API that’s easy to reason about when you’re tired, shipping fast, and debugging a bug report with a screenshot and no reproduction steps.

Problem you feel Usually caused by What we’ll do instead
Program.cs is a 600-line wall Endpoints + wiring mixed with logic Keep Program.cs as composition; move features to modules
“Random” runtime bugs Wrong DI lifetimes + shared mutable state Choose lifetimes intentionally; isolate state
Hard to test anything Business rules hidden inside controllers/endpoints Application services + thin HTTP layer
Every feature touches 8 folders Over-layering (ceremony) Feature-first slices with clear boundaries
Minimal APIs vs “Minimal effort”

Minimal APIs reduce boilerplate, not discipline. You still need clear boundaries, validation, and consistent error handling. The difference is you get to spend your complexity budget on the code that matters.

Core concepts

Before the walkthrough, align on a few mental models. If these click, most .NET architecture decisions become simpler.

DI as a graph, not a “bag of services”

The built-in container assembles an object graph: classes depend on interfaces, and DI creates them in the right order. When your graph is healthy, constructors stay small, responsibilities stay narrow, and tests are easy.

Service lifetimes (the ones you must get right)

Lifetime Created when Use for Watch out for
Singleton Once for the app Stateless services, caches, config readers Thread-safety; accidental shared mutable state
Scoped Once per request DbContext/unit-of-work, request context Never inject scoped into singleton
Transient Every resolution Small pure helpers Too many allocations if abused

The HTTP pipeline: middleware, endpoints, and cross-cutting rules

Your request flows through middleware (logging, errors, auth) and ends at an endpoint handler. Architecture is mostly deciding where logic lives: cross-cutting rules belong in middleware/filters, business rules belong in application/domain services.

Put it in the endpoint handler when…

  • It’s pure HTTP concerns (status codes, headers)
  • It’s simple request-to-command mapping
  • It’s a tiny, obvious query

Put it in a service when…

  • It’s business rules (pricing, permissions, invariants)
  • It needs testing without HTTP
  • It touches storage/external systems
  • It’s reused across multiple endpoints

Practical architecture: boundaries that pay rent

The most useful boundary in a service is: business logic doesn’t know infrastructure. That doesn’t mean you need 12 projects and three diagrams. It means a few disciplined rules:

  • HTTP layer maps requests → application commands/queries, returns results
  • Application layer coordinates use-cases (transaction boundaries, orchestration)
  • Domain layer holds invariants and core rules (pure, testable)
  • Infrastructure implements ports (DB, queues, external APIs) and stays replaceable
The architecture trap

If your “architecture” makes simple features slower to implement, you’ll bypass it under pressure—and end up with a messy hybrid. Prefer small boundaries you can keep consistently, even when rushing.

Step-by-step

Let’s build a small API the way you’d want it to look after 6 months: Minimal APIs, DI that behaves, and a structure that doesn’t fight you. The examples below are intentionally compact, but the patterns scale to real services.

Step 1 — Scaffold a solution that can grow

Start with a clean solution layout. Even if you keep everything in one project initially, create a place for tests from day one. This makes refactoring toward stronger boundaries much easier later.

# Create solution + API project
dotnet new sln -n ModernNetSample
dotnet new web -n Api
dotnet sln add Api/Api.csproj

# Common API add-ons (optional, but practical)
dotnet add Api package Swashbuckle.AspNetCore

# Test project (integration-style)
dotnet new xunit -n Api.Tests
dotnet add Api.Tests reference Api/Api.csproj
dotnet add Api.Tests package Microsoft.AspNetCore.Mvc.Testing

# Run tests (should pass)
dotnet test
Start “thin HTTP” immediately

Even in a tiny API, treat endpoints as IO glue. It prevents the classic outcome where controllers/endpoints become your real domain model.

Step 2 — Keep Program.cs focused: compose, configure, map

Program.cs is where you wire up the app. The goal is to make it readable like a checklist: register services → configure middleware → map endpoints. Everything else should live elsewhere.

using Microsoft.AspNetCore.Diagnostics;
using Microsoft.Extensions.Options;

var builder = WebApplication.CreateBuilder(args);

// --- Service registration (DI) ---
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

// Example options binding (configuration -> typed options)
builder.Services.Configure<TodoOptions>(builder.Configuration.GetSection("Todo"));

// Application services (keep state out; make them testable)
builder.Services.AddScoped<TodoService>();

// --- Build app ---
var app = builder.Build();

// --- Middleware pipeline ---
app.UseExceptionHandler("/error");
app.UseStatusCodePages();

app.UseSwagger();
app.UseSwaggerUI();

// --- Endpoints ---
app.MapGet("/health", () => Results.Ok(new { status = "ok" }))
   .WithName("Health");

var todos = app.MapGroup("/todos").WithTags("Todos");

todos.MapGet("/", async (TodoService svc, CancellationToken ct) =>
{
    var items = await svc.ListAsync(ct);
    return Results.Ok(items);
})
.WithName("ListTodos");

todos.MapPost("/", async (CreateTodoRequest req, TodoService svc, IOptions<TodoOptions> opts, CancellationToken ct) =>
{
    if (string.IsNullOrWhiteSpace(req.Title))
        return Results.BadRequest(new { error = "Title is required." });

    if (req.Title.Length > opts.Value.MaxTitleLength)
        return Results.BadRequest(new { error = $"Title must be <= {opts.Value.MaxTitleLength} chars." });

    var created = await svc.CreateAsync(req, ct);
    return Results.Created($"/todos/{created.Id}", created);
})
.WithName("CreateTodo");

// Centralized error shape (Problem Details)
app.MapGet("/error", (HttpContext ctx) =>
{
    var feature = ctx.Features.Get<IExceptionHandlerFeature>();
    var ex = feature?.Error;

    return Results.Problem(
        title: "Unhandled error",
        detail: ex?.Message,
        statusCode: StatusCodes.Status500InternalServerError);
});

app.Run();

// --- Simple request/response contracts ---
record CreateTodoRequest(string Title);
record TodoDto(Guid Id, string Title, bool Done);

sealed class TodoOptions
{
    public int MaxTitleLength { get; set; } = 120;
}

// --- Application service (placeholder implementation) ---
sealed class TodoService
{
    private static readonly List<TodoDto> _store = new();

    public Task<IReadOnlyList<TodoDto>> ListAsync(CancellationToken ct)
        => Task.FromResult((IReadOnlyList<TodoDto>)_store);

    public Task<TodoDto> CreateAsync(CreateTodoRequest req, CancellationToken ct)
    {
        var dto = new TodoDto(Guid.NewGuid(), req.Title.Trim(), false);
        _store.Add(dto);
        return Task.FromResult(dto);
    }
}

In a real service, you would replace the in-memory list with persistence, and you would move TodoService and DTOs into feature files. But the shape matters: endpoints are thin, DI is explicit, and errors are standardized.

Step 3 — Make architecture decisions that stay easy to follow

You don’t need a “big rewrite” to get good architecture. You need a few repeatable patterns that keep files small and responsibilities clear. Here’s a simple way to structure a Minimal API codebase:

Area What goes there Rule of thumb
Api/ Program.cs, endpoint modules, HTTP concerns Knows HTTP, does not own business rules
Features/* Requests, handlers/services, validators, contracts Organize by “what the user wants” (use-cases)
Domain/ Entities/value objects, invariants No EF, no HTTP, no logging
Infrastructure/ DB access, external clients, message bus adapters Pluggable implementation details
You can start in one project

You can keep these as folders first. Split into multiple projects only when boundaries are stable and you truly benefit (build times, shared libs, separate deployment units).

Step 4 — Add testing that protects behavior, not implementation

The fastest confidence boost is an integration-style test that boots your API in-memory and calls endpoints with HTTP. This catches DI misconfigurations, routing mistakes, serialization issues, and behavior regressions—without over-mocking.

using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;

public class TodosApiTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;

    public TodosApiTests(WebApplicationFactory<Program> factory)
    {
        // CreateClient boots the API in-memory (no real network).
        _client = factory.CreateClient();
    }

    [Fact]
    public async Task Post_then_list_contains_created_item()
    {
        var create = await _client.PostAsJsonAsync("/todos", new { title = "Ship it" });
        create.EnsureSuccessStatusCode();

        var items = await _client.GetFromJsonAsync<TodoDto[]>("/todos");
        Assert.NotNull(items);
        Assert.Contains(items!, x => x.Title == "Ship it");
    }

    // Mirror the API response contract to keep tests stable.
    private record TodoDto(Guid Id, string Title, bool Done);
}

Notice what we didn’t test: private methods, internal data structures, or “did method X get called.” We tested the contract: if we POST a todo, we can later GET it. That’s the behavior your users care about.

Step 5 — Production habits: cancellation, consistency, and observability

These are “small” details that prevent big outages and painful debugging sessions later:

Request handling habits

  • Accept CancellationToken and pass it down
  • Return consistent problem responses for errors
  • Prefer explicit status codes over “magic exceptions”
  • Keep handlers small; keep business rules testable

Operational habits

  • Log key decisions (not noise)
  • Expose a health endpoint (and keep it cheap)
  • Version APIs when contracts change
  • Measure worst-case latency, not just average
Avoid “ambient” state

If your service behavior depends on static state, thread-local hacks, or shared mutable singletons, it will eventually break under load. Prefer request-scoped dependencies and pure functions where possible.

Common mistakes

These are the real-world footguns that show up in code reviews (and production incidents). The fixes are usually simple once you know the pattern.

Mistake 1 — Injecting a scoped service into a singleton

Example: a singleton cache depends on DbContext. This can crash at startup or behave unpredictably.

  • Fix: make the consumer scoped, or inject a factory (IServiceScopeFactory) with care.
  • Fix: keep singletons stateless and thread-safe.

Mistake 2 — Treating Minimal APIs as “throw everything into Program.cs”

Minimal APIs remove controllers, not structure. Without modules, Program.cs grows into a monolith.

  • Fix: use route groups + “endpoint modules” per feature.
  • Fix: keep handler code small and push work into services.

Mistake 3 — Business rules in the HTTP layer

If your business logic is buried inside endpoint handlers, it becomes hard to test and easy to duplicate.

  • Fix: create application services for use-cases (commands/queries).
  • Fix: make domain rules explicit (entities/value objects).

Mistake 4 — Inconsistent error handling

Clients hate “sometimes it’s JSON, sometimes it’s plain text.” Tests become fragile too.

  • Fix: standardize on Problem Details for failures.
  • Fix: map known exceptions to explicit status codes (404/409/422).

Mistake 5 — Confusing “clean” with “many layers”

Over-layering creates ceremony: every feature requires editing five projects and twelve interfaces.

  • Fix: start feature-first, then extract boundaries when they pay off.
  • Fix: keep a strict rule: business logic doesn’t depend on infrastructure.

Mistake 6 — Ignoring cancellation and timeouts

Under load, slow downstream calls pile up. Without cancellation, your server keeps doing work for clients who already left.

  • Fix: accept CancellationToken and pass it down to IO calls.
  • Fix: set timeouts on outbound calls (HTTP/DB where applicable).
The most common “architecture bug”

Not having a clear boundary between “decision code” (rules) and “plumbing” (HTTP/DB/serialization). Fix that boundary first; most other problems shrink automatically.

FAQ

Should I use Minimal APIs or controllers?

Use Minimal APIs when you want low ceremony, explicit routing, and a slim surface area—especially for small-to-medium services and internal APIs. Controllers can still be a good fit when you rely heavily on MVC conventions, attributes, or existing controller-based tooling. The key is consistency: pick one approach per service and standardize patterns (validation, errors, authorization) across all endpoints.

What’s the “composition root” in modern .NET?

The composition root is the place where you wire your app together: DI registrations, configuration binding, middleware pipeline, and endpoint mapping. In ASP.NET Core, this is typically Program.cs. Keeping this explicit makes the rest of your code easier to test and reason about.

How do I choose DI lifetimes correctly?

Start from “who owns the state.” If something is request-specific (like database units of work), it’s usually scoped. If it’s stateless and thread-safe, it can be singleton. If it’s a light helper with no shared state, transient works. When unsure, default to scoped for services that touch IO and transient for small pure services.

How do I keep Minimal API projects organized as they grow?

Use route groups and feature modules. Each feature gets its own endpoint mapping method (or class) and its own request/response contracts. Endpoints call application services; application services call infrastructure via interfaces. This keeps Program.cs readable and prevents “God files.”

How should I handle validation and errors?

Validate inputs as close to the boundary as possible (request DTO validation) and return predictable errors. Standardize failures with Problem Details so clients and tests see a consistent shape. For domain/business errors, prefer explicit results (e.g., “not found”, “conflict”) mapped to 404/409 rather than throwing exceptions for control flow.

What’s a reasonable “clean architecture” for a small service?

Keep it pragmatic: feature folders, a small application layer for use-cases, and domain code that doesn’t depend on EF/HTTP. You can keep everything in one project as folders. Split into multiple projects only when boundaries stabilize and you benefit from separate build/deployment units.

How do I test Minimal APIs without over-mocking?

Use integration-style tests with an in-memory server (via WebApplicationFactory). This exercises routing, JSON, DI, and middleware. Keep a small number of unit tests for pure domain logic, but let HTTP tests protect the API contract.

Cheatsheet

A scan-fast checklist you can keep next to your editor. If you follow this consistently, you’ll avoid most “it got messy” outcomes.

Minimal API endpoint checklist

  • Handler is thin (maps request → service call → response)
  • Accepts CancellationToken and passes it down
  • Returns explicit status codes (200/201/400/404/409)
  • Uses route groups for feature organization
  • Doesn’t reach directly into infrastructure when avoidable

DI sanity checklist

  • No scoped dependencies inside singletons
  • Singletons are thread-safe and stateless
  • Request-specific state is scoped
  • Prefer constructor injection; avoid service locator
  • Keep registrations close to their feature when possible

Architecture “pays rent” checklist

  • Business rules are testable without HTTP
  • Domain code doesn’t depend on EF/logging/HTTP
  • Infrastructure implements interfaces (ports), not the other way around
  • Each feature has a clear entry point (endpoints) and use-case service
  • Refactors improve readability (not only abstractions)

“Ready to ship” checklist

  • Standardized errors (Problem Details)
  • Health endpoint exists and is cheap
  • Basic integration tests cover key flows
  • Logs include request IDs / correlation basics
  • Known failure modes documented (timeouts, downstream errors)
If you only adopt one rule

Keep endpoints thin. It’s the simplest constraint that keeps your service testable, readable, and maintainable.

Wrap-up

Modern .NET makes building APIs fast—but staying fast requires structure: DI lifetimes that make sense, Minimal API endpoints that stay thin, and an architecture boundary where business rules don’t depend on infrastructure.

What to do next (pick one)

  • Refactor one endpoint: move business logic into a service, keep the handler as glue
  • Audit DI lifetimes: find singletons with hidden state and fix them
  • Add one integration test: protect your most important API flow with HTTP-level tests
  • Modularize routing: use route groups and feature files to keep Program.cs readable

If you want to reinforce the habits, re-skim the Cheatsheet after your next feature. It’s designed to catch “small drift” before it becomes a rewrite.

Quiz

Quick self-check (demo). This quiz is auto-generated for programming / csharp / dotnet.

1) Which DI lifetime is usually appropriate for a database unit-of-work (for example, a DbContext) in a web API?
2) In a Minimal API, what is the primary job of an endpoint handler?
3) What does “composition root” mean in a .NET service?
4) What’s a practical way to keep a Minimal API codebase organized as it grows?