Skip to Content

.NET Cheat Sheet

.NET CLI, C# language patterns, and Azure SDK integration. Targets .NET 10 and C# 14.

Versions: .NET 10 · C# 14 · Azure.Identity 1.13+ · xUnit 2.x · Moq 4.x


.NET CLI

Project management

Bash
# Install / update SDK
# Download from https://dot.net or:
winget install Microsoft.DotNet.SDK.10
brew install dotnet
 
dotnet --version
dotnet --list-sdks
dotnet --list-runtimes
 
# New projects
dotnet new console   -n MyApp    -o ./src/MyApp
dotnet new classlib  -n MyLib    -o ./src/MyLib
dotnet new webapi    -n MyApi    -o ./src/MyApi    --use-controllers
dotnet new worker    -n MyWorker -o ./src/MyWorker
dotnet new xunit     -n MyApp.Tests -o ./tests/MyApp.Tests
dotnet new sln       -n MySolution
 
# Add projects to solution
dotnet sln add ./src/MyApp/MyApp.csproj
dotnet sln add ./src/MyLib/MyLib.csproj
dotnet sln add ./tests/MyApp.Tests/MyApp.Tests.csproj
 
# Add project reference
dotnet add ./src/MyApp/MyApp.csproj reference ./src/MyLib/MyLib.csproj

Build, run, test

Bash
# Restore, build, run
dotnet restore
dotnet build   --configuration Release
dotnet run     --project ./src/MyApp
dotnet run     --project ./src/MyApp -- --arg1 value1
 
# Publish (self-contained single binary)
dotnet publish ./src/MyApp \
  --configuration Release \
  --runtime       linux-x64 \
  --self-contained true \
  -p:PublishSingleFile=true \
  -p:EnableCompressionInSingleFile=true \
  --output ./publish
 
# Tests
dotnet test
dotnet test --configuration Release --logger "console;verbosity=normal"
dotnet test --filter "Category=Unit"
dotnet test --collect "Code Coverage"  # or coverlet
 
# Watch (auto-rebuild on save)
dotnet watch run   --project ./src/MyApp
dotnet watch test  --project ./tests/MyApp.Tests

NuGet package management

Bash
# Add packages
dotnet add package Azure.Identity
dotnet add package Azure.Security.KeyVault.Secrets
dotnet add package Azure.Monitor.Query
dotnet add package Microsoft.Extensions.DependencyInjection
dotnet add package Microsoft.Extensions.Hosting
dotnet add package Microsoft.Extensions.Http
dotnet add package Serilog.AspNetCore
 
# Remove package
dotnet remove package SomePackage
 
# List outdated packages
dotnet list package --outdated
 
# Update all (requires dotnet-outdated tool)
dotnet tool install -g dotnet-outdated-tool
dotnet-outdated --upgrade
 
# Global tools
dotnet tool install -g dotnet-ef
dotnet tool install -g dotnet-format
dotnet tool list   -g
dotnet tool update -g dotnet-ef

Project Structure

.csproj - key properties

XML
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
    <WarningsAsErrors />
    <AllowUnsafeBlocks>false</AllowUnsafeBlocks>
    <Optimize>true</Optimize>
    <RootNamespace>MyCompany.MyApp</RootNamespace>
  </PropertyGroup>
 
  <ItemGroup>
    <PackageReference Include="Azure.Identity"              Version="1.13.*" />
    <PackageReference Include="Azure.Security.KeyVault.Secrets" Version="4.7.*" />
    <PackageReference Include="Azure.Monitor.Query"         Version="1.4.*" />
  </ItemGroup>
</Project>

Interfaces & Classes

Interface patterns

CSHARP
// Basic interface
public interface IUserRepository
{
    Task<User?> GetByIdAsync(string id, CancellationToken ct = default);
    Task<IReadOnlyList<User>> GetAllAsync(CancellationToken ct = default);
    Task<User> CreateAsync(CreateUserRequest request, CancellationToken ct = default);
    Task UpdateAsync(string id, UpdateUserRequest request, CancellationToken ct = default);
    Task DeleteAsync(string id, CancellationToken ct = default);
}
 
// Interface with default implementation (C# 8+)
public interface ILogger
{
    void Log(string message, LogLevel level = LogLevel.Info);
    void LogError(string message) => Log(message, LogLevel.Error);
    void LogWarning(string message) => Log(message, LogLevel.Warning);
}
 
// Generic interface
public interface IRepository<T, TId> where T : class
{
    Task<T?> FindAsync(TId id, CancellationToken ct = default);
    Task<T>  SaveAsync(T entity, CancellationToken ct = default);
}
 
// Interface segregation
public interface IReadableStore<T>  { Task<T?> GetAsync(string key); }
public interface IWritableStore<T>  { Task SetAsync(string key, T value); }
public interface IStore<T>          : IReadableStore<T>, IWritableStore<T> { }

Class patterns

CSHARP
// Sealed class with primary constructor
public sealed class UserService(
    IUserRepository repo,
    ILogger<UserService> logger,
    TimeProvider time)
{
    public async Task<User?> GetUserAsync(string id, CancellationToken ct = default)
    {
        logger.LogInformation("Fetching user {Id}", id);
        var user = await repo.GetByIdAsync(id, ct);
        if (user is null) logger.LogWarning("User {Id} not found", id);
        return user;
    }
 
    public async Task<User> CreateUserAsync(CreateUserRequest request, CancellationToken ct)
    {
        ArgumentNullException.ThrowIfNull(request);
        ArgumentException.ThrowIfNullOrWhiteSpace(request.Name);
 
        // The repository maps the request to an entity and returns the persisted User.
        return await repo.CreateAsync(request, ct);
    }
}
 
// Abstract base class
public abstract class BaseHandler<TRequest, TResponse>
{
    protected abstract Task<TResponse> HandleCoreAsync(TRequest request, CancellationToken ct);
 
    public async Task<TResponse> HandleAsync(TRequest request, CancellationToken ct)
    {
        ArgumentNullException.ThrowIfNull(request);
        return await HandleCoreAsync(request, ct);
    }
}

