.NET Done Right

Practical .NET practices for dependency injection, async code, resource management, configuration, and structured logging.

.NET Done Right

Every .NET project starts clean. Then the deadline arrives, someone says “I’ll clean it up later,” and six months later you have a static Helper that does everything, an async void that swallows exceptions, and a catch block that exists only to pretend it handles errors. None of this is .NET’s fault. The platform already gives you almost everything you need to do things right. The problem is ignoring what comes out of the box.

Cover image contrasting well-structured .NET code with a messy workaround
Cover image contrasting well-structured .NET code with a messy workaround

Dependency injection: the built-in option is enough

You don’t need Autofac, Ninject, or a homegrown ServiceLocator. Microsoft.Extensions.DependencyInjection covers 95% of use cases. Register services in the right place and let the container resolve them.

What trips people up is service lifetime. A practical rule: use Singleton for stateless objects that are expensive to create, such as an HttpClient factory or a cache; Scoped for anything that follows the request, such as a DbContext; and Transient for everything else. The classic mistake is injecting a Scoped service into a Singleton—the container complains, and rightly so: you’ve tied a request-scoped object to the entire lifetime of the application.

Programming against an interface here isn’t purism; it’s what makes testing possible without relying on magical mocks.

async/await for real: CancellationToken and no more async void

async void is a trap. An exception thrown there doesn’t propagate to the caller—it can bring down the process or disappear. There is only one legitimate use: event handlers. Outside of that, use async Task, always.

And propagate the CancellationToken. It isn’t decoration in a method signature: it’s what allows your API to stop processing when the client has already given up. Receive it in the controller, pass it to the service, then to HttpClient and EF Core. A token that dies in the first method is a useless token.

csharp public async Task<Pedido> BuscarAsync(int id, CancellationToken ct) { var pedido = await _db.Pedidos.FindAsync([id], ct); return pedido ?? throw new NotFoundException(id); }

Also: don’t use .Result or .Wait(). That’s a deadlock waiting to happen.

IDisposable and using: release what you hold

Connections, streams, file handles—anything that implements IDisposable needs to be released. And the correct way isn’t to write try/finally by hand; it’s to use using. A using declaration, without braces, keeps the code flat and releases the resource at the end of the scope:

csharp using var stream = File.OpenRead(caminho); var hash = await SHA256.HashDataAsync(stream, ct);

If your class holds a disposable resource, it also becomes IDisposable—and should forward Dispose down the chain. Holding a resource without releasing it is a silent leak that only appears in production, under load, at the worst possible time.

Records and immutability: fewer surprises, fewer bugs

DTOs, value objects, events, and messages: all of these represent data that shouldn’t change after it’s created. A record gives you that out of the box—value-based equality, a useful ToString, and with expressions for copying while changing a field.

csharp public record Cliente(string Nome, string Email);

An immutable object won’t be modified by accident in another thread or in another method three layers down. Less mutable state moving through the system means fewer “but I didn’t change that” bugs. Keep mutable class types for cases where you genuinely need identity and a lifecycle—not for every bag of data.

Configuration with IOptions, not magic strings

Scattering configuration["ChaveDaApi"] throughout your code is guaranteed technical debt: no type safety, no validation, and you only discover that the key is missing when something fails at runtime. Model the configuration with a class and inject IOptions<T>.

csharp builder.Services.AddOptions<EmailOptions>() .Bind(builder.Configuration.GetSection("Email")) .ValidateDataAnnotations() .ValidateOnStart();

ValidateOnStart is the key detail: invalid configuration brings the application down during startup, not in the middle of an email send at 2 a.m. If the configuration changes at runtime, use IOptionsSnapshot. But start with the simple option.

Structured logging: stop concatenating strings

_logger.LogInformation("Pedido " + id + " criado") produces a log you can’t query effectively. Use placeholders instead—they become indexed fields in your observability backend:

csharp _logger.LogInformation("Pedido {PedidoId} criado para {ClienteId}", pedido.Id, cliente.Id);

Now you can filter by PedidoId in Seq, Grafana, or whatever tool you use. Combine this with ILogger<T>—which already provides the right category—and scopes for correlating requests, and logs stop being textual clutter and become an investigation tool.

Good .NET practices are rarely about the newest thing on NuGet. They’re almost always about using what the platform already provides, the way it was designed to be used. The framework won’t stop you from writing a hack—but it doesn’t force you to, either. As always, the choice is yours.

Did you enjoy this article?

Share it with your friends and help spread knowledge!