All topics
Backend · Learning hub

.NET notes for developers

Master .NET with a curated set of 8 developer notes — core concepts, patterns, and interview prep. Maintained by the DevRecall team.

Save this stack to your DevRecallMore Backend notes
.NET

CLR, Ecosystem & Project Structure

.NET: CLR, Ecosystem & Project Structure .NET is Microsoft's cross-platform, open-source framework for building web, desktop, mobile, cloud, and IoT application

.NET: CLR, Ecosystem & Project Structure

.NET is Microsoft's cross-platform, open-source framework for building web, desktop, mobile, cloud, and IoT applications. .NET 8 (LTS) and .NET 9 are current. The CLR (Common Language Runtime) provides JIT compilation, garbage collection, and type safety.

.NET Ecosystem Overview

  • ASP.NET Core: web APIs, MVC, Razor Pages, Blazor — runs on Linux/Mac/Windows

  • Entity Framework Core: ORM for SQL databases (PostgreSQL, SQL Server, SQLite)

  • MAUI: cross-platform native UI (iOS, Android, Windows, macOS)

  • Blazor: C# in the browser (WebAssembly) or server-side rendering

  • .NET CLI: dotnet new, build, run, test, publish — the primary tool

  • NuGet: package manager (nuget.org). Packages declared in .csproj.

  • LTS vs STS: even versions (.NET 8, 10) are LTS (3 years); odd versions (9, 11) are STS (18 months)

Project Structure

# Create new projects
dotnet new webapi -n MyApi --use-controllers   # REST API with controllers
dotnet new webapi -n MyApi                     # Minimal API (no controllers)
dotnet new mvc -n MyMvc                        # MVC with Razor views
dotnet new classlib -n MyLib                   # Class library
dotnet new xunit -n MyLib.Tests                # xUnit test project

# Solution (groups multiple projects)
dotnet new sln -n MySolution
dotnet sln add src/MyApi/MyApi.csproj
dotnet sln add tests/MyApi.Tests/MyApi.Tests.csproj

# Common commands
dotnet run                   # run the project
dotnet watch run             # run with hot reload
dotnet build                 # compile
dotnet test                  # run tests
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL
dotnet publish -c Release -o ./publish
<!-- MyApi.csproj — project file -->
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net9.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <RootNamespace>MyApi</RootNamespace>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.0" />
    <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.0" />
    <PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />
  </ItemGroup>
</Project>

Key .NET Concepts

// Namespaces and using
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.DependencyInjection;

// Records (immutable data classes — C# 9+)
public record User(int Id, string Name, string Email);

// var — type inference
var numbers = new List<int> { 1, 2, 3 };
var user = new User(1, "Alice", "alice@example.com");

// LINQ — integrated query language
var admins = users
    .Where(u => u.Role == "admin")
    .OrderBy(u => u.Name)
    .Select(u => new { u.Id, u.Name })
    .ToList();

// Nullable reference types (enabled in modern .NET)
string? nullable = null;
string nonNullable = "hello";  // compiler warns if this could be null

// Pattern matching
object obj = "hello";
if (obj is string s && s.Length > 3)
    Console.WriteLine(s.ToUpper());

// Switch expression
string label = score switch {
    >= 90 => "A",
    >= 80 => "B",
    >= 70 => "C",
    _ => "F"
};
.NET

ASP.NET Core Web APIs

.NET: ASP.NET Core Web APIs Minimal API (modern approach) // Program.cs — entry point and composition root var builder = WebApplication.CreateBuilder(args); bui

.NET: ASP.NET Core Web APIs

Minimal API (modern approach)

// Program.cs — entry point and composition root
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOpenApi();
builder.Services.AddDbContext<AppDbContext>(opts =>
    opts.UseNpgsql(builder.Configuration.GetConnectionString("Default")));
builder.Services.AddScoped<IUserService, UserService>();

var app = builder.Build();

if (app.Environment.IsDevelopment())
    app.MapOpenApi();

app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();

// Map routes
app.MapGet("/users", async (IUserService svc) =>
    Results.Ok(await svc.GetAllAsync()))
    .WithName("GetUsers")
    .WithTags("Users");

app.MapGet("/users/{id:int}", async (int id, IUserService svc) => {
    var user = await svc.GetByIdAsync(id);
    return user is null ? Results.NotFound() : Results.Ok(user);
});