Interface vs abstract class vs static function - choosing one

These three constructs overlap, and picking the wrong one is one of the more common sources of friction in a C# codebase. The decision tree:

QuestionIf yes, use
Does this need to be swapped at runtime (fakes in tests, multiple implementations resolved via DI)?Interface
Are there multiple unrelated types that share only a contract, not implementation?Interface
Is there a partial implementation that all subclasses will share, plus a few hooks they override?Abstract class
Does the type need fields, constructors, or protected state?Abstract class (or concrete base)
Is the operation stateless and deterministic (pure transform, helper, formatter)?Static function
Is this method called from one place and unlikely to grow another implementation?Static function or instance method

Three concrete examples of each choice being the right one:

CSHARP
// 1. INTERFACE - external dependency, must be swappable for unit tests
public interface IClock { DateTimeOffset UtcNow { get; } }
 
public sealed class SystemClock : IClock { public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; }
public sealed class FakeClock(DateTimeOffset now) : IClock { public DateTimeOffset UtcNow => now; }
 
// Registered once in DI - tests pass FakeClock, prod gets SystemClock.
// Note: .NET 8+ ships TimeProvider for this exact use case - prefer it over a hand-rolled IClock.
 
// 2. ABSTRACT CLASS - shared template with one variable step (Template Method)
public abstract class MessageHandler<TMessage>
{
    private readonly ILogger _logger;
    protected MessageHandler(ILogger logger) => _logger = logger;
 
    public async Task HandleAsync(TMessage message, CancellationToken ct)
    {
        _logger.LogInformation("Handling {Type}", typeof(TMessage).Name);
        var sw = Stopwatch.StartNew();
        try
        {
            await HandleCoreAsync(message, ct);
            _logger.LogInformation("Handled in {Elapsed}ms", sw.ElapsedMilliseconds);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Handler failed after {Elapsed}ms", sw.ElapsedMilliseconds);
            throw;
        }
    }
 
    // The only variable step - subclasses fill this in.
    protected abstract Task HandleCoreAsync(TMessage message, CancellationToken ct);
}
 
// 3. STATIC FUNCTION - pure transform, no state, no I/O
public static class Slug
{
    // NOTE: .ToString() on IEnumerable<char> returns the iterator's type name, not
    // the characters - rebuild the string with new string(chars.ToArray()).
    public static string From(string input) =>
        string.Join('-',
            new string(input.ToLowerInvariant()
                            .Where(c => char.IsLetterOrDigit(c) || c == ' ')
                            .ToArray())
                .Split(' ', StringSplitOptions.RemoveEmptyEntries));
}
// Used as: Slug.From("Hello World!") => "hello-world"

