Think your C# is clean? I thought so too-until a code review took me down in three minutes. Here are the tiny fixes and patterns I now use every day so you don’t learn the hard way.
If you write C# or ship ASP.NET Core services, you can shave minutes off tasks, cut allocations, and avoid classic bugs with a few small habits. Below are 50 practical hacks I’ve collected from real projects, broken into themed packs with short samples you can copy.
1) Language & Syntax Wins
1. Prefer using declarations over blocks
Keeps scopes tight and nesting flat.
using var stream = File.OpenRead(path);
using var reader = new StreamReader(stream);
var line = await reader.ReadLineAsync();2. File‑scoped namespaces
Less indent, same meaning (C# 10+).
namespace MyApp.Models;
public sealed record UserId(Guid Value);3. Target‑typed new
Cut noise when the type is clear.
Dictionary<string, int> scores = new();4. with on records for safe copies
Immutable updates read better.
public record User(string Name, int Age);
var older = user with { Age = user.Age + 1 };5. Switch expressions beat long if chains
string ToEmoji(LogLevel level) => level switch
{
LogLevel.Trace => "·",
LogLevel.Debug => "•",
LogLevel.Information => "i",
LogLevel.Warning => "!",
LogLevel.Error => "✖",
_ => "?"
};6. Pattern matching: is not null
if (obj is not null)
{
// safe to use
}7. Primary constructors (C# 12)
Great for tiny types.
public class Clock(ILogger<Clock> log)
{
public DateTimeOffset Now() => DateTimeOffset.UtcNow;
}8. Required members (C# 11)
public sealed class AppOptions
{
public required string ApiBaseUrl { get; init; }
public int TimeoutSeconds { get; init; } = 30;
}9. Collection expressions (C# 12)
int[] odds = [1, 3, 5, 7];10. Use readonly structs for value objects
Avoid surprises from hidden copies.
public readonly record struct Money(decimal Amount, string Currency);From my project: switching to file‑scoped namespaces and target‑typed
newdropped about 200 lines across a small library. Reviews got easier.
2) Guard Clauses & Fail Fast
11. Small guard helpers
public static class Guard
{
public static T NotNull<T>(T? value, string name) where T : class
=> value ?? throw new ArgumentNullException(name);
}
var client = Guard.NotNull(httpClient, nameof(httpClient));12. Validate early in constructors
public sealed class EmailService(string apiKey)
{
private readonly string _apiKey = !string.IsNullOrWhiteSpace(apiKey)
? apiKey : throw new ArgumentException("apiKey required");
}13. Prefer TryParse to exceptions
if (!Guid.TryParse(idText, out var id)) return BadRequest("Invalid id");14. Use exception filters to log once
try
{
await work();
}
catch (Exception ex) when (Log(ex))
{
// filter always returns false, so this never runs
}
static bool Log(Exception ex) { /* log */ return false; }15. Return ProblemDetails in APIs
app.MapGet("/users/{id}", async (string id, IUserRepo repo) =>
{
if (!Guid.TryParse(id, out var guid))
return Results.Problem("Invalid id", statusCode: 400);
var user = await repo.Find(guid);
return user is null ? Results.NotFound() : Results.Ok(user);
});3) Async & Concurrency
16. Never async void in library code
Use Task so callers can await and handle errors.
17. Respect CancellationToken
public async Task<User?> Find(Guid id, CancellationToken ct)
=> await _db.Users.FindAsync([id], ct);18. ConfigureAwait(false) in libraries
await stream.WriteAsync(buffer, ct).ConfigureAwait(false);19. Use IAsyncEnumerable<T> for streams
await foreach (var line in ReadLines(path, ct))
{
// process
}
static async IAsyncEnumerable<string> ReadLines(string p, [EnumeratorCancellation] CancellationToken ct)
{
using var r = new StreamReader(File.OpenRead(p));
while (!r.EndOfStream)
yield return (await r.ReadLineAsync(ct))!;
}20. Limit parallel work with SemaphoreSlim
var gate = new SemaphoreSlim(4);
await Parallel.ForEachAsync(items, async (x, ct) =>
{
await gate.WaitAsync(ct);
try { await Process(x, ct); }
finally { gate.Release(); }
});21. Use Channel<T> for producer/consumer
var channel = Channel.CreateUnbounded<string>();
_ = Task.Run(async () =>
{
await foreach (var m in channel.Reader.ReadAllAsync())
Console.WriteLine(m);
});
await channel.Writer.WriteAsync("Hello");22. PeriodicTimer for steady jobs
var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
while (await timer.WaitForNextTickAsync(ct))
{
await DoWork(ct);
}23. Prefer ValueTask for hot paths
Only when a result is often cached/synchronous.
public ValueTask<User?> GetCached(Guid id)
=> _cache.TryGetValue(id, out var u)
? new(u) : new(_repo.Find(id));24. Task.WhenAll for true parallelism
var tasks = urls.Select(DownloadAsync);
var pages = await Task.WhenAll(tasks);Lesson learned: my old “fire‑and‑forget” method swallowed exceptions and took down a worker later. Moving to
Channel<T>plus one consumer fixed backpressure and made errors visible.
4) LINQ & Collections
25. Avoid double enumeration
var list = source.ToList();
if (list.Count == 0) return;
// use list multiple times safely26. Use Any() not Count() > 0
if (users.Any()) { /* ... */ }27. Prefer TryGetValue on dictionaries
if (map.TryGetValue(key, out var value))
{
// use value
}28. Chunk for batching (NET 6+)
foreach (var batch in items.Chunk(100))
{
await SaveBatch(batch);
}29. DistinctBy and MaxBy (NET 6+)
var latestPerUser = events
.GroupBy(e => e.UserId)
.Select(g => g.MaxBy(e => e.Timestamp));30. Prefer ImmutableArray<T> for read‑only
ImmutableArray<string> roles = users
.Select(u => u.Role).ToImmutableArray();31. Use Span<T>/Memory<T> for parsing
ReadOnlySpan<char> s = "2025-11-04";
if (DateOnly.TryParse(s, out var date)) { /* fast */ }5) I/O, HTTP & JSON
32. Always reuse HttpClient with factory
builder.Services.AddHttpClient("github", c =>
{
c.BaseAddress = new Uri("https://api.github.com/");
c.DefaultRequestHeaders.UserAgent.ParseAdd("my-app");
});
app.MapGet("/stars", async (IHttpClientFactory f) =>
{
var client = f.CreateClient("github");
return await client.GetStringAsync("repos/dotnet/runtime");
});33. Use JsonSerializerContext for AOT speed
[JsonSerializable(typeof(User))]
public partial class AppJsonContext : JsonSerializerContext { }
var json = JsonSerializer.Serialize(user, AppJsonContext.Default.User);34. Stream JSON for big payloads
await using var stream = response.Content.ReadAsStream();
var result = await JsonSerializer.DeserializeAsync<Result>(stream, ct: ct);35. Prefer DateTimeOffset over DateTime for APIs
public record Post(string Title, DateTimeOffset PublishedAt);36. Use Path.Combine and Path.GetTempPath()
var path = Path.Combine(Path.GetTempPath(), "report.csv");6) EF Core & Data Access
37. Keep DbContext scoped
builder.Services.AddDbContext<AppDb>(opt =>
opt.UseNpgsql(connString));38. Project columns, not entities
var dto = await db.Users
.Where(u => u.Id == id)
.Select(u => new UserDto(u.Id, u.Name))
.SingleOrDefaultAsync(ct);39. Use AsNoTracking() for read‑only
var posts = await db.Posts.AsNoTracking()
.Where(p => p.Published)
.ToListAsync(ct);40. Cancellation in queries
await db.SaveChangesAsync(ct);Why it helped: switching a read‑heavy endpoint to
AsNoTracking()cut CPU by ~20% and reduced GC pressure.
7) ASP.NET Core, DI & Middleware
41. Minimal APIs for simple endpoints
var app = WebApplication.CreateBuilder(args).Build();
app.MapGet("/health", () => Results.Ok(new { ok = true }));
app.Run();42. Typed options with validation
builder.Services.AddOptions<AppOptions>()
.BindConfiguration("App")
.ValidateDataAnnotations()
.Validate(o => Uri.IsWellFormedUriString(o.ApiBaseUrl, UriKind.Absolute),
"ApiBaseUrl must be absolute").ValidateOnStart();43. Keyed services for multi‑client setups (NET 8+)
builder.Services.AddKeyedSingleton<IStorage>("s3", new S3Storage());44. Health checks and readiness
builder.Services.AddHealthChecks().AddDbContextCheck<AppDb>();
app.MapHealthChecks("/healthz");45. Structured logging with scopes
using (_log.BeginScope(new { OrderId = id }))
{
_log.LogInformation("Processing order");
}46. Rate limit middleware (NET 7+)
builder.Services.AddRateLimiter(o =>
{
o.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(ctx =>
RateLimitPartition.GetFixedWindowLimiter("global", _ =>
new FixedWindowRateLimiterOptions
{
PermitLimit = 100,
Window = TimeSpan.FromSeconds(1)
}));
});
app.UseRateLimiter();8) Testing, Tooling & Quality
47. Enable nullable and analyzers
<!-- Directory.Build.props -->
<Project>
<PropertyGroup>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AnalysisLevel>latest</AnalysisLevel>
</PropertyGroup>
</Project>48. Golden master for risky refactors
// serialize output before change, compare after change
var before = JsonSerializer.Serialize(await RunOld());
var after = JsonSerializer.Serialize(await RunNew());
Assert.Equal(before, after);49. dotnet format in CI
- name: Format
run: dotnet tool restore && dotnet format --verify-no-changes50. Benchmark with BenchmarkDotNet
[MemoryDiagnoser]
public class ParseBench
{
[Benchmark] public int ParseInt() => int.Parse("123");
[Benchmark] public bool TryParseInt() => int.TryParse("123", out _);
}Common Pitfalls I Still See
- Random usage: newing
Random()per call causes repeats. Use a single staticRandomorRandomNumberGeneratorfor crypto. - Local time in logs: use UTC everywhere. Convert only for UI.
- Timing with
DateTime.Now: useStopwatchfor durations. - Blind catches:
catch (Exception)without logging hides real issues. - DTO drift: returning EF entities from APIs couples schema to clients; project to DTOs.
FAQ: Quick Answers Before You Ask
Most language tips work back to C# 10. For C# 12 features (primary constructors, collection expressions), keep them to new modules or guard with analyzers.
ConfigureAwait(false) in ASP.NET Core?In your app code, the sync context isn’t captured, so it’s optional. In libraries used by many hosts, keep it.
ValueTask always be faster?No. Use it only when the result is often synchronous or cached. Otherwise Task is simpler and just fine.
HttpClientFactory really needed?Yes if you call HTTP often. It fixes socket exhaustion and gives handlers/policies per client.
Add analyzers + Directory.Build.props, write a short style doc, and flip rules one by one. Pull requests should show the change and the reason.
Conclusion: 50 small wins add up fast
You don’t need a rewrite to raise quality. Pick five hacks from above and ship them this week: guard clauses, AsNoTracking, HttpClientFactory, Any() over Count(), and nullable + analyzers. Your diffs will shrink, bugs will drop, and on‑call will be calmer.
What would you add as hack #51? Drop your tip in the comments – I’ll test it on a real service and report back.