app.MapPost("/users", async (CreateUserDto dto, IUserService svc) => {
    var user = await svc.CreateAsync(dto);
    return Results.CreatedAtRoute("GetUser", new { id = user.Id }, user);
});

app.MapPut("/users/{id:int}", async (int id, UpdateUserDto dto, IUserService svc) => {
    var updated = await svc.UpdateAsync(id, dto);
    return updated ? Results.NoContent() : Results.NotFound();
});

app.MapDelete("/users/{id:int}", async (int id, IUserService svc) => {
    await svc.DeleteAsync(id);
    return Results.NoContent();
});

app.Run();

Controller-Based API

// Controllers/UsersController.cs
[ApiController]
[Route("api/[controller]")]
[Produces("application/json")]
public class UsersController : ControllerBase
{
    private readonly IUserService _service;

    public UsersController(IUserService service)
    {
        _service = service;
    }

    [HttpGet]
    [ProducesResponseType<List<UserDto>>(StatusCodes.Status200OK)]
    public async Task<IActionResult> GetAll([FromQuery] int page = 1, [FromQuery] int pageSize = 25)
    {
        var users = await _service.GetPagedAsync(page, pageSize);
        return Ok(users);
    }

    [HttpGet("{id:int}")]
    [ProducesResponseType<UserDto>(StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<IActionResult> GetById(int id)
    {
        var user = await _service.GetByIdAsync(id);
        return user is null ? NotFound() : Ok(user);
    }

    [HttpPost]
    [ProducesResponseType<UserDto>(StatusCodes.Status201Created)]
    [ProducesResponseType<ValidationProblemDetails>(StatusCodes.Status422UnprocessableEntity)]
    public async Task<IActionResult> Create([FromBody] CreateUserDto dto)
    {
        var user = await _service.CreateAsync(dto);
        return CreatedAtAction(nameof(GetById), new { id = user.Id }, user);
    }

    [HttpPatch("{id:int}")]
    public async Task<IActionResult> Update(int id, [FromBody] UpdateUserDto dto)
    {
        return await _service.UpdateAsync(id, dto) ? NoContent() : NotFound();
    }
}

DTOs & Validation

// Data Transfer Objects — separate from domain models
public record UserDto(int Id, string Name, string Email, DateTime CreatedAt);

public record CreateUserDto(
    [Required, MaxLength(100)] string Name,
    [Required, EmailAddress] string Email,
    [Required, MinLength(8)] string Password
);

public record UpdateUserDto(
    [MaxLength(100)] string? Name,
    [EmailAddress] string? Email
);

// Fluent validation (alternative to data annotations)
// Install: dotnet add package FluentValidation.AspNetCore
public class CreateUserValidator : AbstractValidator<CreateUserDto>
{
    public CreateUserValidator()
    {
        RuleFor(x => x.Name).NotEmpty().MaximumLength(100);
        RuleFor(x => x.Email).NotEmpty().EmailAddress();
        RuleFor(x => x.Password).MinimumLength(8)
            .Matches("[A-Z]").WithMessage("Password must contain an uppercase letter");
    }
}

Authentication (JWT)

// Program.cs
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(opts => {
        opts.TokenValidationParameters = new TokenValidationParameters {
            ValidateIssuerSigningKey = true,
            IssuerSigningKey = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Secret"]!)),
            ValidateIssuer = true,
            ValidIssuer = builder.Configuration["Jwt:Issuer"],
            ValidateAudience = true,
            ValidAudience = builder.Configuration["Jwt:Audience"],
            ValidateLifetime = true,
        };
    });

builder.Services.AddAuthorization(opts => {
    opts.AddPolicy("AdminOnly", p => p.RequireRole("admin"));
});

// Protect endpoints
[Authorize]                            // any authenticated user
[Authorize(Policy = "AdminOnly")]     // admin only
[Authorize(Roles = "admin,moderator")] // role-based
[AllowAnonymous]                       // override — no auth
.NET

Entity Framework Core

.NET: Entity Framework Core EF Core is the official ORM for .NET. It supports PostgreSQL (Npgsql), SQL Server, SQLite, MySQL, and others. Uses LINQ for queries

.NET: Entity Framework Core

EF Core is the official ORM for .NET. It supports PostgreSQL (Npgsql), SQL Server, SQLite, MySQL, and others. Uses LINQ for queries and Code First migrations.

DbContext & Models

// Models/User.cs
public class User
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public required string Email { get; set; }
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
    public List<Post> Posts { get; set; } = [];  // navigation property
}

public class Post
{
    public int Id { get; set; }
    public required string Title { get; set; }
    public required string Body { get; set; }
    public int UserId { get; set; }
    public User User { get; set; } = null!;  // navigation property
}

// Data/AppDbContext.cs
public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) {}

    public DbSet<User> Users => Set<User>();
    public DbSet<Post> Posts => Set<Post>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<User>(entity => {
            entity.HasIndex(u => u.Email).IsUnique();
            entity.Property(u => u.Name).HasMaxLength(100).IsRequired();
            entity.HasMany(u => u.Posts)
                  .WithOne(p => p.User)
                  .HasForeignKey(p => p.UserId)
                  .OnDelete(DeleteBehavior.Cascade);
        });
    }
}