Why prefer an interface over an abstract class by default?

  • Single inheritance limit: a class can implement many interfaces but inherit only one base. Forcing consumers into your inheritance chain is invasive.
  • Mockability: mocking libraries (Moq, NSubstitute) generate proxies cleanly from interfaces; abstract classes work but require all methods to be virtual and sealed members can’t be overridden.
  • No accidental state: interfaces can’t hold fields, which keeps the contract honest. Default interface methods (C# 8+) cover the “just one shared helper” case without dragging in inheritance.

Use an abstract class when you genuinely have shared implementation (fields, constructor logic, base behaviour that every subclass uses). Template Method is the textbook case - everything else is usually better as an interface plus a concrete helper or extension methods.

Why prefer a static function over an interface?

  • No DI overhead, no allocation, no mock plumbing in tests - calls a static method directly.
  • Forces purity: if the operation needs a database, clock, or HTTP client, you’ll feel the friction of static-ness and naturally promote it to an injected service.
  • Discoverability: Slug.From(...) is one IDE jump; ISlugGenerator requires a registration, an interface, and an implementation to read three files.

A good heuristic: if the implementation only ever depends on its arguments, make it static. The moment it needs an injected dependency (clock, logger, HTTP client, DB), promote it to an instance class behind an interface.

Don’t create interfaces “just in case”

IFoo with a single Foo implementation, no tests substituting it, and no DI-driven swapping, is dead weight. Add the interface when you have a second implementation (a fake, a stub, a real alternative) - not before. Until then, inject the concrete type directly; ASP.NET Core’s DI container resolves concrete types just fine.


Records

CSHARP
// Positional record (immutable, value equality)
public record User(string Id, string Name, string Email, DateTime CreatedAt);
 
// Record with additional members
public record UserProfile(string Id, string Name, string Email) : IEquatable<UserProfile>
{
    public string DisplayName => $"{Name} <{Email}>";
 
    // Non-destructive mutation
    public UserProfile WithName(string name) => this with { Name = name };
}
 
// Record struct (stack-allocated, value type - C# 10+)
public readonly record struct Point(double X, double Y)
{
    public double DistanceTo(Point other) =>
        Math.Sqrt(Math.Pow(X - other.X, 2) + Math.Pow(Y - other.Y, 2));
}
 
// Class records are reference types with value equality
var u1 = new User("1", "Alice", "alice@example.com", DateTime.UtcNow);
var u2 = u1 with { Name = "Bob" };   // new record, different Name
Console.WriteLine(u1 == u2);         // false
Console.WriteLine(u1 with { } == u1); // true - structural equality

Pattern Matching

CSHARP
// Switch expression
string Describe(object obj) => obj switch
{
    int n when n < 0          => "negative integer",
    int n                     => $"positive integer: {n}",
    string { Length: 0 }      => "empty string",
    string s                  => $"string: {s}",
    null                      => "null",
    _                         => "something else"
};
 
// Property pattern
decimal GetDiscount(User user) => user switch
{
    { Role: "admin" }                       => 0.5m,
    { Role: "premium", CreatedAt: var d }
        when (DateTime.UtcNow - d).TotalDays > 365 => 0.2m,
    _                                       => 0m
};
 
// List pattern
string Describe(int[] arr) => arr switch
{
    []          => "empty",
    [var x]     => $"single: {x}",
    [var x, var y] => $"pair: {x}, {y}",
    [var first, .., var last] => $"starts {first}, ends {last}",
    _           => "many elements"
};
 
// Deconstruct in patterns
if (GetUser() is { Name: var name, Email: var email })
    Console.WriteLine($"{name} - {email}");

LINQ

CSHARP
var users = new List<User> { /* ... */ };
 
// Query syntax
var admins =
    from u in users
    where u.Role == "admin"
    orderby u.Name
    select new { u.Id, u.Name };
 
// Method syntax (preferred)
var top10 = users
    .Where(u => u.IsActive)
    .OrderByDescending(u => u.CreatedAt)
    .Take(10)
    .Select(u => new UserSummary(u.Id, u.Name));
 
// Grouping
var byRole = users
    .GroupBy(u => u.Role)
    .ToDictionary(g => g.Key, g => g.ToList());
 
// Aggregations
var stats = new
{
    Total   = users.Count,
    Active  = users.Count(u => u.IsActive),
    Oldest  = users.Min(u => u.CreatedAt),
    Newest  = users.Max(u => u.CreatedAt),
};
 
// Async LINQ (EF Core)
var activeUsers = await dbContext.Users
    .Where(u => u.IsActive && u.Role == "admin")
    .OrderBy(u => u.Name)
    .Select(u => new UserDto(u.Id, u.Name, u.Email))
    .AsNoTracking()
    .ToListAsync(cancellationToken);
 
// Chunk (batching)
foreach (var batch in users.Chunk(100))
{
    await ProcessBatchAsync(batch);
}

Async & Error Handling

CSHARP
// Async best practices
public async Task<Result<User>> GetUserSafeAsync(string id, CancellationToken ct)
{
    try
    {
        // ConfigureAwait(false) in library code (not in ASP.NET Core - sync context absent)
        var user = await repo.GetByIdAsync(id, ct).ConfigureAwait(false);
        return user is not null
            ? Result<User>.Success(user)
            : Result<User>.Failure("User not found");
    }
    catch (OperationCanceledException)
    {
        throw;   // never swallow cancellation
    }
    catch (Exception ex)
    {
        logger.LogError(ex, "Failed to get user {Id}", id);
        return Result<User>.Failure(ex.Message);
    }
}
 
// Parallel async with bounded concurrency
var semaphore = new SemaphoreSlim(10);   // max 10 concurrent
var tasks = ids.Select(async id =>
{
    await semaphore.WaitAsync(ct);
    try   { return await GetUserSafeAsync(id, ct); }
    finally { semaphore.Release(); }
});
var results = await Task.WhenAll(tasks);
 
// Task.WhenEach (.NET 9+) - process results as each task completes
await foreach (var completed in Task.WhenEach(tasks))
{
    var result = await completed;
    Console.WriteLine(result.IsSuccess ? result.Value!.Name : result.Error);
}
 
// ValueTask - avoid heap allocation for hot paths that usually complete synchronously
public ValueTask<int> GetCountAsync()
{
    if (_cache.TryGetValue("count", out var cached)) return new ValueTask<int>(cached);
    return new ValueTask<int>(FetchCountFromDbAsync());
}
 
// IAsyncEnumerable - streaming
public async IAsyncEnumerable<User> StreamUsersAsync(
    [EnumeratorCancellation] CancellationToken ct = default)
{
    await foreach (var user in dbContext.Users.AsAsyncEnumerable().WithCancellation(ct))
        yield return user;
}
 
// Consume
await foreach (var user in StreamUsersAsync(ct))
    Console.WriteLine(user.Name);

Result type pattern

CSHARP
public class Result<T>
{
    public bool    IsSuccess { get; }
    public T?      Value     { get; }
    public string? Error     { get; }
 
    private Result(bool ok, T? value, string? error)
        => (IsSuccess, Value, Error) = (ok, value, error);
 
    public static Result<T> Success(T value) => new(true,  value, null);
    public static Result<T> Failure(string error) => new(false, default, error);
 
    public Result<TOut> Map<TOut>(Func<T, TOut> fn) =>
        IsSuccess ? Result<TOut>.Success(fn(Value!)) : Result<TOut>.Failure(Error!);
 
    public async Task<Result<TOut>> MapAsync<TOut>(Func<T, Task<TOut>> fn) =>
        IsSuccess ? Result<TOut>.Success(await fn(Value!)) : Result<TOut>.Failure(Error!);
}

Dependency Injection

CSHARP
// Program.cs - .NET 10 minimal hosting
using Azure.Identity;
using Azure.Security.KeyVault.Secrets;
using Microsoft.Extensions.Azure;
 
var builder = WebApplication.CreateBuilder(args);
 
// Azure clients (thread-safe, pooled)
builder.Services.AddAzureClients(clients =>
{
    clients.AddSecretClient(new Uri($"https://{builder.Configuration["KeyVaultName"]}.vault.azure.net/"));
    clients.AddLogsQueryClient();
    clients.UseCredential(new DefaultAzureCredential());
});
 
// Services
builder.Services.AddSingleton<IUserRepository, CosmosUserRepository>();
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddTransient<IEmailSender, SmtpEmailSender>();
 
// HttpClient with resilience
builder.Services.AddHttpClient<IGitHubClient, GitHubClient>(c =>
{
    c.BaseAddress = new Uri("https://api.github.com/");
    c.DefaultRequestHeaders.Add("User-Agent", "MyApp/1.0");
})
.AddStandardResilienceHandler();   // retries, circuit breaker, timeouts (Polly)
 
// Options pattern
builder.Services.Configure<DatabaseOptions>(builder.Configuration.GetSection("Database"));
builder.Services.AddOptions<DatabaseOptions>()
    .BindConfiguration("Database")
    .ValidateDataAnnotations()
    .ValidateOnStart();
 
var app = builder.Build();
app.MapControllers();
app.Run();
CSHARP
// Strongly-typed options
public class DatabaseOptions
{
    public const string Section = "Database";
 
    [Required, Url]
    public string ConnectionString { get; init; } = "";
 
    [Range(1, 100)]
    public int MaxPoolSize { get; init; } = 10;
}
 
// Inject options
public class DatabaseService(IOptions<DatabaseOptions> opts)
{
    private readonly DatabaseOptions _opts = opts.Value;
}

Logging

🛠️ Deeper reference - this section covers JSON logging setup, log levels, structured logging, source-generated [LoggerMessage], scopes, and the decorator pattern with Scrutor. It’s a mini-guide, not a quick-reference snippet.

Microsoft.Extensions.Logging is the standard abstraction; back it with a real sink (Serilog, OpenTelemetry, or the built-in console). The default WebApplication.CreateBuilder already wires console + debug + event source providers - the rules below cover what to do with that.

Sensible defaults in Program.cs

CSHARP
using Microsoft.Extensions.Logging;
 
var builder = WebApplication.CreateBuilder(args);
 
// 1. Strip the noisy defaults, then add what you actually want.
builder.Logging.ClearProviders();
 
// 2. JSON to stdout - parseable by any log shipper (App Insights, Loki, Datadog).
//    Use SimpleConsole locally if you want human-readable colour output.
builder.Logging.AddJsonConsole(options =>
{
    options.IncludeScopes        = true;
    options.TimestampFormat      = "yyyy-MM-ddTHH:mm:ss.fffZ ";
    options.UseUtcTimestamp      = true;
    options.JsonWriterOptions    = new() { Indented = false };
});
 
// 3. OpenTelemetry / Application Insights pick this up automatically.
builder.Logging.AddOpenTelemetry(o =>
{
    o.IncludeScopes           = true;
    o.IncludeFormattedMessage = true;
    o.ParseStateValues        = true;
});
 
// 4. Filters - keep framework noise down, keep your code at Information.
builder.Logging.SetMinimumLevel(LogLevel.Information);
builder.Logging.AddFilter("Microsoft.AspNetCore", LogLevel.Warning);
builder.Logging.AddFilter("Microsoft.EntityFrameworkCore.Database.Command", LogLevel.Warning);
builder.Logging.AddFilter("Azure",                  LogLevel.Warning);
builder.Logging.AddFilter("MyApp",                  LogLevel.Information);

Matching appsettings.json (env-specific overrides in appsettings.Production.json):

JSON
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning",
      "Microsoft.EntityFrameworkCore.Database.Command": "Warning",
      "Azure": "Warning"
    }
  }
}

