Beyond Tutorial .NET: Patterns That Actually Matter in Production
- ShiftQuality Contributor
- May 3
- 6 min read
You finished the tutorial. You can build a controller, return JSON, connect to a database. The todo app works. Congratulations — you have reached the point where most .NET learning resources abandon you.
The gap between tutorial .NET and production .NET is not about knowing more APIs. It is about understanding why the framework is structured the way it is, and using that structure instead of fighting it. Dependency injection is not ceremony — it is how you keep a 50,000-line codebase testable. Middleware is not boilerplate — it is how you enforce cross-cutting behavior without scattering it across every controller. The patterns that feel like unnecessary complexity at tutorial scale are the patterns that prevent collapse at production scale.
This post covers the three areas where most developers stall after tutorials: dependency injection, the middleware pipeline, and the architectural patterns that keep real C# applications maintainable over time.
Dependency Injection: It's Not About the Container
Every ASP.NET Core tutorial shows you builder.Services.AddScoped<IUserService, UserService>() and moves on. You register services. The framework injects them. It works. But the tutorial never explains why this matters or what happens when you get the lifetimes wrong.
Dependency injection is not about the container. It is about controlling how objects are created, how long they live, and what they depend on. The container automates this, but the design thinking is what matters.
Lifetimes Are Architecture Decisions
The three service lifetimes — transient, scoped, and singleton — are not interchangeable labels. They are architectural decisions with consequences.
Transient creates a new instance every time the service is requested. Use this for lightweight, stateless services where sharing an instance could cause problems. A service that builds a report or formats a response is a good candidate.
Scoped creates one instance per HTTP request. This is the correct default for most application services. A database context should be scoped — one context per request, disposed when the request ends. A service that tracks state within a request should be scoped.
Singleton creates one instance for the entire application lifetime. Use this for services that are expensive to create and safe to share across threads — HTTP client factories, configuration objects, caches.
The bug that bites every intermediate .NET developer: injecting a scoped service into a singleton. The singleton lives forever. The scoped service it captured was created for a single request. That scoped service — and its database connection — is now held open indefinitely, shared across requests that should have their own instances.
// This is a bug. BackgroundWorker is singleton, IDbContext is scoped.
public class BackgroundWorker : IHostedService
{
private readonly IDbContext _db; // captured scoped service — stale, shared, broken
public BackgroundWorker(IDbContext db)
{
_db = db;
}
}
The fix is to inject IServiceScopeFactory and create a scope when you need the scoped service:
public class BackgroundWorker : IHostedService
{
private readonly IServiceScopeFactory _scopeFactory;
public BackgroundWorker(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}
public async Task DoWork()
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IDbContext>();
// db is properly scoped — created here, disposed when scope ends
}
}
This is not an edge case. Every application with background processing, hosted services, or singleton caches hits this problem. Understanding lifetimes is not optional knowledge.
Registration Is Design
How you register services communicates your architecture. Dumping everything into Program.cs as a wall of AddScoped calls is technically functional and practically unreadable.
Organize registrations by feature or layer using extension methods:
public static class ServiceRegistration
{
public static IServiceCollection AddUserFeature(this IServiceCollection services)
{
services.AddScoped<IUserRepository, UserRepository>();
services.AddScoped<IUserService, UserService>();
services.AddScoped<IUserValidator, UserValidator>();
return services;
}
}
// In Program.cs
builder.Services.AddUserFeature();
builder.Services.AddOrderFeature();
builder.Services.AddNotificationFeature();
This is not just aesthetics. When someone new joins the project and asks "where is the user service registered?", the answer is obvious. When you need to swap an implementation for testing, you know exactly where the registration lives.
The Middleware Pipeline: Order Is Everything
The ASP.NET Core middleware pipeline is a sequence of components that process every HTTP request in order. Each middleware can inspect the request, modify the response, or short-circuit the pipeline entirely. The order in which you add middleware is the order in which it executes, and getting it wrong produces bugs that are subtle and persistent.
A typical production pipeline:
app.UseExceptionHandler("/error");
app.UseHsts();
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiting();
app.MapControllers();
Exception handling is first because it needs to catch exceptions from everything downstream. HTTPS redirection is early because there is no point processing an insecure request through the rest of the pipeline. Authentication comes before authorization because you need to know who the user is before you can check what they are allowed to do.
Swap UseAuthentication and UseAuthorization, and every authorized endpoint becomes accessible to unauthenticated users. Put UseStaticFiles after UseAuthorization, and your CSS files require a login. These are not hypothetical mistakes. They happen in production codebases.
Writing Custom Middleware
Custom middleware is how you enforce cross-cutting concerns without polluting your controllers. Request logging, correlation IDs, tenant resolution in multi-tenant applications — these belong in middleware.
public class CorrelationIdMiddleware
{
private readonly RequestDelegate _next;
public CorrelationIdMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
var correlationId = context.Request.Headers["X-Correlation-Id"].FirstOrDefault()
?? Guid.NewGuid().ToString();
context.Items["CorrelationId"] = correlationId;
context.Response.Headers["X-Correlation-Id"] = correlationId;
using (_logger.BeginScope(new Dictionary<string, object>
{
["CorrelationId"] = correlationId
}))
{
await _next(context);
}
}
}
Every request gets a correlation ID. Every log entry within that request includes it. When a user reports a problem, you search logs by correlation ID and see the entire request lifecycle. This is the kind of infrastructure that separates applications you can debug from applications you cannot.
Patterns That Survive Contact with Production
Tutorials teach you patterns in isolation. Production teaches you which patterns compose well and which collapse under real requirements.
The Options Pattern
Hardcoded configuration values are a deployment problem waiting to happen. The Options pattern binds configuration sections to strongly-typed classes, validated at startup.
public class EmailOptions
{
public const string Section = "Email";
[Required]
public string SmtpServer { get; set; } = string.Empty;
[Range(1, 65535)]
public int Port { get; set; }
[Required]
public string FromAddress { get; set; } = string.Empty;
}
// Registration
builder.Services.AddOptions<EmailOptions>()
.BindConfiguration(EmailOptions.Section)
.ValidateDataAnnotations()
.ValidateOnStart();
ValidateOnStart is the critical line. Without it, invalid configuration is discovered when the service is first used — possibly in production, possibly at 2 AM, possibly in a code path that only executes on the third Tuesday of the month. With it, the application refuses to start with invalid configuration. Fail fast is always cheaper than fail later.
The Result Pattern
Throwing exceptions for expected failures — user not found, validation failed, duplicate email — is expensive and makes control flow harder to follow. The Result pattern makes success and failure explicit in the return type.
public class Result<T>
{
public bool IsSuccess { get; }
public T? Value { get; }
public string? Error { get; }
private Result(T value) { IsSuccess = true; Value = value; }
private Result(string error) { IsSuccess = false; Error = error; }
public static Result<T> Success(T value) => new(value);
public static Result<T> Failure(string error) => new(error);
}
A service method returns Result<User> instead of User. The caller checks IsSuccess and handles both cases explicitly. No try/catch blocks for business logic. No ambiguity about whether a method might throw. No exceptions for non-exceptional situations.
This is not about avoiding exceptions entirely. Network failures, null references, out-of-memory conditions — those are exceptional and should throw. But "the user entered an invalid email" is not exceptional. It is a completely expected outcome that your code should handle as a normal control flow path.
The Mediator Pattern
As applications grow, controllers that directly call services start to accumulate dependencies. A controller that handles user creation might need a user service, an email service, a notification service, and an audit service. The constructor becomes a dependency list.
The mediator pattern decouples the request from its handling:
public record CreateUserCommand(string Email, string Name) : IRequest<Result<User>>;
public class CreateUserHandler : IRequestHandler<CreateUserCommand, Result<User>>
{
private readonly IUserRepository _users;
private readonly IEmailService _email;
public CreateUserHandler(IUserRepository users, IEmailService email)
{
_users = users;
_email = email;
}
public async Task<Result<User>> Handle(CreateUserCommand command, CancellationToken ct)
{
// validation, creation, notification — all in one place
}
}
The controller sends a command. The handler processes it. The controller does not know or care what the handler depends on. Adding a new step to user creation means modifying the handler, not the controller. The controller stays thin — it translates HTTP into commands and commands into HTTP responses.
This is not the right pattern for every application. A CRUD app with five endpoints does not need a mediator. But when your controllers start accumulating six or seven injected services, the mediator pattern is how you keep the codebase navigable.
The Takeaway
Production .NET is not harder than tutorial .NET. It is different. The patterns that feel like over-engineering at small scale — explicit lifetimes, organized middleware, the Options and Result patterns — are the patterns that keep large applications debuggable, testable, and maintainable.
The common thread: make things explicit. Explicit lifetimes instead of hoping the default works. Explicit pipeline ordering instead of trusting it will be fine. Explicit success and failure instead of exceptions for expected outcomes. Explicit configuration validation instead of runtime surprises.
Tutorial .NET teaches you to make it work. Production .NET teaches you to make it work reliably, across a team, over years, under conditions you did not anticipate. The transition is not about learning more APIs. It is about understanding why the framework gives you these tools and using them with intention.
Next in the ".NET Beyond Basics" learning path: We'll dig into Entity Framework Core patterns for production — repository vs. direct DbContext, query optimization, migration strategies, and the patterns that keep your data access layer from becoming the bottleneck.



Comments