Migrations

# Install EF CLI tools
dotnet tool install --global dotnet-ef

# Create migration
dotnet ef migrations add InitialCreate

# Apply migrations to database
dotnet ef database update

# Revert last migration
dotnet ef database update PreviousMigrationName

# Generate SQL script (for production)
dotnet ef migrations script --idempotent -o migration.sql

# Remove last migration (if not yet applied)
dotnet ef migrations remove

Querying

// Injected via DI
public class UserRepository
{
    private readonly AppDbContext _db;

    public UserRepository(AppDbContext db) { _db = db; }

    // Basic queries
    public async Task<List<User>> GetAllAsync() =>
        await _db.Users.ToListAsync();

    // Filter, order, project
    public async Task<List<UserDto>> GetAdminsAsync() =>
        await _db.Users
            .Where(u => u.Role == "admin")
            .OrderBy(u => u.Name)
            .Select(u => new UserDto(u.Id, u.Name, u.Email, u.CreatedAt))
            .ToListAsync();

    // Include related data (eager loading)
    public async Task<User?> GetWithPostsAsync(int id) =>
        await _db.Users
            .Include(u => u.Posts)
            .FirstOrDefaultAsync(u => u.Id == id);

    // Pagination
    public async Task<List<User>> GetPagedAsync(int page, int pageSize) =>
        await _db.Users
            .OrderBy(u => u.Id)
            .Skip((page - 1) * pageSize)
            .Take(pageSize)
            .ToListAsync();

    // Insert
    public async Task<User> CreateAsync(User user) {
        _db.Users.Add(user);
        await _db.SaveChangesAsync();
        return user;
    }

    // Update
    public async Task UpdateAsync(User user) {
        _db.Users.Update(user);
        await _db.SaveChangesAsync();
    }

    // Delete
    public async Task DeleteAsync(int id) {
        await _db.Users.Where(u => u.Id == id).ExecuteDeleteAsync();  // EF Core 7+
    }

    // Raw SQL (when LINQ isn't enough)
    public async Task<List<User>> SearchAsync(string term) =>
        await _db.Users
            .FromSqlRaw("SELECT * FROM users WHERE name ILIKE {0}", $"%{term}%")
            .ToListAsync();
}
.NET

Dependency Injection & Middleware

.NET: Dependency Injection & Middleware Dependency Injection ASP.NET Core has a built-in DI container. Services are registered in Program.cs and injected via co

.NET: Dependency Injection & Middleware

Dependency Injection

ASP.NET Core has a built-in DI container. Services are registered in Program.cs and injected via constructor injection.

// Service lifetimes
builder.Services.AddTransient<IEmailService, EmailService>();  // new instance every time
builder.Services.AddScoped<IUserRepository, UserRepository>();  // once per HTTP request
builder.Services.AddSingleton<ICacheService, MemoryCacheService>();  // one instance total

// Interface + implementation pattern
public interface IUserService
{
    Task<UserDto?> GetByIdAsync(int id);
    Task<UserDto> CreateAsync(CreateUserDto dto);
}

public class UserService : IUserService
{
    private readonly IUserRepository _repo;
    private readonly ILogger<UserService> _logger;

    public UserService(IUserRepository repo, ILogger<UserService> logger)
    {
        _repo = repo;
        _logger = logger;
    }

    public async Task<UserDto?> GetByIdAsync(int id)
    {
        _logger.LogInformation("Fetching user {UserId}", id);
        var user = await _repo.GetByIdAsync(id);
        return user is null ? null : new UserDto(user.Id, user.Name, user.Email, user.CreatedAt);
    }
}

// Options pattern — strongly typed config
public class JwtOptions
{
    public const string Section = "Jwt";
    public required string Secret { get; init; }
    public required string Issuer { get; init; }
    public int ExpiryMinutes { get; init; } = 60;
}