Log levels - when to use which

LevelUse forGoes to prod?
TracePer-iteration detail, byte-level dumpsNo - dev only
DebugLocal diagnostics, control-flow narrationNo - rare in prod
InformationBusiness events: request handled, message processed, job completedYes
WarningRecoverable issue, fallback used, retry succeededYes
ErrorAn operation failed; the caller will see a failureYes
CriticalThe process is unhealthy / about to terminateYes - alert on it

Rule of thumb: an on-call engineer reading Warning and above at 3am should learn something actionable. If a log line doesn’t help diagnose a problem, it’s Debug or it shouldn’t exist.

Structured logging - always use named placeholders

CSHARP
// CORRECT - structured: each property is queryable in your log backend
logger.LogInformation("Order {OrderId} processed for customer {CustomerId} in {Elapsed}ms",
    order.Id, order.CustomerId, sw.ElapsedMilliseconds);
 
// WRONG - string interpolation flattens everything into one Message string,
// destroying the structure and making it un-queryable.
logger.LogInformation($"Order {order.Id} processed for customer {order.CustomerId}");

Analyzer CA2254 will flag the interpolation form - enable it.

Source-generated logging - [LoggerMessage] (preferred for hot paths)

LoggerMessageAttribute (.NET 6+) generates a strongly-typed, zero-allocation log method at compile time. It avoids boxing of value types, validates the template at build time, and reads better at call sites.

CSHARP
public partial class OrderService(ILogger<OrderService> logger)
{
    [LoggerMessage(
        EventId = 1001,
        Level   = LogLevel.Information,
        Message = "Order {OrderId} processed for customer {CustomerId} in {Elapsed}ms")]
    private partial void LogOrderProcessed(string orderId, string customerId, long elapsed);
 
    [LoggerMessage(
        EventId = 1002,
        Level   = LogLevel.Warning,
        Message = "Order {OrderId} requeued (attempt {Attempt})")]
    private partial void LogOrderRequeued(string orderId, int attempt);
 
    public async Task ProcessAsync(Order order, CancellationToken ct)
    {
        var sw = Stopwatch.StartNew();
        // ...
        LogOrderProcessed(order.Id, order.CustomerId, sw.ElapsedMilliseconds);
    }
}

Use [LoggerMessage] for every recurring log line in production code. Reach for logger.LogInformation(...) only for one-off / startup messages.

Scopes - propagate context to every log line in a block

CSHARP
using (logger.BeginScope(new Dictionary<string, object>
{
    ["CorrelationId"] = correlationId,
    ["UserId"]        = user.Id,
}))
{
    logger.LogInformation("Starting checkout");
    await checkoutService.RunAsync(ct);
    logger.LogInformation("Checkout complete");
}
// Every log line emitted inside the using block carries CorrelationId + UserId.

ASP.NET Core already opens a scope per request containing RequestId and TraceId; you usually don’t need to add another at the top level - add scopes inside long-running operations or background jobs where the framework hasn’t.

Logging decorator pattern - cross-cutting timing/auditing

A logging decorator is a small class that implements the same interface as the thing it wraps, logs around each call, and delegates. It’s the cleanest way to add timing/auditing without sprinkling logger.Log… calls through business code, and is well-supported in .NET via DI.

CSHARP
public interface IOrderService
{
    Task<Order> PlaceAsync(PlaceOrderRequest req, CancellationToken ct);
}
 
public sealed class OrderService : IOrderService
{
    public Task<Order> PlaceAsync(PlaceOrderRequest req, CancellationToken ct) { /* real work */ }
}
 