builder.Services.AddOptions<JwtOptions>()
    .BindConfiguration(JwtOptions.Section)
    .ValidateDataAnnotations();

// Inject options
public class TokenService(IOptions<JwtOptions> opts) {
    private readonly JwtOptions _opts = opts.Value;
}

Middleware Pipeline

// Middleware runs in registration order (request) then reverse order (response)
// Program.cs
app.UseExceptionHandler("/error");   // catch unhandled exceptions
app.UseHsts();
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseCors("AllowFrontend");
app.UseAuthentication();            // must come before UseAuthorization
app.UseAuthorization();
app.MapControllers();               // or app.MapEndpoints()

// Custom middleware
public class RequestLoggingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<RequestLoggingMiddleware> _logger;

    public RequestLoggingMiddleware(RequestDelegate next, ILogger<RequestLoggingMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        var sw = Stopwatch.StartNew();
        _logger.LogInformation("{Method} {Path}", context.Request.Method, context.Request.Path);

        await _next(context);

        sw.Stop();
        _logger.LogInformation("Response {StatusCode} in {ElapsedMs}ms",
            context.Response.StatusCode, sw.ElapsedMilliseconds);
    }
}

// Register
app.UseMiddleware<RequestLoggingMiddleware>();

// Inline middleware (simpler for one-liners)
app.Use(async (context, next) => {
    context.Response.Headers["X-Frame-Options"] = "DENY";
    await next();
});

Configuration & Secrets

// appsettings.json — checked into git
{
  "Logging": { "LogLevel": { "Default": "Information" } },
  "AllowedHosts": "*",
  "Jwt": { "Issuer": "myapp", "ExpiryMinutes": 60 }
}

// appsettings.Development.json — overrides for dev
// appsettings.Production.json — overrides for prod (don't commit secrets)

// Environment variables override all (for production secrets)
// Hierarchy: appsettings < appsettings.{env} < env vars < command line

// User secrets (dev only — stored outside repo)
// dotnet user-secrets init
// dotnet user-secrets set "Jwt:Secret" "my-dev-secret-key-32chars"

// Access in code
var secret = builder.Configuration["Jwt:Secret"];
// or strongly typed:
var jwtOpts = builder.Configuration.GetSection("Jwt").Get<JwtOptions>();

Logging & Health Checks

// Structured logging with ILogger (Serilog recommended for production)
// dotnet add package Serilog.AspNetCore
builder.Host.UseSerilog((context, config) => {
    config
        .ReadFrom.Configuration(context.Configuration)
        .Enrich.FromLogContext()
        .WriteTo.Console()
        .WriteTo.Seq("http://localhost:5341");
});

// In services — structured logging with parameters
_logger.LogWarning("User {UserId} exceeded rate limit: {Requests} requests", userId, count);
_logger.LogError(ex, "Failed to process order {OrderId}", orderId);

// Health checks
builder.Services.AddHealthChecks()
    .AddNpgSql(connectionString, name: "postgres")
    .AddRedis(redisConnection, name: "redis")
    .AddCheck("disk", () =>
        DriveInfo.GetDrives().Any(d => d.AvailableFreeSpace < 100_000_000)
            ? HealthCheckResult.Degraded("Low disk space")
            : HealthCheckResult.Healthy());

app.MapHealthChecks("/health");
app.MapHealthChecks("/health/ready", new HealthCheckOptions {
    Predicate = check => check.Tags.Contains("ready")
});
.NET

Deployment, Performance & Interview Questions

.NET: Deployment, Performance & Interview Questions Publishing & Docker # Publish for production dotnet publish -c Release -o ./publish # Self-contained (no run

.NET: Deployment, Performance & Interview Questions

Publishing & Docker

# Publish for production
dotnet publish -c Release -o ./publish

# Self-contained (no runtime needed on target)
dotnet publish -c Release -r linux-x64 --self-contained -o ./publish

# Single file executable
dotnet publish -c Release -r linux-x64 --self-contained   -p:PublishSingleFile=true -o ./publish
# Multi-stage Dockerfile
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY *.csproj ./
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app/publish

FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
WORKDIR /app
COPY --from=build /app/publish .
EXPOSE 8080
ENV ASPNETCORE_URLS=http://+:8080
ENTRYPOINT ["dotnet", "MyApi.dll"]