// The decorator - same interface, wraps the inner implementation.
public sealed class LoggingOrderService(
    IOrderService inner,
    ILogger<LoggingOrderService> logger) : IOrderService
{
    public async Task<Order> PlaceAsync(PlaceOrderRequest req, CancellationToken ct)
    {
        using var _ = logger.BeginScope(new Dictionary<string, object>
        {
            ["CustomerId"] = req.CustomerId,
        });
 
        var sw = Stopwatch.StartNew();
        logger.LogInformation("PlaceOrder starting");
        try
        {
            var result = await inner.PlaceAsync(req, ct);
            logger.LogInformation("PlaceOrder succeeded in {Elapsed}ms (OrderId={OrderId})",
                sw.ElapsedMilliseconds, result.Id);
            return result;
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "PlaceOrder failed after {Elapsed}ms", sw.ElapsedMilliseconds);
            throw;
        }
    }
}

Wiring decorators in DI - Scrutor (clean) or manual (no extra package)

The built-in IServiceCollection has no first-class decorator API, but Scrutor (dotnet add package Scrutor) adds one and is the de-facto standard:

CSHARP
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.Decorate<IOrderService, LoggingOrderService>();
// Resolution order: consumers receive LoggingOrderService → wraps OrderService.
 
// Stack multiple decorators - innermost registered first.
builder.Services.Decorate<IOrderService, CachingOrderService>();   // outermost
builder.Services.Decorate<IOrderService, LoggingOrderService>();   // middle, wraps Caching
// Final chain: Logging → Caching → OrderService

Without Scrutor, register the inner type by its concrete class and resolve it from a factory:

CSHARP
builder.Services.AddScoped<OrderService>();
builder.Services.AddScoped<IOrderService>(sp =>
    new LoggingOrderService(
        inner:  sp.GetRequiredService<OrderService>(),
        logger: sp.GetRequiredService<ILogger<LoggingOrderService>>()));

When to use a logging decorator

  • Cross-cutting concerns on a stable interface boundary - one place to add timing, retries, audit, or metrics for every method on the interface.
  • Keeping business code free of logging plumbing - the inner OrderService doesn’t know about logging; it’s testable in isolation.
  • Pairs naturally with caching/retry decorators for chains like Logging → Retry → Caching → Real.

Avoid decorators when:

  • The interface has many methods and only one or two need logging - just inline [LoggerMessage] calls there.
  • The wrapped logic varies per method enough that the decorator becomes a switch statement - that’s a smell.
  • You want method-level parameter logging beyond a couple of fields - use a source generator or AOP (Castle DynamicProxy, MethodBoundaryAspect) instead.

Common pitfalls

  • Don’t log and rethrow at every layer. Log at the boundary that decides the user-facing failure (controller, message handler, top-level worker). Inner code throws; the boundary logs once with context.
  • Don’t log secrets. Tokens, connection strings, passwords, PII - even via structured properties, they’ll land in your log backend. Mask in the call site, not after.
  • Don’t await inside the log message arguments - the message is built only if the level is enabled, but the arguments are always evaluated. Compute first, log second.
  • Don’t use Console.WriteLine. It bypasses filters, providers, scopes, and structured properties.
  • Check IsEnabled for expensive payloads - if (logger.IsEnabled(LogLevel.Debug)) logger.LogDebug(...) avoids building a big string when Debug is off. [LoggerMessage] does this for you.

Azure SDK

Authentication

CSHARP
using Azure.Identity;
using Azure.Core;
 
// ✅ DefaultAzureCredential - tries: env vars → workload identity → managed identity → CLI → VS
// Best choice for code that must run in both CI and locally (as of 2026)
var credential = new DefaultAzureCredential();
 
// ✅ Managed identity (system-assigned) - preferred auth for Azure-hosted workloads (as of 2026)
var credential = new ManagedIdentityCredential();
 
// Managed identity (user-assigned)
var credential = new ManagedIdentityCredential(clientId: "YOUR-UAMI-CLIENT-ID");
 
// ⚠️ Service principal (client secret) - legacy, avoid in new projects; use federated credentials
var credential = new ClientSecretCredential(
    tenantId:     Environment.GetEnvironmentVariable("AZURE_TENANT_ID")!,
    clientId:     Environment.GetEnvironmentVariable("AZURE_CLIENT_ID")!,
    clientSecret: Environment.GetEnvironmentVariable("AZURE_CLIENT_SECRET")!);
 
// ✅ Workload identity (GitHub Actions / AKS pod) - current preferred CI/CD auth pattern (as of 2026)
var credential = new WorkloadIdentityCredential();
 
// Get token manually (for raw HTTP calls)
var token = await credential.GetTokenAsync(
    new TokenRequestContext(["https://management.azure.com/.default"]),
    cancellationToken);

Key Vault secrets

CSHARP
using Azure.Security.KeyVault.Secrets;
 
var kvUri    = new Uri($"https://{kvName}.vault.azure.net/");
var client   = new SecretClient(kvUri, new DefaultAzureCredential());
 
// Get secret
var secret   = await client.GetSecretAsync("my-secret");
string value = secret.Value.Value;
 
// Set secret
await client.SetSecretAsync("my-secret", "new-value");
 
// List secrets
await foreach (var props in client.GetPropertiesOfSecretsAsync())
    Console.WriteLine($"{props.Name}  expires: {props.ExpiresOn}");
 
// Delete (soft-delete - must purge to fully remove)
await client.StartDeleteSecretAsync("my-secret");
 
// Inject via IConfiguration (recommended for ASP.NET Core)
// builder.Configuration.AddAzureKeyVault(kvUri, new DefaultAzureCredential());
// Then: config["my-secret"]

Blob Storage

CSHARP
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
 
var serviceClient    = new BlobServiceClient(new Uri($"https://{account}.blob.core.windows.net/"), credential);
var containerClient  = serviceClient.GetBlobContainerClient("my-container");
await containerClient.CreateIfNotExistsAsync();
 
// Upload
var blobClient = containerClient.GetBlobClient("path/to/file.json");
await blobClient.UploadAsync(BinaryData.FromString(json), overwrite: true);
 
// Upload stream
await using var stream = File.OpenRead("large-file.bin");
await blobClient.UploadAsync(stream, new BlobUploadOptions
{
    TransferOptions = new StorageTransferOptions { MaximumConcurrency = 4 }
});
 
// Download
var download = await blobClient.DownloadContentAsync();
string content = download.Value.Content.ToString();
 
// List blobs with prefix
await foreach (var blob in containerClient.GetBlobsAsync(prefix: "data/2026/"))
    Console.WriteLine($"{blob.Name}  {blob.Properties.ContentLength} bytes");
 
// Generate SAS (requires StorageSharedKeyCredential - not managed identity)
var sasBuilder = new BlobSasBuilder
{
    BlobContainerName = "my-container",
    BlobName          = "path/to/file.json",
    Resource          = "b",
    ExpiresOn         = DateTimeOffset.UtcNow.AddHours(1),
};
sasBuilder.SetPermissions(BlobSasPermissions.Read);

Azure Resource Manager

CSHARP
using Azure.ResourceManager;
using Azure.ResourceManager.Resources;
using Azure.ResourceManager.Compute;
 
var armClient  = new ArmClient(new DefaultAzureCredential());
var subscription = await armClient.GetDefaultSubscriptionAsync();
 
// List resource groups
await foreach (var rg in subscription.GetResourceGroups().GetAllAsync())
    Console.WriteLine($"{rg.Data.Name}  {rg.Data.Location}");
 
// Get a specific VM
var vmId = new ResourceIdentifier(
    $"/subscriptions/{subId}/resourceGroups/{rgName}/providers/Microsoft.Compute/virtualMachines/{vmName}");
var vm = armClient.GetVirtualMachineResource(vmId);
var vmData = (await vm.GetAsync()).Value;
Console.WriteLine(vmData.Data.HardwareProfile?.VmSize);
 
// Create resource group
var rgData = new ResourceGroupData(AzureLocation.UKSouth);
rgData.Tags.Add("Environment", "prod");
var operation = await subscription.GetResourceGroups()
    .CreateOrUpdateAsync(WaitUntil.Completed, "rg-myapp-prod", rgData);

See also: Azure - Az CLI commands for the resources this SDK manages (Key Vault, Blob Storage, resource groups, AKS).


Kusto (Azure Data Explorer) SDK

Setup

Bash
dotnet add package Microsoft.Azure.Kusto.Data
dotnet add package Microsoft.Azure.Kusto.Ingest
dotnet add package Azure.Identity   # for modern auth

Query client

CSHARP
using Kusto.Data;
using Kusto.Data.Common;
using Kusto.Data.Net.Client;
using Azure.Identity;
using Azure.Core;
 
// Build connection string with AAD token provider
var credential    = new DefaultAzureCredential();
var clusterUri    = "https://mycluster.westeurope.kusto.windows.net";
var databaseName  = "mydb";
 
var connectionStringBuilder = new KustoConnectionStringBuilder(clusterUri, databaseName)
    .WithAadAzureTokenCredentialsAuthentication(credential);
 
using var queryClient = KustoClientFactory.CreateCslQueryProvider(connectionStringBuilder);

Execute KQL queries

CSHARP
// Simple query
var query   = "MyTable | where Timestamp > ago(1h) | take 100";
var reader  = await queryClient.ExecuteQueryAsync(databaseName, query, new ClientRequestProperties());
 
while (reader.Read())
{
    var name      = reader.GetString(0);
    var count     = reader.GetInt64(1);
    var timestamp = reader.GetDateTime(2);
    Console.WriteLine($"{name}: {count} at {timestamp}");
}
 
// Parameterised query (prevents KQL injection)
var props = new ClientRequestProperties();
props.SetParameter("userId",    "user-123");
props.SetParameter("hoursBack", 24L);
 
var paramQuery = @"
declare query_parameters(userId: string, hoursBack: long);
MyTable
| where UserId == userId
| where Timestamp > ago(hoursBack * 1h)
| summarize count() by Category
";
 
using var paramReader = await queryClient.ExecuteQueryAsync(databaseName, paramQuery, props);
var results = new List<(string Category, long Count)>();
while (paramReader.Read())
    results.Add((paramReader.GetString(0), paramReader.GetInt64(1)));
 
// Map to typed objects
public static async Task<List<T>> QueryAsync<T>(
    ICslQueryProvider client,
    string database,
    string kql,
    Func<IDataReader, T> mapper,
    ClientRequestProperties? props = null,
    CancellationToken ct = default)
{
    using var reader = await client.ExecuteQueryAsync(database, kql, props ?? new());
    var results = new List<T>();
    while (reader.Read()) results.Add(mapper(reader));
    return results;
}

Validate KQL queries locally

The Kusto.Language package provides an offline KQL parser - no cluster connection required. Use it in CI pipelines, editors, or pre-flight checks before sending queries to ADX/Sentinel.

Bash
# Offline KQL parser - separate from the query/ingest packages
dotnet add package Kusto.Language
CSHARP
using Kusto.Language;
using Kusto.Language.Symbols;
 
// ── 1. Syntax-only validation (pure offline, no schema needed) ──────────────
string kql = "MyTable | where Timestamp > ago(1h) | summarize count() by Category";
 
KustoCode parsed      = KustoCode.Parse(kql);
var       diagnostics = parsed.GetDiagnostics();
 
if (diagnostics.Count == 0)
    Console.WriteLine("Syntax OK");
else
    foreach (var d in diagnostics)
        Console.WriteLine($"[{d.Severity}] {d.Message}  (pos {d.Start}, len {d.Length})");
 
// ── 2. Semantic validation with a local schema ──────────────────────────────
// Define the schema that mirrors your ADX/Sentinel table
var table = new TableSymbol("MyTable",
    new ColumnSymbol("Timestamp",  ScalarTypes.DateTime),
    new ColumnSymbol("UserId",     ScalarTypes.String),
    new ColumnSymbol("EventType",  ScalarTypes.String),
    new ColumnSymbol("Count",      ScalarTypes.Long));
 
GlobalState globals  = GlobalState.Default.WithDatabase(
    new DatabaseSymbol("mydb", table));
 