Performance Tips

  • Use async/await throughout — never block on async code with .Result or .Wait()

  • AsNoTracking() on EF queries that don't need change tracking — significant performance gain for read-heavy endpoints

  • Response compression: app.UseResponseCompression() — cuts JSON payload 60-80%

  • Output caching (ASP.NET Core 7+): [OutputCache(Duration = 60)] — server-side caching without client changes

  • IMemoryCache / IDistributedCache (Redis): cache expensive queries or computed results

  • Minimal APIs are slightly faster than controller-based (less overhead) — but controllers are easier to organize

  • Span<T> and ArrayPool<T>: avoid allocations in hot paths — reduces GC pressure

  • Use CancellationToken everywhere — allows request cancellation to propagate to DB queries

Testing

// xUnit + Moq
public class UserServiceTests
{
    private readonly Mock<IUserRepository> _repoMock = new();
    private readonly UserService _sut;

    public UserServiceTests()
    {
        _sut = new UserService(_repoMock.Object, Mock.Of<ILogger<UserService>>());
    }

    [Fact]
    public async Task GetByIdAsync_WhenUserExists_ReturnsDto()
    {
        var user = new User { Id = 1, Name = "Alice", Email = "alice@example.com" };
        _repoMock.Setup(r => r.GetByIdAsync(1)).ReturnsAsync(user);

        var result = await _sut.GetByIdAsync(1);

        Assert.NotNull(result);
        Assert.Equal("Alice", result.Name);
    }

    // Integration test with WebApplicationFactory
    public class ApiIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
    {
        private readonly HttpClient _client;

        public ApiIntegrationTests(WebApplicationFactory<Program> factory)
        {
            _client = factory.WithWebHostBuilder(builder => {
                builder.ConfigureServices(services => {
                    // Replace real DB with in-memory for tests
                    services.AddDbContext<AppDbContext>(opts =>
                        opts.UseInMemoryDatabase("TestDb"));
                });
            }).CreateClient();
        }

        [Fact]
        public async Task GetUsers_ReturnsOk()
        {
            var response = await _client.GetAsync("/api/users");
            response.EnsureSuccessStatusCode();
        }
    }
}

Interview Questions

  • What is the difference between .NET Framework and .NET (Core)? .NET Framework is Windows-only, legacy. .NET 5+ is cross-platform, open-source, and the future.

  • What is the CLR? Common Language Runtime — manages memory (GC), JIT compilation, exception handling, and thread management.

  • Explain GC generations. Gen 0 (short-lived), Gen 1 (medium), Gen 2 (long-lived). GC promotes objects that survive collections. Large objects (>85KB) go to the LOH.

  • What is the difference between IEnumerable and IQueryable? IEnumerable executes in-memory; IQueryable translates to SQL (via EF). Always finish EF queries with ToList() to materialize.

  • Explain async/await internals. await captures the continuation and returns control to the caller. The state machine resumes when the awaited task completes — no thread is blocked.

  • What is the difference between Transient, Scoped, and Singleton? Transient: new per injection. Scoped: one per HTTP request. Singleton: one per app lifetime.

  • What is middleware? Pipeline components that handle HTTP requests/responses. Each calls next() to pass to the next component or short-circuits.

  • What are value types vs reference types? Value types (struct, int, bool) stored on stack — copied on assignment. Reference types (class) stored on heap — shared reference.

.NET

SignalR & Background Services

.NET: SignalR & Background Services SignalR — Real-Time Communication SignalR abstracts WebSockets, Server-Sent Events, and Long Polling behind a single Hub API

.NET: SignalR & Background Services

SignalR — Real-Time Communication

SignalR abstracts WebSockets, Server-Sent Events, and Long Polling behind a single Hub API. Clients can receive server pushes without polling.

// Program.cs
builder.Services.AddSignalR();
app.MapHub<ChatHub>("/hubs/chat");

// Hubs/ChatHub.cs
public class ChatHub : Hub
{
    // Called by client
    public async Task SendMessage(string room, string message)
    {
        await Clients.Group(room).SendAsync("ReceiveMessage", new {
            User = Context.User?.Identity?.Name,
            Message = message,
            At = DateTime.UtcNow,
        });
    }

    public async Task JoinRoom(string room)
    {
        await Groups.AddToGroupAsync(Context.ConnectionId, room);
        await Clients.Group(room).SendAsync("UserJoined", Context.ConnectionId);
    }