KustoCode analyzed = KustoCode.ParseAndAnalyze(kql, globals);
 
bool isValid = analyzed.GetDiagnostics().Count == 0;
Console.WriteLine(isValid ? "Valid" : "Invalid");
foreach (var d in analyzed.GetDiagnostics())
    Console.WriteLine($"  [{d.Severity}] {d.Message}");
 
// ── 3. Bulk validation (useful for a library of saved queries) ──────────────
string[] queries =
[
    "MyTable | count",
    "MyTable | where NonExistentCol == 1",  // semantic error - unknown column
    "MyTable | where Timestamp >",          // syntax error - incomplete expression
    "MyTable | where EventType == 'login' | summarize count() by UserId",
];
 
foreach (var q in queries)
{
    var result = KustoCode.ParseAndAnalyze(q, globals);
    var diags  = result.GetDiagnostics();
    Console.WriteLine($"{(diags.Count == 0 ? "OK  " : "FAIL")} {q}");
    foreach (var d in diags)
        Console.WriteLine($"     └ [{d.Severity}] {d.Message}");
}
 
// ── 4. Build GlobalState from a live cluster schema ─────────────────────────
// Run once per deployment; cache the GlobalState for offline validation.
// .show database schema as json  →  returns JSON describing all tables/columns.
const string schemaCmd = ".show database schema as json";
using var schemaReader = await queryClient.ExecuteControlCommandAsync(
    databaseName, schemaCmd, new ClientRequestProperties());
 
// The first column of the first row is the JSON blob
string schemaJson = schemaReader.Read() ? schemaReader.GetString(0) : "{}";
 
// Parse the JSON into ColumnSymbol/TableSymbol collections
// (structure: { "Tables": { "TableName": { "OrderedColumns": [...] } } })
using var doc = JsonDocument.Parse(schemaJson);
var tables = new List<TableSymbol>();
 
foreach (var tableEntry in doc.RootElement.GetProperty("Tables").EnumerateObject())
{
    var columns = tableEntry.Value
        .GetProperty("OrderedColumns")
        .EnumerateArray()
        .Select(col =>
        {
            var csType = col.GetProperty("CslType").GetString() switch
            {
                "datetime"  => ScalarTypes.DateTime,
                "string"    => ScalarTypes.String,
                "long"      => ScalarTypes.Long,
                "real"      => ScalarTypes.Real,
                "bool"      => ScalarTypes.Bool,
                "dynamic"   => ScalarTypes.Dynamic,
                "guid"      => ScalarTypes.Guid,
                "timespan"  => ScalarTypes.TimeSpan,
                _           => ScalarTypes.String,
            };
            return new ColumnSymbol(col.GetProperty("Name").GetString()!, csType);
        })
        .ToArray();
 
    tables.Add(new TableSymbol(tableEntry.Name, columns));
}
 
GlobalState liveGlobals = GlobalState.Default.WithDatabase(
    new DatabaseSymbol(databaseName, [.. tables]));
 
// Now validate any KQL query against the real schema - offline
var check = KustoCode.ParseAndAnalyze("MyTable | where Timestamp > ago(1d)", liveGlobals);
Console.WriteLine(check.GetDiagnostics().Count == 0 ? "Valid" : "Invalid");
CSHARP
// ── 5. Server-side dry run via set noexec (requires cluster) ────────────────
// Compiles the query against the real schema but skips execution entirely.
// No RU cost, no results returned - useful when you don't have the schema locally.
string noExecKql = $"set noexec;\n{kql}";
try
{
    using var reader = await queryClient.ExecuteQueryAsync(
        databaseName, noExecKql, new ClientRequestProperties());
    Console.WriteLine("Query compiled successfully (noexec)");
}
catch (Exception ex) when (ex.GetType().Name.Contains("KustoService"))
{
    Console.WriteLine($"Compilation failed: {ex.Message}");
}

Log Analytics (Azure Monitor) via SDK

CSHARP
using Azure.Monitor.Query;
using Azure.Monitor.Query.Models;
 
var logsClient    = new LogsQueryClient(new DefaultAzureCredential());
var workspaceId   = "00000000-0000-0000-0000-000000000000";
 
// Query workspace
var response = await logsClient.QueryWorkspaceAsync(
    workspaceId,
    @"AzureActivity
    | where TimeGenerated > ago(24h)
    | summarize count() by OperationNameValue
    | top 10 by count_",
    TimeSpan.FromDays(1),
    cancellationToken: ct);
 
foreach (var table in response.Value.AllTables)
{
    foreach (var row in table.Rows)
    {
        Console.WriteLine($"{row["OperationNameValue"]}: {row["count_"]}");
    }
}
 
// Batch queries
var batch = new LogsBatchQuery();
var q1Id  = batch.AddWorkspaceQuery(workspaceId, "AzureActivity | count", TimeSpan.FromDays(1));
var q2Id  = batch.AddWorkspaceQuery(workspaceId, "SigninLogs | where ResultType != '0' | count", TimeSpan.FromDays(1));
 
var batchResponse = await logsClient.QueryBatchAsync(batch, ct);
var q1Result      = batchResponse.Value.GetResult<long>(q1Id);
var q2Result      = batchResponse.Value.GetResult<long>(q2Id);

Kusto ingest

CSHARP
using Kusto.Ingest;
 
var ingestUri     = "https://ingest-mycluster.westeurope.kusto.windows.net";
var ingestConnStr = new KustoConnectionStringBuilder(ingestUri)
    .WithAadAzureTokenCredentialsAuthentication(new DefaultAzureCredential());
 
using var ingestClient = KustoIngestFactory.CreateQueuedIngestClient(ingestConnStr);
 
var ingestProps = new KustoQueuedIngestionProperties(databaseName, "MyTable")
{
    Format = DataSourceFormat.json,
    IngestionMapping = new IngestionMapping
    {
        IngestionMappingReference = "my-json-mapping",
        IngestionMappingKind      = IngestionMappingKind.Json,
    },
    ReportLevel  = IngestionReportLevel.FailuresAndSuccesses,
    ReportMethod = IngestionReportMethod.Table,
};
 