    public override async Task OnDisconnectedAsync(Exception? exception)
    {
        // cleanup
        await base.OnDisconnectedAsync(exception);
    }
}

// Push from outside a Hub (e.g., from a service)
public class NotificationService(IHubContext<ChatHub> hub)
{
    public async Task NotifyAsync(string userId, string message) =>
        await hub.Clients.User(userId).SendAsync("Notification", message);
}
// TypeScript client
import { HubConnectionBuilder, LogLevel } from '@microsoft/signalr'

const connection = new HubConnectionBuilder()
  .withUrl('/hubs/chat', { accessTokenFactory: () => getToken() })
  .withAutomaticReconnect()
  .configureLogging(LogLevel.Information)
  .build()

connection.on('ReceiveMessage', (data) => {
  console.log(`${data.user}: ${data.message}`)
})

await connection.start()
await connection.invoke('JoinRoom', 'general')
await connection.invoke('SendMessage', 'general', 'Hello!')

Background Services

// IHostedService — runs alongside the web server
// BackgroundService — abstract base class (preferred)

public class EmailQueueProcessor : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<EmailQueueProcessor> _logger;

    public EmailQueueProcessor(IServiceScopeFactory scopeFactory, ILogger<EmailQueueProcessor> logger)
    {
        _scopeFactory = scopeFactory;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        _logger.LogInformation("Email processor started");

        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                using var scope = _scopeFactory.CreateScope();  // Scoped services need a scope
                var emailService = scope.ServiceProvider.GetRequiredService<IEmailService>();
                await emailService.ProcessQueueAsync(stoppingToken);
            }
            catch (Exception ex) when (!stoppingToken.IsCancellationRequested)
            {
                _logger.LogError(ex, "Error processing email queue");
            }

            await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
        }
    }
}

// Register
builder.Services.AddHostedService<EmailQueueProcessor>();

// Worker Service project (console app, no HTTP)
// dotnet new worker -n MyWorker

Channels — Producer/Consumer

// System.Threading.Channels — async queue built into .NET
// Use for producer/consumer pipelines within a process

public class WorkQueue
{
    private readonly Channel<WorkItem> _channel =
        Channel.CreateBounded<WorkItem>(new BoundedChannelOptions(500) {
            FullMode = BoundedChannelFullMode.Wait,
        });

    public async ValueTask EnqueueAsync(WorkItem item, CancellationToken ct = default) =>
        await _channel.Writer.WriteAsync(item, ct);

    public IAsyncEnumerable<WorkItem> ReadAllAsync(CancellationToken ct = default) =>
        _channel.Reader.ReadAllAsync(ct);
}

// Producer (e.g., API endpoint)
await _queue.EnqueueAsync(new WorkItem(payload));

// Consumer (background service)
await foreach (var item in _queue.ReadAllAsync(stoppingToken))
{
    await ProcessAsync(item);
}

// Hangfire — persistent background jobs (survives restarts)
// dotnet add package Hangfire.AspNetCore Hangfire.SqlServer
builder.Services.AddHangfire(cfg => cfg.UseSqlServerStorage(connStr));
builder.Services.AddHangfireServer();
app.MapHangfireDashboard("/hangfire");

BackgroundJob.Enqueue(() => Console.WriteLine("Fire and forget"));
BackgroundJob.Schedule(() => SendReminder(userId), TimeSpan.FromDays(1));
RecurringJob.AddOrUpdate("cleanup", () => Cleanup(), Cron.Daily);
.NET

Testing in .NET

.NET: Testing xUnit Basics // dotnet add package xunit xunit.runner.visualstudio // dotnet add package Moq // dotnet add package FluentAssertions // dotnet add

.NET: Testing

xUnit Basics

// dotnet add package xunit xunit.runner.visualstudio
// dotnet add package Moq
// dotnet add package FluentAssertions
// dotnet add package Bogus  (fake data)

public class CalculatorTests
{
    [Fact]
    public void Add_TwoPositiveNumbers_ReturnsSum()
    {
        // Arrange
        var calc = new Calculator();
        // Act
        var result = calc.Add(3, 4);
        // Assert
        Assert.Equal(7, result);
    }

    [Theory]
    [InlineData(2, 3, 5)]
    [InlineData(-1, 1, 0)]
    [InlineData(0, 0, 0)]
    public void Add_VariousInputs_ReturnsCorrectSum(int a, int b, int expected)
    {
        var calc = new Calculator();
        Assert.Equal(expected, calc.Add(a, b));
    }

    [Theory]
    [ClassData(typeof(AddTestData))]
    public void Add_ClassData_Works(int a, int b, int expected) { }
}

public class AddTestData : TheoryData<int, int, int>
{
    public AddTestData()
    {
        Add(1, 2, 3);
        Add(10, 20, 30);
    }
}

Moq — Mocking

using Moq;

public class UserServiceTests
{
    private readonly Mock<IUserRepository> _repoMock = new();
    private readonly Mock<IEmailService> _emailMock = new();
    private readonly UserService _sut;

    public UserServiceTests()
    {
        _sut = new UserService(_repoMock.Object, _emailMock.Object,
            Mock.Of<ILogger<UserService>>());
    }

    [Fact]
    public async Task CreateAsync_ValidUser_SendsWelcomeEmail()
    {
        // Arrange
        var dto = new CreateUserDto("Alice", "alice@example.com", "password");
        _repoMock.Setup(r => r.ExistsAsync(dto.Email)).ReturnsAsync(false);
        _repoMock.Setup(r => r.CreateAsync(It.IsAny<User>()))
                 .ReturnsAsync((User u) => u);

        // Act
        var result = await _sut.CreateAsync(dto);

        // Assert
        _emailMock.Verify(e => e.SendWelcomeAsync(dto.Email), Times.Once);
        Assert.Equal("Alice", result.Name);
    }

    [Fact]
    public async Task CreateAsync_DuplicateEmail_ThrowsException()
    {
        _repoMock.Setup(r => r.ExistsAsync("alice@example.com")).ReturnsAsync(true);

        await Assert.ThrowsAsync<DuplicateEmailException>(
            () => _sut.CreateAsync(new CreateUserDto("Alice", "alice@example.com", "pass")));
    }
}

FluentAssertions

using FluentAssertions;

// Readable assertions — error messages show expected vs actual clearly
result.Should().Be(42);
result.Should().NotBeNull();
result.Should().BeGreaterThan(0).And.BeLessThan(100);

users.Should().HaveCount(3);
users.Should().ContainSingle(u => u.Name == "Alice");
users.Should().BeInAscendingOrder(u => u.Name);
users.Should().AllSatisfy(u => u.IsActive.Should().BeTrue());

user.Name.Should().StartWith("Al").And.HaveLength(5);
user.Email.Should().MatchRegex(@"^[^@]+@[^@]+\.[^@]+$");

// Exceptions
action.Should().Throw<ArgumentException>()
    .WithMessage("*cannot be empty*");

// Collections
list.Should().Contain(5).And.HaveCountGreaterThan(2);
list.Should().BeEquivalentTo(new[] { 1, 2, 3 });  // order-insensitive

Integration Tests with WebApplicationFactory

// dotnet add package Microsoft.AspNetCore.Mvc.Testing
// dotnet add package Testcontainers.PostgreSql  (real DB in Docker)

public class ApiIntegrationTests : IClassFixture<CustomWebApplicationFactory>
{
    private readonly HttpClient _client;

    public ApiIntegrationTests(CustomWebApplicationFactory factory)
    {
        _client = factory.CreateClient();
    }

    [Fact]
    public async Task CreateUser_ValidRequest_Returns201()
    {
        var dto = new { Name = "Alice", Email = "alice@test.com", Password = "Password1!" };
        var response = await _client.PostAsJsonAsync("/api/users", dto);

        response.StatusCode.Should().Be(HttpStatusCode.Created);
        var user = await response.Content.ReadFromJsonAsync<UserDto>();
        user!.Name.Should().Be("Alice");
    }
}

public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureServices(services => {
            // Replace real DB with test DB
            services.RemoveAll<DbContextOptions<AppDbContext>>();
            services.AddDbContext<AppDbContext>(opts =>
                opts.UseInMemoryDatabase("TestDb_" + Guid.NewGuid()));

            // Or use Testcontainers for real PostgreSQL
            // var pg = new PostgreSqlBuilder().Build();
            // pg.StartAsync().GetAwaiter().GetResult();
            // services.AddDbContext<AppDbContext>(opts => opts.UseNpgsql(pg.GetConnectionString()));
        });
    }
}
.NET

Caching & gRPC

.NET: Caching & gRPC Caching Strategies // IMemoryCache — in-process, single server builder.Services.AddMemoryCache(); public class ProductService(IMemoryCache

.NET: Caching & gRPC

Caching Strategies

// IMemoryCache — in-process, single server
builder.Services.AddMemoryCache();