// Ingest from stream
var payload = JsonSerializer.Serialize(new { UserId = "u1", EventType = "login", Timestamp = DateTime.UtcNow });
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(payload));
await ingestClient.IngestFromStreamAsync(stream, ingestProps);
 
// Ingest from blob
await ingestClient.IngestFromStorageAsync(
    "https://mystg.blob.core.windows.net/raw/data.json?sv=...",
    ingestProps);

See also: KQL - the query language used in ADX and Log Analytics. Covers query structure, joins, aggregations, and threat hunting patterns that complement this SDK reference.


Testing with xUnit

🛠️ Deeper reference - this section covers xUnit patterns with Moq, FluentAssertions, Theory/InlineData, NullLogger, and coverage reporting.

CSHARP
// MyApp.Tests/UserServiceTests.cs
using Xunit;
using Moq;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
 
public class UserServiceTests
{
    private readonly Mock<IUserRepository> _repoMock  = new();
    private readonly UserService           _sut;
 
    public UserServiceTests()
    {
        _sut = new UserService(_repoMock.Object, NullLogger<UserService>.Instance, TimeProvider.System);
    }
 
    [Fact]
    public async Task GetUserAsync_WhenUserExists_ReturnsUser()
    {
        // Arrange
        var expected = new User("1", "Alice", "alice@example.com", DateTime.UtcNow);
        _repoMock.Setup(r => r.GetByIdAsync("1", default)).ReturnsAsync(expected);
 
        // Act
        var result = await _sut.GetUserAsync("1");
 
        // Assert
        result.Should().BeEquivalentTo(expected);
        _repoMock.Verify(r => r.GetByIdAsync("1", default), Times.Once);
    }
 
    [Fact]
    public async Task GetUserAsync_WhenNotFound_ReturnsNull()
    {
        _repoMock.Setup(r => r.GetByIdAsync(It.IsAny<string>(), default)).ReturnsAsync((User?)null);
        var result = await _sut.GetUserAsync("999");
        result.Should().BeNull();
    }
 
    [Theory]
    [InlineData("")]
    [InlineData("  ")]
    [InlineData(null)]
    public async Task CreateUserAsync_WithInvalidName_Throws(string? name)
    {
        var request = new CreateUserRequest { Name = name!, Email = "test@example.com" };
        await _sut.Invoking(s => s.CreateUserAsync(request, default))
            .Should().ThrowAsync<ArgumentException>();
    }
}
Bash
# Run tests with coverage
dotnet test --collect:"XPlat Code Coverage"
dotnet tool install -g dotnet-reportgenerator-globaltool
reportgenerator -reports:"**/coverage.cobertura.xml" -targetdir:"coverage" -reporttypes:Html

Useful Patterns

System.Threading.Lock (.NET 9+)

CSHARP
// Prefer Lock over lock(object) - lower overhead, no boxing, non-reentrant
private readonly Lock _gate = new();
 
public void UpdateCache(string key, string value)
{
    using (_gate.EnterScope())
    {
        _cache[key] = value;
    }
}
 
// TryEnter for non-blocking attempt
if (_gate.TryEnter())
{
    using (_gate.EnterScope()) { /* already entered */ }
}

Cancellation token propagation

CSHARP
// Always accept and forward CancellationToken
public async Task<IReadOnlyList<User>> SearchAsync(
    string query,
    CancellationToken ct = default)
{
    using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
    cts.CancelAfter(TimeSpan.FromSeconds(30));   // per-operation timeout
 
    return await repo.SearchAsync(query, cts.Token);
}

Strongly-typed IDs

CSHARP
// Prevent mixing IDs of different entity types
public readonly record struct UserId(Guid Value)
{
    public static UserId New() => new(Guid.NewGuid());
    public static UserId Parse(string s) => new(Guid.Parse(s));
    public override string ToString() => Value.ToString();
}
 
public readonly record struct OrderId(Guid Value)
{
    public static OrderId New() => new(Guid.NewGuid());
}
 
// Now the compiler catches: void Process(UserId id) called with an OrderId

Extension methods

CSHARP
public static class StringExtensions
{
    public static string ToKebabCase(this string s) =>
        string.Concat(s.Select((c, i) => i > 0 && char.IsUpper(c) ? $"-{c}" : $"{c}")).ToLower();
 
    public static bool IsNullOrWhiteSpace([NotNullWhen(false)] this string? s) =>
        string.IsNullOrWhiteSpace(s);
}
 
public static class EnumerableExtensions
{
    public static IEnumerable<IReadOnlyList<T>> Chunk<T>(this IEnumerable<T> source, int size) =>
        source.Select((x, i) => (x, i))
              .GroupBy(t => t.i / size)
              .Select(g => (IReadOnlyList<T>)g.Select(t => t.x).ToList());
}

Anti-patterns

  • ⚠️ Console.WriteLine in service code - bypasses the logging pipeline with no levels, no structured fields, and no redaction. Use ILogger<T> everywhere.
  • 🚨 async void - exceptions are unobservable and crash the process; the only valid use is event handlers. Use async Task for everything else.
  • 🔬 Logging and rethrowing at every layer - log once at the boundary that owns the user-facing failure (controller, worker). Inner methods throw; the boundary logs once with full context.
  • ⚠️ Not propagating CancellationToken - passing default or CancellationToken.None silently opts out of cancellation. Accept and forward ct through every async call.
  • 🔬 IFoo with a single Foo and no tests substituting it - premature abstraction with real costs (extra file, DI registration, harder to read). Inject the concrete type; add the interface when you have a second implementation or a test fake.
  • 🔬 ConfigureAwait(false) in ASP.NET Core - the synchronisation context is absent in ASP.NET Core; ConfigureAwait(false) is a no-op there and misleads readers. Remove it from ASP.NET Core project code; keep it in library code that may be consumed outside of ASP.NET Core.
  • 🚨 Swallowing exceptions with empty catch {} - silently absorbs failures that should propagate. Always log with logger.LogError(ex, ...) or rethrow, never swallow.
Last updated on