public class ProductService(IMemoryCache cache, IProductRepository repo)
{
    public async Task<Product?> GetByIdAsync(int id)
    {
        var key = $"product:{id}";
        if (cache.TryGetValue(key, out Product? cached))
            return cached;

        var product = await repo.GetByIdAsync(id);

        cache.Set(key, product, new MemoryCacheEntryOptions {
            AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10),
            SlidingExpiration = TimeSpan.FromMinutes(2),
            Priority = CacheItemPriority.Normal,
        });
        return product;
    }

    public void Invalidate(int id) => cache.Remove($"product:{id}");
}

// IDistributedCache — Redis (shared across multiple servers)
builder.Services.AddStackExchangeRedisCache(opts => {
    opts.Configuration = builder.Configuration.GetConnectionString("Redis");
    opts.InstanceName = "MyApp:";
});

public class DistributedProductService(IDistributedCache cache, IProductRepository repo)
{
    public async Task<Product?> GetByIdAsync(int id, CancellationToken ct = default)
    {
        var key = $"product:{id}";
        var cached = await cache.GetStringAsync(key, ct);
        if (cached is not null)
            return JsonSerializer.Deserialize<Product>(cached);

        var product = await repo.GetByIdAsync(id, ct);
        if (product is not null)
            await cache.SetStringAsync(key, JsonSerializer.Serialize(product),
                new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) }, ct);

        return product;
    }
}

// Output Cache (ASP.NET Core 7+) — cache full HTTP responses
builder.Services.AddOutputCache(opts => {
    opts.AddBasePolicy(b => b.Expire(TimeSpan.FromMinutes(5)));
    opts.AddPolicy("Products", b => b.Expire(TimeSpan.FromMinutes(10)).Tag("products"));
});
app.UseOutputCache();

[HttpGet]
[OutputCache(PolicyName = "Products")]
public async Task<IActionResult> GetAll() { }

// Invalidate tagged cache entries
await _outputCache.EvictByTagAsync("products", ct);

gRPC

gRPC uses HTTP/2 and Protocol Buffers (binary). ~5x faster than JSON over REST for internal services. Supports streaming. Ideal for microservice-to-microservice communication.

// Protos/users.proto
syntax = "proto3";
option csharp_namespace = "MyApi.Protos";

package users;

service UsersService {
  rpc GetUser (GetUserRequest) returns (UserReply);
  rpc ListUsers (ListUsersRequest) returns (stream UserReply);  // server streaming
  rpc CreateUser (CreateUserRequest) returns (UserReply);
}

message GetUserRequest { int32 id = 1; }
message ListUsersRequest { int32 page = 1; int32 page_size = 2; }
message CreateUserRequest { string name = 1; string email = 2; }
message UserReply { int32 id = 1; string name = 2; string email = 3; }
// Server — Services/UsersGrpcService.cs
// dotnet add package Grpc.AspNetCore
public class UsersGrpcService : UsersService.UsersServiceBase
{
    private readonly IUserRepository _repo;
    public UsersGrpcService(IUserRepository repo) => _repo = repo;

    public override async Task<UserReply> GetUser(GetUserRequest request, ServerCallContext context)
    {
        var user = await _repo.GetByIdAsync(request.Id)
            ?? throw new RpcException(new Status(StatusCode.NotFound, $"User {request.Id} not found"));

        return new UserReply { Id = user.Id, Name = user.Name, Email = user.Email };
    }

    public override async Task ListUsers(ListUsersRequest request,
        IServerStreamWriter<UserReply> responseStream, ServerCallContext context)
    {
        var users = await _repo.GetPagedAsync(request.Page, request.PageSize);
        foreach (var user in users)
        {
            await responseStream.WriteAsync(new UserReply { Id = user.Id, Name = user.Name });
            await Task.Delay(10);  // simulate streaming pace
        }
    }
}

// Program.cs
builder.Services.AddGrpc();
app.MapGrpcService<UsersGrpcService>();

// Client
// dotnet add package Grpc.Net.Client Google.Protobuf Grpc.Tools
using var channel = GrpcChannel.ForAddress("https://localhost:5001");
var client = new UsersService.UsersServiceClient(channel);
var user = await client.GetUserAsync(new GetUserRequest { Id = 1 });

Keep your .NET knowledge sharp.

Save this stack to your personal DevRecall — add your own notes, track what you're learning, and share what you know with the community.

Get started — free forever