Skip to content
ExponaBLOG
320 questions · Backend Engineering

300+ .NET Interview Questions and Answers

Prepare with 320 .NET interview questions, detailed answers, C# code examples and diagrams, ordered from beginner through experienced to expert.
300+ .NET Interview Questions and Answers

This guide collects 320 .NET interview questions with answers, from the basics every junior developer should know to the runtime internals and architecture topics asked in senior and lead interviews. Every answer is written to be said out loud in an interview: a clear explanation first, then an example in C#, then the follow-ups interviewers usually ask.

How the questions are organized

Questions are numbered in order of difficulty, so you can work from the top or jump straight to your level.

Topics covered

How to use this guide

  • Answer each question aloud before reading the answer, then compare.

  • Run the code examples in a scratch project — interviewers often ask you to change them.

  • Follow the related-question links at the end of each answer to cover a topic end to end.

  • For senior roles, prepare a real example from your own work for every architecture and performance question.

Go deeper on the blog

  1. 01What is .NET?

    Beginner

    .NET is a free, open-source, cross-platform developer platform created by Microsoft for building applications across web, desktop, mobile, cloud, and IoT environments. It provides developers with a runtime environment, a comprehensive standard library, and language options like C#, F#, and Visual Basic to write modern applications that run on Windows, Linux, and macOS.

    How it works

    .NET operates on a managed execution model powered by the Common Language Runtime (CLR). When you write C# code, the compiler translates it into an intermediate language called Common Intermediate Language (CIL). When your application runs, the CLR's Just-In-Time (JIT) compiler converts CIL into native machine code specific to the operating system. This abstraction layer provides several benefits: automatic memory management through garbage collection, platform independence, and security features built into the runtime.

    .NET includes the Base Class Library (BCL), a massive collection of pre-built types and APIs for common tasks like file I/O, networking, collections, and data processing. Modern .NET (versions 5+) is unified and cross-platform by default, making it easy to share code between a web server running on Linux and a desktop application on Windows. The SDK provides command-line tools, NuGet package management, and integration with Visual Studio for a complete development experience.

    Example

    csharp
    using System;
    
    class Program
    {
        static void Main()
        {
            string message = "Hello, .NET!";
            int version = 8;
            
            Console.WriteLine($"{message} Running on .NET {version}");
            Console.WriteLine($"Timestamp: {DateTime.UtcNow:O}");
        }
    }
    

    This simple console application demonstrates .NET fundamentals: it uses C# language features (string interpolation), the BCL (Console and DateTime), and outputs a message with the current timestamp—all managed by the runtime.

    Key points to mention

    • Free and open-source: .NET is licensed under MIT and actively developed on GitHub, with contributions from Microsoft and the community.
    • Cross-platform: Write once and run on Windows, Linux, and macOS without recompilation.
    • Managed runtime: The CLR handles memory management, type safety, and security automatically.
    • Unified platform: Use the same .NET runtime for web (ASP.NET Core), desktop (WinForms, WPF), mobile (MAUI), and cloud applications.
    • Strong ecosystem: Access to NuGet packages, extensive documentation, and a large developer community.

    Common follow-up questions

    • What is the difference between .NET Framework and modern .NET?
    • How does garbage collection work in .NET?
    • What is ASP.NET Core and how does it relate to .NET?

    Related interview questions

    .NETCsharp
  2. 02What is the difference between .NET Framework, .NET Core and modern .NET?

    Beginner

    Direct Answer: .NET Framework is the original Windows-only runtime released in 2002. .NET Core, launched in 2016, was Microsoft's cross-platform rewrite. Starting with .NET 5 (2020), they unified into one platform simply called ".NET" with annual releases, LTS support every two years, and full cross-platform support for Windows, Linux, and macOS.

    How it works

    .NET Framework was tightly integrated with Windows and relied on the Windows Runtime environment. This made it powerful for Windows desktop and server applications but impossible to run on other operating systems.

    Microsoft recognized the industry shift toward cloud, containerization, and cross-platform development. In 2016, they created .NET Core as a ground-up rewrite: open-source, modular, and designed to run on any OS. For several years, both platforms coexisted—enterprises stayed on Framework for legacy systems while new projects chose Core for flexibility.

    By 2020, .NET Core had matured enough to become the single, unified ".NET" platform. .NET Framework is now in maintenance mode (no new features, security updates only). Modern .NET emphasizes fast iteration with yearly releases, LTS versions every two years for production stability, and a lean, composable toolchain.

    Today, all new .NET development uses modern .NET (currently .NET 8 or 9), which includes ASP.NET Core, Entity Framework Core, and a rich ecosystem of NuGet packages.

    Example

    csharp
    // Modern .NET (8+) – works on Windows, Linux, macOS
    using System;
    
    var message = "Hello from .NET 8!";
    Console.WriteLine(message);
    Console.WriteLine($"OS: {System.Runtime.InteropServices.RuntimeInformation.OSDescription}");
    Console.WriteLine($"Runtime: {System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription}");
    

    This code runs identically on any platform—try it on Windows PowerShell, a Linux terminal, or macOS. .NET Framework code would only work on Windows; .NET Core 3.1 would work cross-platform but with older APIs.

    Key points to mention

    • .NET Framework (2002–present): Windows-only, in maintenance mode, used for legacy enterprise systems.
    • .NET Core (2016–2019): The experimental, open-source cross-platform foundation that proved the concept.
    • Modern .NET (5+): Unified platform with yearly releases, LTS versions, cloud-native tooling, and full OS support.
    • Migration path: Framework → Core → modern .NET; most new production code targets .NET 8 LTS or .NET 9 STS.
    • .NET Framework will receive security patches indefinitely but no new features; modern .NET is the strategic investment.

    Comparison table

    Aspect .NET Framework .NET Core (3.1) Modern .NET (5+)
    OS Support Windows only Windows, Linux, macOS Windows, Linux, macOS
    Release Model Infrequent major versions Annual releases Annual releases + LTS every 2 years
    Open Source Partial Full (GitHub) Full (GitHub)
    Status Maintenance mode End of life (Jan 2022) Active development
    Cloud Ready Legacy approach Modern containers Cloud-native, optimized
    Performance Good for Windows Fast startup Industry-leading speed
    What is the difference between .NET Framework, .NET Core and modern .NET diagram

    Common follow-up questions

    • "Should we migrate our .NET Framework app?" – Yes, if it's actively used. Modern .NET offers better performance, security, and cloud support. Plan for refactoring, especially UI code (Windows Forms works, but WPF requires careful porting).
    • "What's the difference between .NET 8 and .NET 9?" – .NET 8 is LTS (supported until Nov 2026); .NET 9 is STS (18 months). Pick LTS for production stability, STS if you want cutting-edge features.
    • "Can .NET Framework and modern .NET coexist?" – Yes, on the same machine. They're separate runtimes. Legacy services can stay on Framework while new microservices run on modern .NET.

    Related interview questions

    Further reading

    What is the difference between .NET Framework, .NET Core and modern .NET?
    .NETCsharp
  3. 03What is the CLR?

    Beginner

    The Common Language Runtime (CLR) is the virtual machine that executes all .NET code. It provides essential services like Just-In-Time (JIT) compilation, automatic memory management through garbage collection, type safety, exception handling, and security—all abstracted away from the developer. Without the CLR, you would need to manage these concerns manually, similar to writing in C++.

    How it works

    When you write C# code and compile it, the compiler doesn't produce machine code directly. Instead, it produces Intermediate Language (IL) bytecode that's platform-agnostic. This IL is stored in assemblies (.dll or .exe files) along with metadata describing types, methods, and dependencies.

    When your .NET application runs, the CLR takes over. It reads the IL from your assembly and uses the JIT compiler to translate it into native machine code specific to the processor architecture you're running on. This happens on-demand: the first time a method is called, it's compiled; subsequent calls use the cached compiled version.

    The CLR also manages memory through the garbage collector, which automatically tracks object references and frees memory that's no longer in use. It enforces type safety at runtime, ensuring you can't cast an object to an incompatible type without validation. It provides a unified exception handling model and a security framework that controls what code can do, such as file access or network operations.

    The CLR operates on a managed memory model where objects live on the heap and primitives or references live on the stack. This abstraction lets developers write code without worrying about memory corruption, pointer arithmetic, or manual cleanup—major sources of bugs in unmanaged languages.

    Example

    csharp
    class Program
    {
        static void Main()
        {
            var person = new Person { Name = "Alice", Age = 30 };
            Console.WriteLine($"{person.Name} is {person.Age}");
            // CLR automatically manages memory; no delete needed
        }
    }
    
    class Person
    {
        public string Name { get; set; }
        public int Age { get; set; }
    }

    When this code runs, the CLR JIT-compiles the Main method, allocates the Person object on the heap, and automatically frees its memory when it's no longer referenced—all without explicit code.

    Key points to mention

    • The CLR is a virtual machine that sits between your code and the operating system, providing platform independence.
    • It performs JIT compilation: converting IL to native machine code at runtime, enabling optimizations based on actual usage patterns.
    • Automatic garbage collection eliminates manual memory management and prevents memory leaks (though IDisposable is still used for unmanaged resources).
    • Type safety and exception handling are enforced by the CLR, catching errors at runtime rather than causing undefined behavior.
    • The CLR enables the "write once, run anywhere" philosophy: your IL works across Windows, Linux, and macOS with the appropriate .NET runtime installed.

    Common follow-up questions

    • What's the difference between the CLR and the .NET Framework? The CLR is the runtime engine; .NET Framework is a larger ecosystem including the CLR, class libraries, and tools.
    • Does .NET Core have a CLR? Yes. .NET Core (now just ".NET" in versions 5+) includes its own CLR optimized for cross-platform performance and cloud scenarios.
    • How does the CLR handle different processor architectures? The JIT compiler generates native code specific to the current processor at runtime, so the same IL works on ARM, x86, and x64 without recompilation.

    Related interview questions

    Further reading

    .NETCsharpPerformanceSecurity
  4. 04What is IL (Intermediate Language)?

    Beginner

    IL (Intermediate Language), also called MSIL or CIL, is a platform-agnostic instruction set that C# code compiles into. It sits between human-readable C# and machine code, allowing .NET programs to run across different operating systems and architectures. At runtime, the JIT compiler translates IL into native machine code specific to the current platform.

    How it works

    When you compile a C# project, the C# compiler doesn't produce native machine code directly. Instead, it produces IL instructions that are stored inside .NET assemblies (DLL or EXE files). These IL instructions are human-readable yet hardware-independent, similar to bytecode in Java.

    During runtime, the CLR's Just-In-Time (JIT) compiler reads the IL and converts it on-demand into native machine code that the CPU can execute. This happens method-by-method: the first time a method is called, its IL is compiled to native code and cached. Subsequent calls use the cached native code, avoiding recompilation.

    This two-stage compilation model gives .NET several advantages. First, it enables platform independence—the same IL assembly runs on Windows, Linux, and macOS because each platform has its own JIT compiler. Second, it allows for runtime optimizations—the JIT can apply optimizations based on actual runtime behavior and CPU architecture. Third, it enables security and verification—IL can be inspected and validated before execution.

    Example

    csharp
    public class Calculator
    {
        public int Add(int a, int b)
        {
            return a + b;
        }
    }
    
    var calc = new Calculator();
    int result = calc.Add(5, 3);

    When you compile this C#, the Add method is converted to IL instructions (such as ldarg.0, ldarg.1, add, ret). When the program runs and calls Add(5, 3), the JIT compiler translates those IL instructions into native x86 or ARM machine code that executes on your specific CPU.

    Key points to mention

    • Platform independence: IL is the same across Windows, Linux, and macOS; only the JIT compiler is platform-specific.
    • JIT compilation: IL is compiled to native code at runtime by the CLR, not ahead of time (though Native AOT and ReadyToRun are alternatives).
    • Verifiable and secure: IL can be inspected for type safety and security violations before execution.
    • Stored in assemblies: IL lives in DLL and EXE files alongside metadata about types, methods, and resources.
    • Performance trade-off: JIT compilation adds startup latency but allows runtime optimizations that can exceed pre-compiled code performance.

    Common follow-up questions

    • What is the CLR (Common Language Runtime)?
    • Can I see the IL that my C# code compiles to?
    • How does IL differ from native code compilation in C++?

    Related interview questions

    .NETCsharpPerformance
  5. 05What is JIT compilation?

    Beginner

    JIT (Just-In-Time) compilation is the process where the .NET runtime converts Intermediate Language (IL) code into native machine code at runtime, just before a method is executed for the first time. This allows .NET to be both platform-independent (IL runs anywhere) and performance-optimized (native code is fast). Modern .NET uses tiered compilation, which starts with quick compilation and later recompiles frequently-used methods with aggressive optimizations.

    What is JIT compilation diagram

    How it works

    When you write C# and compile it, the compiler produces IL (Intermediate Language), not native machine code. This IL is stored in your assembly. When your program runs and calls a method for the first time, the JIT compiler kicks in:

    1. The runtime detects the method hasn't been compiled yet.
    2. The JIT compiler reads the IL for that method.
    3. It compiles the IL into native code optimized for the current CPU architecture.
    4. The native code is cached in memory.
    5. Future calls to that method execute the cached native code directly—no recompilation.

    In modern .NET, tiered compilation makes this even smarter. Tier 0 (quick JIT) compiles with minimal optimization to get the method running fast. If the method is called frequently, Tier 1 (optimizing JIT) recompiles it with full optimizations like inlining and loop unrolling. This balance means your app starts quickly but still gets peak performance for hot code paths.

    Example

    csharp
    public class Program
    {
        public static void Main()
        {
            // First call: JIT compiles Calculate to native code
            int result1 = Calculate(5);
            Console.WriteLine($"Result: {result1}");
    
            // Subsequent calls: use cached native code
            int result2 = Calculate(10);
            Console.WriteLine($"Result: {result2}");
        }
    
        public static int Calculate(int x)
        {
            // On first invocation, IL here is converted to native machine code
            return x * x + x;
        }
    }
    

    The first call to Calculate(5) triggers JIT compilation; the runtime converts its IL to native code. The second call to Calculate(10) reuses that already-compiled native code without recompilation.

    Key points to mention

    • Startup delay: JIT compilation adds a small delay the first time each method runs (warm-up time), but this is typically negligible for modern applications.
    • Platform independence: Because compilation happens at runtime, the same IL works on Windows, Linux, and macOS—the JIT produces platform-specific native code automatically.
    • Optimization opportunities: The JIT can optimize based on actual runtime data—it knows which CPU you're on, actual data types flowing through methods, and call patterns, enabling smarter optimizations than static ahead-of-time compilers.
    • Tiered compilation: .NET 5+ defaults to tiered compilation, balancing startup speed (Tier 0) with peak performance (Tier 1) automatically.
    • Caching: Compiled native code is cached in memory for the lifetime of the process; it's never re-JIT'd unless explicitly requested by the runtime.

    Common follow-up questions

    • Can we pre-compile IL to native code? Yes—ReadyToRun (R2R) and NativeAOT allow ahead-of-time compilation to eliminate JIT overhead and improve startup time.
    • What's the performance difference between Tier 0 and Tier 1? Tier 0 compiles very quickly (sometimes in microseconds) but produces less-optimized code. Tier 1 takes longer to compile but produces code that can be 10–30% faster for compute-intensive workloads.
    • How does JIT affect cold startup? In cold applications, you pay JIT cost for every method called during startup. For typical web services, this is under 100ms; for CLI tools, it can be noticeable. This is why NativeAOT is gaining adoption for trimmed, fast-startup scenarios.

    Related interview questions

    Further reading

    What is JIT compilation?
    .NETCsharpPerformance
  6. 06What is an assembly in .NET?

    Beginner

    An assembly is the compiled unit of deployment in .NET, typically a .dll (dynamic link library) or .exe (executable) file. It contains Intermediate Language (IL) code, metadata describing all types and members, and a manifest that specifies the assembly's version, name, and dependencies on other assemblies.

    How it works

    When you compile a C# project, the compiler translates your source code into IL—a platform-independent intermediate representation. This IL, along with rich metadata about classes, methods, properties, and other type information, is packaged into an assembly file.

    The assembly also includes a manifest—a metadata table that describes the assembly itself: its identity (name, version, culture), the types it exports, and which other assemblies it depends on. When the .NET runtime loads an assembly, it reads the manifest to understand what's inside and how to resolve dependencies.

    Assemblies are the unit of versioning, security, and deployment. You can share assemblies across applications, place them in the Global Assembly Cache (in .NET Framework), or bundle them with your application. Each assembly is independently versionable, so you can update one assembly without recompiling the entire application.

    The runtime uses a process called Just-In-Time (JIT) compilation to convert IL code to native machine code when the assembly executes. This allows the same IL assembly to run on different platforms (Windows, Linux, macOS) as long as the appropriate .NET runtime is installed.

    Example

    csharp
    // MathLibrary.csproj – a class library assembly
    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <TargetFramework>net8.0</TargetFramework>
        <AssemblyName>MathLibrary</AssemblyName>
        <Version>1.0.0</Version>
      </PropertyGroup>
    </Project>
    
    // MathLibrary.cs – code in the assembly
    namespace MathLibrary
    {
        public class Calculator
        {
            public int Add(int a, int b) => a + b;
        }
    }
    
    // Program.cs – consumer project
    using MathLibrary;
    
    var calc = new Calculator();
    Console.WriteLine(calc.Add(5, 3)); // 8
    

    When you build MathLibrary, the compiler produces MathLibrary.dll—the assembly. The manifest inside records that it's version 1.0.0, contains the Calculator type, and depends on System.Runtime. Your Program.cs project references this assembly; at runtime, the .NET loader reads the manifest and executes the IL code.

    Key points to mention

    • Assemblies are files (.dll or .exe) containing IL code and metadata; they're the output of compilation.
    • The manifest is metadata inside the assembly that describes the assembly's identity, exported types, and dependencies—it's how the runtime resolves what to load.
    • Assemblies are independently versioned and deployed; a single project can reference assemblies of different versions.
    • IL is platform-independent; the JIT compiler converts it to native code at runtime, enabling cross-platform execution.
    • The Base Class Library (BCL) itself is distributed as a set of assemblies that the runtime loads automatically.

    Common follow-up questions

    • What is the difference between a private assembly and a shared assembly?
    • What is the Global Assembly Cache (GAC), and why is it rarely used in modern .NET?
    • How does the runtime resolve assembly dependencies, and what is assembly binding?

    Related interview questions

    .NETCsharpDevOps
  7. 07What is the Base Class Library (BCL)?

    Beginner

    The Base Class Library (BCL) is the standard library that ships with .NET, providing fundamental types and functionality for building applications. It includes namespaces like System, System.Collections, System.IO, System.Net, and System.Text, among hundreds of others. Every .NET application relies on the BCL to perform core operations such as file I/O, string manipulation, threading, networking, and data structures.

    How it works

    The BCL is a collection of managed assemblies (compiled code) that are part of the .NET runtime distribution. When you install .NET, the BCL assemblies are installed alongside it, making them immediately available to any application you build. You access BCL functionality by referencing namespaces in your code—for example, using System; or using System.Collections.Generic;—and then calling the types and methods defined in those namespaces.

    The BCL is organized hierarchically by namespace, grouping related functionality together. For instance, System.IO contains file and directory operations, System.Net handles networking, System.Collections.Generic provides type-safe collections, and System.Text.Json handles JSON serialization. These namespaces wrap the underlying operating system capabilities and provide a consistent, object-oriented interface that works across Windows, Linux, and macOS.

    The BCL also includes modern features like LINQ, which allows you to query collections with a SQL-like syntax, and System.Text.Json for parsing and generating JSON without external dependencies. The BCL is continuously updated with each .NET release, adding new types, methods, and performance improvements while maintaining backward compatibility.

    Example

    csharp
    using System;
    using System.Collections.Generic;
    using System.IO;
    using System.Linq;
    using System.Text.Json;
    
    // BCL example: Collections, LINQ, File I/O, JSON
    var numbers = new List<int> { 1, 2, 3, 4, 5 };
    var evenNumbers = numbers.Where(n => n % 2 == 0).ToList();
    Console.WriteLine($"Even numbers: {string.Join(", ", evenNumbers)}");
    
    // File I/O from System.IO
    var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "data.json");
    File.WriteAllText(path, JsonSerializer.Serialize(evenNumbers));
    
    // Read it back
    var readContent = File.ReadAllText(path);
    var parsed = JsonSerializer.Deserialize<List<int>>(readContent);
    Console.WriteLine($"Parsed from file: {string.Join(", ", parsed ?? [])}");
    

    This example demonstrates BCL usage: creating a typed collection, using LINQ.Where() to filter, writing to a file with System.IO.File, serializing to JSON with System.Text.Json, and reading it back—all from the BCL with no external packages.

    Key points to mention

    • The BCL is built-in and free; no installation or NuGet packages required for basic functionality.
    • It provides cross-platform abstractions, so code written for Windows works on Linux and macOS without changes.
    • Common namespaces include System (core types), System.Collections.Generic (dictionaries, lists), System.IO (files), System.Linq (querying), System.Net (networking), and System.Text.Json (serialization).
    • The BCL is always available in every .NET project; you only need to add using statements to access it.
    • It is designed for performance and is heavily optimized; many operations delegate to the runtime or operating system.

    Common follow-up questions

    • How does the BCL differ from NuGet packages? The BCL is built-in and maintained by Microsoft; NuGet packages are third-party libraries you install separately to extend functionality.
    • What happens if I use BCL types in .NET Framework vs. .NET 8+? Some newer BCL types (like JsonSerializer) are only available in newer .NET versions; you must target the appropriate framework or use compatibility packages.
    • Can I override or extend BCL classes? Yes, many BCL classes are designed to be inherited (e.g., collections, exceptions); sealed classes cannot be extended intentionally.

    Related interview questions

    Further reading

    .NETCsharpasynclinq
  8. 08What is the .NET SDK vs the .NET runtime?

    Beginner

    The .NET SDK (Software Development Kit) contains everything you need to develop and build .NET applications, including compilers, the dotnet CLI, and project templates. The .NET runtime is a minimal package that contains only what is necessary to execute already-compiled .NET applications. Think of it this way: developers install the SDK, but end users only need the runtime.

    How it works

    When you develop a .NET application, you use the SDK, which includes the C# compiler, the dotnet command-line tool, NuGet package manager, and pre-built project templates. The SDK is a superset that enables you to write code, compile it into Intermediate Language (IL), and create assemblies.

    Once your application is compiled and ready for deployment, end users do not need the compiler or development tools. They only need the .NET runtime, which provides the Just-In-Time (JIT) compiler to convert IL to machine code at runtime, the garbage collector, the type system, and the base class library necessary to execute your application.

    For deployment scenarios, you have flexibility: you can deploy your app to machines with the runtime installed, or you can create self-contained deployments where the runtime is bundled with your application. When containerizing an ASP.NET Core app, you often use a lightweight runtime image as the base rather than the full SDK.

    Example

    csharp
    // Program.cs - requires SDK to compile
    using System;
    
    class Program
    {
        static void Main()
        {
            Console.WriteLine("Hello from .NET!");
            Console.WriteLine($"Runtime: {System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription}");
        }
    }
    

    To build this, you run dotnet build (SDK required). To run it, you execute dotnet run or ./bin/Release/net8.0/Program.exe (runtime required). End users only need the runtime to execute the compiled DLL.

    Key points to mention

    • SDK is for development: Includes compiler, CLI tools, debugger, templates, and everything needed to create applications.
    • Runtime is for execution: Minimal footprint with JIT compiler, garbage collector, and runtime libraries to execute compiled code.
    • Size difference: The SDK is significantly larger (500+ MB) compared to the runtime (50-150 MB), which matters for production deployments.
    • Separate downloads: Both are available separately from microsoft.com; you choose based on your use case (development vs. deployment).
    • Version alignment: Applications compiled with SDK version X.Y should run on runtime version X.Y or newer (generally backward compatible within a major version).

    Common follow-up questions

    • Can you run a .NET application without installing the SDK? Yes, if the runtime is installed or the application is self-contained with the runtime bundled.
    • What is the difference between SDK versions and runtime versions? The SDK is used at compile time; the runtime version is what your deployed application targets and what end-user machines need.
    • Should production servers have the SDK installed? Generally no—install only the runtime to reduce surface area and system complexity. The SDK is for development machines.

    Related interview questions

    .NETCsharp
  9. 09What does the dotnet CLI do?

    Beginner

    The dotnet CLI (Command Line Interface) is a cross-platform tool that allows developers to create, build, run, test, and publish .NET applications directly from the command line. It's the foundation for modern .NET development, eliminating the need to rely solely on Visual Studio and enabling automation in CI/CD pipelines.

    How it works

    The dotnet CLI is installed as part of the .NET SDK and provides a unified command structure for managing your entire project lifecycle. When you run a command like dotnet build, the CLI parses your project file (typically a .csproj), restores NuGet dependencies, compiles your code, and produces output artifacts. Each command follows a predictable pattern: dotnet [command] [arguments] [options].

    The CLI works with project files to understand your application's structure, dependencies, and configuration. It supports multiple project types—console applications, class libraries, ASP.NET Core web applications, and more. The CLI also integrates with the NuGet package manager, allowing you to add, update, and remove dependencies seamlessly.

    One of the most powerful aspects is its cross-platform nature. The same commands work identically on Windows, macOS, and Linux, making it ideal for DevOps teams and developers working in mixed environments. The CLI also powers many higher-level tools, including Visual Studio and Visual Studio Code, which use it behind the scenes.

    Example

    csharp
    // Program.cs - Simple console application
    Console.WriteLine("Hello from dotnet CLI!");
    
    var greeting = GetGreeting("World");
    Console.WriteLine(greeting);
    
    static string GetGreeting(string name) => $"Welcome, {name}!";
    

    To execute this, you'd use dotnet run in the terminal, which restores dependencies, compiles the code, and executes the program without manually creating build artifacts.

    Key points to mention

    • Core commands: dotnet new (creates projects), dotnet build (compiles), dotnet run (executes), dotnet test (runs unit tests), dotnet publish (creates deployment packages)
    • Dependency management: Use dotnet add package and dotnet remove package to manage NuGet packages without editing .csproj files manually
    • Project agnostic: Works with any .NET project type—console apps, libraries, web applications, and more—using the same fundamental commands
    • CI/CD integration: Perfect for automation scripts and continuous integration pipelines since it's scriptable and cross-platform
    • Restore and clean: dotnet restore downloads dependencies, while dotnet clean removes build artifacts

    Common follow-up questions

    • What's the difference between dotnet build and dotnet publish? (Build compiles for development; publish prepares for deployment)
    • How do you run integration tests for ASP.NET Core using the CLI?
    • What does dotnet restore do, and when is it necessary?

    Related interview questions

    Further reading

    .NETCsharpTestingDevOps
  10. 10What is NuGet?

    Beginner

    NuGet is the package manager for .NET that enables developers to discover, download, and manage reusable libraries and dependencies in their projects. Packages are defined in your project file and automatically restored from nuget.org (the public feed) or private feeds during the build process, making dependency management centralized and version-controlled.

    How it works

    NuGet operates as a centralized repository system. When you add a package reference to your .csproj file, NuGet resolves that package and all its transitive dependencies from configured feeds. During build time, NuGet restores these packages to a local cache (typically in ~/.nuget/packages) and makes the assemblies available to your project.

    You interact with NuGet through multiple interfaces: the dotnet CLI (e.g., dotnet add package), Visual Studio's Package Manager UI, the Package Manager Console, or by directly editing your project file. The packages.lock.json file (when enabled) ensures deterministic restores by locking exact versions across team members.

    Private NuGet feeds allow organizations to host internal packages securely, enabling code sharing across teams while maintaining intellectual property protection. Authentication can be configured per feed, supporting various credential types.

    Example

    csharp
    // After installing NuGet package "Newtonsoft.Json" via:
    // dotnet add package Newtonsoft.Json
    
    using Newtonsoft.Json;
    
    var jsonString = "{\"name\": \"Alice\", \"age\": 30}";
    var person = JsonConvert.DeserializeObject<dynamic>(jsonString);
    
    Console.WriteLine($"{person.name} is {person.age} years old");
    // Output: Alice is 30 years old

    This code uses the popular Newtonsoft.Json NuGet package to deserialize JSON without writing custom parsing logic—demonstrating how NuGet packages provide reusable functionality.

    Key points to mention

    • Central repository: nuget.org hosts millions of open-source packages; organizations can also host private feeds
    • Declarative dependency management: Package references in the .csproj file make dependencies explicit and version-controlled
    • Transitive dependency resolution: NuGet automatically downloads dependencies of your dependencies, resolving version conflicts
    • Version control: Lock files ensure reproducible builds and consistent versions across development teams
    • Package metadata: Each package includes version information, license, dependencies, and documentation for informed selection

    Common follow-up questions

    • How do you handle version conflicts when two packages require different versions of the same dependency?
    • What's the difference between a NuGet package and a local project reference?
    • How do you create and publish your own NuGet package?

    Related interview questions

    .NETCsharp
  11. 11What is a .csproj file?

    Beginner

    A .csproj file is the MSBuild project file for C# projects. It is an XML configuration file that defines how your project should be built, including the target framework, dependencies, output type, and build settings. Modern .NET projects use the compact SDK-style format rather than the verbose .NET Framework format.

    How it works

    When you build a C# project, MSBuild reads the .csproj file to understand what to compile, which NuGet packages to restore, what the target framework is, and which compilation options to apply. The file is XML-based and contains property groups, item groups, and targets that instruct MSBuild how to orchestrate the build process.

    The SDK-style format (introduced with .NET Core) automatically includes sensible defaults and globs common file patterns like *.cs files in the project directory. This means you rarely need to explicitly list every source file. The file can also reference property files, define custom build steps, and configure package metadata for NuGet publishing.

    The .csproj file works hand-in-hand with the .sln (solution) file when managing multiple projects. The solution file references one or more .csproj files, enabling you to build and manage them as a cohesive unit.

    Example

    csharp
    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <TargetFramework>net8.0</TargetFramework>
        <OutputType>Exe</OutputType>
        <LangVersion>latest</LangVersion>
        <Nullable>enable</Nullable>
      </PropertyGroup>
    
      <ItemGroup>
        <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
      </ItemGroup>
    </Project>

    This minimal .csproj declares a console application targeting .NET 8.0 with nullable reference types enabled and a dependency on Newtonsoft.Json; MSBuild automatically discovers all .cs files in the directory and compiles them.

    Key points to mention

    • PropertyGroup: Contains metadata like TargetFramework, OutputType (Exe, Library), and compiler settings such as Nullable and LangVersion.
    • ItemGroup: Lists package references (NuGet dependencies), project references, and other files to include in the build.
    • SDK-style format: The modern, concise format used by .NET 5+; older .NET Framework projects used verbose formats that explicitly listed every file.
    • Automatic file discovery: SDK projects automatically include all .cs files in the directory tree without explicit declarations.
    • TargetFramework: Specifies which .NET runtime your project targets (e.g., net8.0, net48, net6.0-windows).

    Common follow-up questions

    • What is the difference between a .csproj file and a .sln file? A .csproj file defines a single project; a .sln file is a container that references multiple .csproj files to organize them into a solution.
    • How do you add a NuGet package to a .csproj file? You can either use dotnet add package from the CLI, or manually add a <PackageReference> entry to the <ItemGroup> section and restore with dotnet restore.
    • What is the .csproj.user file? It is a local, machine-specific file that stores user preferences (like debug settings or breakpoints) and is typically not checked into source control.

    Related interview questions

    .NETCsharp
  12. 12What is the difference between a value type and a reference type?

    Beginner

    Value types hold their data directly in memory and are copied entirely when assigned to another variable, meaning each copy is independent. Reference types store only a reference (pointer) to an object on the managed heap, so multiple variables can point to the same object. This fundamental distinction affects memory allocation, performance, and how changes to data are observed across your application.

    How it works

    When you declare a value type variable like int or struct, the runtime allocates memory on the stack to store the actual value. When you assign a value type to another variable, the entire data is copied, creating two independent copies. Changes to one copy do not affect the other.

    Reference types like class, string, and arrays work differently. The variable doesn't hold the actual data; it holds a reference (memory address) to an object stored on the managed heap. When you assign a reference type to another variable, you're copying the reference, not the object itself. Both variables now point to the same object in memory. Any modification through either variable affects the shared object.

    The garbage collector automatically manages heap memory for reference types, cleaning up objects that no longer have any references. Value types on the stack are automatically freed when they go out of scope, making them more efficient for small, short-lived data.

    Example

    csharp
    // Value type example
    int a = 5;
    int b = a;
    b = 10;
    Console.WriteLine($"a: {a}, b: {b}"); // Output: a: 5, b: 10
    
    // Reference type example
    var list1 = new List<int> { 1, 2, 3 };
    var list2 = list1;
    list2.Add(4);
    Console.WriteLine($"list1.Count: {list1.Count}"); // Output: list1.Count: 4
    
    // Struct (value type) example
    struct Point
    {
        public int X, Y;
    }
    
    var p1 = new Point { X = 1, Y = 2 };
    var p2 = p1;
    p2.X = 10;
    Console.WriteLine($"p1.X: {p1.X}, p2.X: {p2.X}"); // Output: p1.X: 1, p2.X: 10

    In the value type example, changing b doesn't affect a because they're independent copies. In the reference type example, list1 and list2 reference the same list, so adding to list2 is visible through list1. The struct example demonstrates that structs are value types, so copying creates independent instances.

    Key points to mention

    • Memory location: Value types live on the stack; reference types live on the managed heap.
    • Copying behavior: Value types are deep-copied on assignment; reference types share the same object.
    • Common value types: Primitives (int, double, bool), struct, and enum.
    • Common reference types: class, string, arrays, delegates, and interfaces.
    • Performance implications: Value types are generally faster for small data due to stack allocation; large value types can harm performance if copied frequently.

    Common follow-up questions

    Related interview questions

    Further reading

    .NETCsharp
  13. 13What is the difference between stack and heap memory?

    Beginner

    The stack stores method frames and local value types (like integers and structs) and is automatically cleaned up when methods return, while the heap stores reference types (objects, strings, arrays) and is managed by the garbage collector. Stack allocation is faster but limited in size, while heap allocation is flexible but requires garbage collection overhead.

    How it works

    Memory in .NET is divided into two main regions: the stack and the heap. When a method is called, a new frame is created on the stack containing space for all local variables and method parameters. Value types (like int, bool, struct) are stored directly in this stack frame. When the method returns, the entire frame is popped off the stack, and all local variables are immediately destroyed—no garbage collection needed.

    The heap, by contrast, is a large pool of memory where reference types live. When you use the new keyword to create an object, the object is allocated on the heap, and a reference (pointer) to that object is returned. This reference is typically stored on the stack (if it's a local variable) or in another object on the heap (if it's a field). Because objects can outlive the method that created them and can be referenced from multiple places, the heap cannot be cleaned up as simply as the stack. Instead, the garbage collector periodically scans the heap, identifies objects that are no longer referenced, and reclaims their memory.

    Stack memory is also more efficient—allocation and deallocation are O(1) operations that just move a pointer. Heap allocation and garbage collection introduce overhead. However, the stack is limited in size (typically a few megabytes per thread), while the heap can grow much larger.

    Example

    csharp
    public class Program
    {
        public static void Main()
        {
            int age = 25;                    // Stack: value type stored directly
            string name = "Alice";           // Stack: reference to string object
                                             // Heap: "Alice" string object stored here
            Person person = new Person();    // Stack: reference to Person object
                                             // Heap: Person object stored here
            person.Name = "Bob";
        }
    }
    
    public class Person
    {
        public string Name { get; set; }     // Field reference stored in Person object on heap
    }
    

    In this example, age (an int) is stored directly on the stack, while name and person are stack references pointing to objects on the heap. When Main returns, the stack frame is destroyed, references are lost, and the heap objects become candidates for garbage collection.

    Key points to mention

    • Stack is automatic and deterministic—memory is freed instantly when a method returns; predictable performance with no GC pauses.
    • Heap requires garbage collection—the CLR must identify unreachable objects and reclaim memory, which can cause temporary pauses.
    • Value types live on the stack (when local), while reference types live on the heap—understanding this distinction is crucial for value types vs. reference types.
    • Stack is thread-local; each thread gets its own stack, preventing race conditions for local variables.
    • Stack size is limited, making it unsuitable for large data structures; this is why collections are reference types stored on the heap.

    Common follow-up questions

    • Why are structs sometimes stored on the stack but can also end up on the heap?
    • Can I force memory to be freed immediately instead of waiting for garbage collection?
    • How do I know if my code is causing excessive garbage collection?

    Related interview questions

    Further reading

    .NETCsharpPerformance
  14. 14What is boxing and unboxing?

    Beginner

    Boxing is the process of wrapping a value type (like int, struct) in an object reference on the heap. Unboxing is extracting that value type back out. Both operations carry performance costs—heap allocation for boxing, and validation plus copying for unboxing—so modern C# encourages generics and value types to avoid them in performance-critical code paths.

    How it works

    When you assign a value type to an object variable or pass it to a method expecting object, the runtime allocates memory on the heap, copies the value there, and gives you a reference to that heap object. This is boxing. The original value type remains on the stack; the boxed copy lives on the heap until garbage collected.

    Unboxing reverses this: you extract the value back from the heap object and copy it onto the stack (or into a local variable). The runtime validates that the boxed type matches the target type—unboxing an int box into a long variable throws an InvalidCastException.

    Because value types are stored on the stack (fast, automatic cleanup) but reference types live on the heap (requiring GC), boxing trades performance for the convenience of treating all values uniformly through the object interface. This was especially common in pre-generic .NET; today, generics like List<T> eliminate most boxing scenarios.

    Example

    csharp
    // Boxing and unboxing example
    int number = 42;
    
    // Boxing: value type → object reference
    object boxed = number;  // Allocates heap memory, copies value
    
    // Unboxing: object reference → value type
    int unboxed = (int)boxed;  // Validates type, copies from heap
    Console.WriteLine(unboxed);  // 42
    
    // Common mistake: implicit boxing in collections
    ArrayList list = new ArrayList();
    list.Add(100);  // Boxing occurs here
    int value = (int)list[0];  // Unboxing occurs here
    
    // Modern approach: use generics to avoid boxing
    List<int> genericList = new List<int>();
    genericList.Add(100);  // No boxing
    int value2 = genericList[0];  // No unboxing
    

    The first section demonstrates explicit boxing (assigning int to object) and explicit unboxing (casting back). The second half shows how old non-generic collections forced boxing on every value; generics eliminate this by storing actual int instances.

    Key points to mention

    • Performance cost: Boxing allocates heap memory and copies the value; unboxing validates type and copies back. Both are expensive in tight loops.
    • Generics eliminate boxing: List<int> stores integers directly without boxing, unlike the old non-generic ArrayList.
    • Type safety on unbox: Unboxing validates that the boxed type matches exactly—you cannot unbox an int box as long without a cast that may fail.
    • Reference equality after boxing: Each boxing creates a new heap object, so two boxed copies of the same value are different objects: object a = 5; object b = 5; a == b is false.
    • Nullable value types: Boxing a nullable value type like int? boxes the underlying value if it has one, or null if it doesn't.

    Common follow-up questions

    • Why does boxing happen implicitly in some cases? When you pass a value type to a method expecting object or an interface the value type implements, the runtime automatically boxes it for you.
    • How do structs avoid boxing? Record structs and regular structs are value types, so using generics with them avoids boxing entirely compared to reference type wrappers.
    • Can you unbox to a different type? No—unboxing requires an exact type match or an exception is thrown. You must unbox to the original type or use Convert class methods instead.

    Related interview questions

    Further reading

    .NETCsharpPerformance
  15. 15What is the difference between class and struct in C#?

    Beginner

    Classes are reference types that live on the heap and support inheritance, making them ideal for complex objects. Structs are value types that live on the stack and are copied on assignment, making them best for small, immutable data structures like coordinates or currency values.

    How it works

    The fundamental difference lies in how classes and structs are stored and passed in memory. Value types like structs store their data directly on the stack, while reference types like classes store a reference on the stack that points to data on the heap. When you assign a struct to another variable or pass it as a parameter, the entire value is copied. When you assign a class, only the reference is copied—both variables point to the same object.

    Classes support inheritance, allowing you to create hierarchies and use polymorphism. Structs do not support inheritance (except from interfaces) and cannot be abstract. Classes can have null values; structs have default values for each field. Classes require explicit initialization; structs are automatically initialized with default values.

    Performance and memory characteristics differ significantly. Structs are allocated on the stack, making them faster to create and destroy, but copying large structs repeatedly can be expensive. Classes have heap allocation overhead but avoid copying when passed around. For small, simple data, structs are often more efficient; for complex, frequently-used objects, classes are typically better.

    Example

    csharp
    // Struct: value type, best for small immutable data
    public struct Point
    {
        public int X { get; set; }
        public int Y { get; set; }
        
        public Point(int x, int y) => (X, Y) = (x, y);
    }
    
    // Class: reference type, supports inheritance
    public class Person
    {
        public string Name { get; set; }
        public int Age { get; set; }
        
        public Person(string name, int age) => (Name, Age) = (name, age);
    }
    
    // Usage demonstrating value vs reference behavior
    var point1 = new Point(10, 20);
    var point2 = point1;
    point2.X = 30;
    Console.WriteLine(point1.X); // Output: 10 (struct was copied)
    
    var person1 = new Person("Alice", 30);
    var person2 = person1;
    person2.Name = "Bob";
    Console.WriteLine(person1.Name); // Output: Bob (both reference same object)
    

    This example shows how modifying a copied struct leaves the original unchanged, while modifying through a class reference affects the original because both variables point to the same object.

    Key points to mention

    • Memory allocation: Structs are stack-allocated (faster creation/destruction), classes are heap-allocated (more memory overhead but better for complex objects)
    • Value vs reference semantics: Structs are copied on assignment; classes pass references, so modifications affect the original
    • Inheritance: Classes support inheritance and polymorphism; structs only implement interfaces
    • Nullability: Class variables can be null; struct variables have default values and cannot be null (without nullable reference types)
    • Use cases: Use structs for small, immutable data (Point, Size, Color); use classes for complex, mutable objects with shared state (Person, Customer, Service)

    Common follow-up questions

    • When would you choose a struct over a class, and when would you choose a class?
    • What is a record, and how does it differ from a class or struct? (See record class vs record struct)
    • What happens when you box a struct, and why is it important to know?

    Related interview questions

    Further reading

    .NETCsharp
  16. 16What is a record in C#?

    Beginner

    A record is a C# type that provides value-based equality, automatic ToString generation, and non-destructive mutation through the with keyword. Introduced in C# 9, records are reference types (or value types if declared as record struct) designed primarily for immutable data transfer objects, events, and domain objects where structural equality matters more than reference equality.

    How it works

    Records automatically generate several members for you:

    • Value-based equality: Two records are equal if all their properties have the same values, not because they reference the same object in memory. The compiler generates Equals, GetHashCode, and equality operators.
    • ToString override: Records automatically generate a readable ToString that displays the type name and all properties, making debugging easier.
    • Copy-with-mutation: The with expression creates a shallow copy of a record with specified properties changed. This is non-destructive—the original record remains unchanged.
    • Positional parameters: Records support positional syntax in declarations, creating properties and a primary constructor automatically.

    Records are immutable by convention, though not enforced by the compiler unless you explicitly use init-only properties. This makes them safe for multi-threaded scenarios and reliable as dictionary keys.

    Example

    csharp
    // Positional record—generates properties, constructor, and equality members
    public record Person(string Name, int Age);
    
    // Usage
    var person1 = new Person("Alice", 30);
    var person2 = new Person("Alice", 30);
    
    // Value-based equality
    Console.WriteLine(person1 == person2); // true
    
    // Automatic ToString
    Console.WriteLine(person1); // Person { Name = Alice, Age = 30 }
    
    // Non-destructive mutation with 'with'
    var person3 = person1 with { Age = 31 };
    Console.WriteLine(person3); // Person { Name = Alice, Age = 31 }
    Console.WriteLine(person1.Age); // 30 (original unchanged)

    This example demonstrates how a record makes data-carrying types concise: a single line replaces dozens of lines of boilerplate code for equality and ToString, and the with expression elegantly handles immutable updates.

    Key points to mention

    • Records are reference types by default—they inherit from object, not struct. Use record struct for value semantics.
    • Equality is based on property values, not reference identity, making records suitable as dictionary keys and safe for comparison.
    • Records are immutable by design convention—use init-only properties to enforce compile-time immutability and prevent accidental mutation.
    • Perfect for DTOs, events, and command/query objects where you need lightweight, immutable data containers without ceremony.
    • The with keyword provides non-destructive mutation, enabling functional-style programming patterns in C#.

    Common follow-up questions

    • What is the difference between record class and record struct?
    • How does record equality differ from class equality, and why should I use init-only properties?
    • Can records inherit from other records or classes? (Yes—records can inherit from other records; inheritance chains work, but be cautious with equality semantics.)

    Related interview questions

    Further reading

    .NETCsharpArchitecture
  17. 17What is the difference between record class and record struct?

    Beginner

    A record class is a reference type stored on the heap with value-based equality, while a record struct is a value type stored on the stack with the same conveniences. Both provide positional deconstruction, immutability support, and compiler-generated equality, but they differ in memory allocation and performance characteristics. The choice depends on whether you need reference semantics (class) or value semantics (struct).

    How it works

    Records were introduced in C# 9 to simplify immutable data types. Both record class and record struct automatically generate constructors, equality methods (Equals, GetHashCode, ==, !=), and a ToString method based on the declared properties.

    A record class is a reference type that inherits from System.Object. Two instances are equal if their property values are equal, even if they occupy different memory locations. It supports inheritance and polymorphism.

    A record struct is a value type that lives on the stack (unless boxed). Two instances are equal based on value comparison. Record structs are useful for small, immutable data structures where avoiding heap allocation improves performance. Unlike regular structs, record structs support the with keyword for non-destructive mutation and cleaner copying.

    Example

    csharp
    // Record class (reference type)
    public record Person(string Name, int Age);
    
    // Record struct (value type)
    public record struct Point(double X, double Y);
    
    var person1 = new Person("Alice", 30);
    var person2 = new Person("Alice", 30);
    Console.WriteLine(person1 == person2); // True (value equality)
    
    var point1 = new Point(1.0, 2.0);
    var point2 = new Point(1.0, 2.0);
    Console.WriteLine(point1 == point2); // True (value equality)
    
    // Both support 'with' for creating modified copies
    var person3 = person1 with { Age = 31 };
    var point3 = point1 with { X = 2.0 };
    

    This demonstrates that both record class and record struct support value-based equality and the convenient with keyword for immutable updates, regardless of their storage location.

    Key points to mention

    • Memory allocation: Record classes allocate on the heap (reference type); record structs allocate on the stack (value type), making structs faster for small data but requiring care with boxing.
    • Equality semantics: Both use structural (value-based) equality by default, not reference equality like mutable classes.
    • Immutability: Both support init-only properties and the with keyword for clean, immutable patterns.
    • Inheritance: Record classes support inheritance; record structs do not (structs cannot inherit from other structs in C#).
    • Performance: Record structs avoid heap allocations and garbage collection pressure, making them ideal for high-frequency data structures like coordinates or measurements.

    Common follow-up questions

    • Can a record struct be mutable? Yes, record structs can have settable properties, but this goes against the intent; records are designed for immutability. Mutable value types can cause subtle bugs.
    • Should I always use record struct instead of record class for performance? No—record structs have trade-offs. Stack allocation is faster, but copying a struct (parameter passing, return values) copies all data. For large types or when you need reference semantics, use record class.
    • How does a record struct differ from a regular struct? Record structs automatically generate equality methods and the with keyword support; regular structs require manual implementation or inherit broken default equality from ValueType.

    Related interview questions

    Further reading

    .NETCsharpPerformance
  18. 18What are the access modifiers in C#?

    Beginner

    C# has six access modifiers—public, private, protected, internal, protected internal, and private protected—that control the visibility and accessibility of classes, methods, properties, and fields. They define which code can access a member, from anywhere in your application down to only within the declaring class or assembly. The best practice is to use the most restrictive modifier that still allows your code to function correctly.

    How it works

    Access modifiers restrict visibility by defining scope boundaries. Public members are accessible from anywhere, making them part of your public API. Private members (the default for class members) are accessible only within the declaring class, hiding implementation details. Protected members are accessible within the class and any derived classes, useful for inheritance hierarchies. Internal members are accessible anywhere within the same assembly, allowing internal collaboration without exposing to external consumers. Protected internal (union) allows access within the same assembly or from derived classes in other assemblies. Private protected (intersection) is the most restrictive combination, allowing access only within the same class or derived classes in the same assembly.

    The key principle is encapsulation: expose only what clients need, and hide everything else. This protects your implementation from unintended misuse and makes refactoring safer.

    Example

    csharp
    public class BankAccount
    {
        private decimal _balance;  // Only accessible within this class
        
        protected string AccountNumber { get; set; }  // Accessible in derived classes
        
        public decimal GetBalance()  // Part of the public API
        {
            return _balance;
        }
        
        internal void LogTransaction(string details)  // Used within the assembly
        {
            Console.WriteLine($"Transaction: {details}");
        }
        
        private void ValidatePin(string pin)  // Hidden implementation detail
        {
            // Validation logic
        }
        
        protected internal decimal ApplyInterest(decimal rate)
        {
            return _balance *= (1 + rate);  // Accessible across assemblies or in derived classes
        }
        
        private protected void ResetInternalState()  // Only in this class or derived classes in same assembly
        {
            _balance = 0;
        }
    }
    

    This example demonstrates how different modifiers create layers of visibility: private hides the balance and validation, protected and internal control inheritance and assembly access, and public defines the safe interface for external code.

    Key points to mention

    • The default access modifier for class members is private; for top-level types it is internal—always be explicit to avoid surprises.
    • Use public sparingly for your public API; prefer private or protected for internal implementation details to reduce coupling.
    • Protected is for inheritance scenarios; use it when derived classes need to override or access behavior, such as in template method patterns.
    • Internal is ideal for multi-project solutions where you want code sharing within your own assemblies but not external consumers.
    • Protected internal and private protected are advanced modifiers; use them when you need precise control over inheritance and assembly boundaries.
    • Access modifiers apply to classes, structs, interfaces, methods, properties, and fields, and even nested types.

    Common follow-up questions

    • What is the default access modifier in C#? — For class members, it is private; for top-level types (classes, interfaces, structs), it is internal.
    • Why should I avoid making everything public? — Public members become part of your contract, making them hard to change later. Private members are implementation details you can refactor freely without breaking clients.
    • When would I use protected internal vs. private protected? — Use protected internal (union) when derived classes or assembly-internal code need access; use private protected (intersection) only when both conditions must be true—rarely needed in practice.

    Related interview questions

    Further reading

    .NETCsharpSecurity
  19. 19What is the difference between const and readonly?

    Beginner

    const is a compile-time constant whose value is baked directly into every place it's referenced, while readonly is a field that can only be assigned once—either at declaration or in the constructor—and its value is determined at runtime. Use const for true constants that never change across versions; use readonly for values that should be immutable but may vary per instance or need runtime initialization.

    How it works

    When you declare a const field, the C# compiler replaces every reference to that constant with its literal value during compilation. This means the value is "baked in" to the calling assembly. If you later change the constant value and recompile only the library—but not the calling code—callers will still see the old value until they recompile.

    readonly fields, by contrast, are resolved at runtime. The field can be assigned a value during initialization (at declaration) or inside any constructor, but nowhere else. Once assigned, it cannot be modified. Because the value is looked up at runtime rather than baked in, changing a readonly field's value and recompiling will immediately affect all callers without them needing to recompile.

    const is implicitly static and must be assigned a compile-time constant expression (a literal or another constant). readonly can be instance-level or static, and can be assigned the result of a method call or any expression that runs at initialization time.

    Example

    csharp
    public class Configuration
    {
        // Compile-time constant: value is baked into calling code
        public const int MaxRetries = 3;
        
        // Runtime constant: value is resolved when accessed
        public readonly string AppVersion;
        public readonly DateTime StartupTime;
        
        public Configuration(string version)
        {
            AppVersion = version;  // Can only assign once
            StartupTime = DateTime.UtcNow;  // Runtime value
        }
    }
    
    // Usage
    var config = new Configuration("1.0.0");
    Console.WriteLine(Configuration.MaxRetries);  // 3 (compiled into IL)
    Console.WriteLine(config.AppVersion);         // "1.0.0" (runtime lookup)
    Console.WriteLine(config.StartupTime);        // Current UTC time
    

    The MaxRetries constant is compiled into the calling code's IL, while AppVersion and StartupTime are set once during construction and cannot change afterward.

    Key points to mention

    • const is implicitly static, so there is only one value shared across all instances; readonly can be instance-level or static.
    • const must be assigned a compile-time constant expression; readonly can be assigned at runtime (in a constructor or initializer).
    • Changing a const value requires recompiling all dependent code; changing readonly only requires recompiling the assembly that defines it.
    • const applies only to primitives and strings; readonly can hold any type, including reference types (though the reference itself becomes immutable, not the object it points to).
    • readonly is the safer default for most scenarios because it provides versioning safety and allows runtime flexibility.

    Common follow-up questions

    • Can you make a readonly reference type truly immutable? The field reference is immutable, but the object it points to can still be modified. To prevent that, use an immutable collection or readonly struct.
    • What happens if a library changes a const value? Callers see the old value until they recompile, which can cause version mismatch bugs. This is why readonly is preferred for public constants.
    • Can you use readonly with properties? Yes, modern C# allows readonly properties and init accessors for similar immutability semantics.

    Related interview questions

    Further reading

    .NETCsharp
  20. 20What is a static class?

    Beginner

    A static class is a sealed class that cannot be instantiated and contains only static members (methods, properties, and fields). It serves as a container for utility functions and extension methods, and is loaded into memory only once by the CLR when first accessed.

    How it works

    When you declare a class as static, the compiler enforces several rules: no instance can be created (the constructor is private by default), all members must be static, and the class is implicitly sealed and abstract. This means you cannot inherit from a static class. Static classes are ideal for grouping related functionality that doesn't require object state—think of them as organized collections of utility functions.

    The CLR loads a static class the first time any of its members are accessed, initializing static fields and running the static constructor if one exists. All subsequent calls to static members reference the same data in memory, making static classes efficient for shared utilities.

    Static classes are commonly used in the .NET Framework itself—examples include Math, String, File, and Console. They're also the natural home for extension methods, which must be declared in static classes in C#.

    Example

    csharp
    public static class StringHelper
    {
        public static bool IsValidEmail(string email)
        {
            return email != null && email.Contains("@");
        }
    
        public static string Reverse(string input)
        {
            return new string(input.Reverse().ToArray());
        }
    }
    
    // Usage
    class Program
    {
        static void Main()
        {
            string email = "user@example.com";
            if (StringHelper.IsValidEmail(email))
            {
                Console.WriteLine("Valid email");
            }
    
            string reversed = StringHelper.Reverse("Hello");
            Console.WriteLine(reversed); // "olleH"
        }
    }
    

    This example shows a static utility class that provides string validation and manipulation methods. All members are accessed directly on the class name without creating an instance.

    Key points to mention

    • No instantiation: You cannot use new to create an instance; the compiler will throw an error.
    • All members must be static: Instance members, instance constructors (other than the private static constructor), and instance fields are not allowed.
    • Sealed and abstract: A static class cannot be inherited from, making it impossible to extend its behavior through inheritance.
    • Memory efficiency: Static members are loaded once and shared across the entire application lifetime, reducing memory overhead compared to creating multiple instances.
    • Thread-safe initialization: The CLR guarantees that static constructors run only once and are thread-safe, making static classes reliable for singleton-like patterns.

    Common follow-up questions

    • How do static classes differ from the Singleton pattern? (Static classes are simpler and thread-safe by default, while Singleton requires manual implementation and instantiation.)
    • Can you use a static class as a base class? (No, static classes are implicitly sealed.)
    • What is the difference between a static class and a regular class with all static members? (A static class is explicitly sealed by the compiler and cannot be instantiated, while a regular class with static members can still be instantiated, though it would be wasteful.)

    Related interview questions

    Further reading

    .NETCsharp
  21. 21What is a constructor?

    Beginner

    A constructor is a special method that runs automatically when you create an instance of a class or struct, allowing you to initialize the object's state. C# supports several constructor types: parameterless (default), parameterized, static, and primary constructors, each serving different initialization needs.

    How it works

    When you use the new keyword to instantiate a class or struct, the runtime automatically invokes the constructor before the object is returned to you. Constructors have the same name as the class and no return type (not even void). They initialize fields, properties, and perform any setup logic the object needs to function correctly.

    Constructors are useful for enforcing invariants—ensuring that an object never exists in an invalid state. For example, you might require certain fields to be set or validated before the object is ready to use. If you don't define a constructor, C# provides a default parameterless constructor, but defining your own gives you control over initialization.

    You can have multiple constructors with different parameters (constructor overloading). Constructors can also call other constructors using the this() keyword, allowing you to reuse initialization logic and reduce code duplication.

    Example

    csharp
    public class Person
    {
        public string Name { get; set; }
        public int Age { get; set; }
    
        // Parameterless constructor
        public Person()
        {
            Name = "Unknown";
            Age = 0;
        }
    
        // Parameterized constructor
        public Person(string name, int age)
        {
            Name = name;
            Age = age;
        }
    
        // Constructor calling another constructor
        public Person(string name) : this(name, 0)
        {
        }
    
        // Static constructor (runs once for the class)
        static Person()
        {
            Console.WriteLine("Person class initialized");
        }
    }
    
    // Usage
    Person p1 = new Person();  // Uses parameterless constructor
    Person p2 = new Person("Alice", 30);  // Uses parameterized constructor
    Person p3 = new Person("Bob");  // Calls parameterized with default age

    This example demonstrates a class with multiple constructors: a default one, a parameterized one, one that chains to another constructor, and a static constructor that initializes class-level data.

    Key points to mention

    • Constructors must have the same name as the class and no return type
    • You can overload constructors with different parameter lists
    • Use this() to call another constructor in the same class (constructor chaining)
    • Static constructors run once before any instance is created and cannot have parameters
    • Classes and structs both support constructors, though structs have special rules for parameterless constructors

    Common follow-up questions

    • What is the difference between a constructor and a finalizer/destructor?
    • Can a constructor be private, and if so, why would you do that?
    • What happens if you don't define a constructor?

    Related interview questions

    Further reading

    .NETCsharp
  22. 22What is a primary constructor in C# 12?

    Beginner

    A primary constructor is a C# 12 feature that allows you to declare constructor parameters directly in the class declaration. These parameters are automatically available as fields throughout the entire class, eliminating the need for manual field declarations and assignments. This reduces boilerplate code, particularly for dependency injection scenarios.

    How it works

    In C# 12, you can append parameter declarations directly to the class name using parentheses, similar to how method signatures work. The parameters become implicitly captured and accessible throughout the class scope without requiring explicit backing fields or properties. The compiler generates the constructor logic automatically.

    When you declare a primary constructor parameter, it has class scope and can be used in field initializers, property initializers, and all instance methods. The parameters are read-only by default, ensuring immutability and thread safety. If you need custom constructor logic beyond parameter capture, you can still define an explicit constructor body using a colon syntax.

    Primary constructors work seamlessly with inheritance, record types, and both reference and value types. They integrate naturally with nullable reference types and property initialization patterns, making them ideal for dependency injection, data carrier classes, and services that depend on external components.

    Example

    csharp
    // Without primary constructor (traditional approach)
    public class OrderService
    {
        private readonly IOrderRepository _repository;
        private readonly ILogger<OrderService> _logger;
    
        public OrderService(IOrderRepository repository, ILogger<OrderService> logger)
        {
            _repository = repository;
            _logger = logger;
        }
    
        public async Task<Order?> GetOrderAsync(int id)
        {
            _logger.LogInformation("Fetching order {OrderId}", id);
            return await _repository.GetByIdAsync(id);
        }
    }
    
    // With primary constructor (C# 12)
    public class OrderService(IOrderRepository repository, ILogger<OrderService> logger)
    {
        public async Task<Order?> GetOrderAsync(int id)
        {
            logger.LogInformation("Fetching order {OrderId}", id);
            return await _repository.GetByIdAsync(id);
        }
    }
    

    In the primary constructor version, the repository and logger parameters are automatically available throughout the class without declaring backing fields or assignment statements.

    Key points to mention

    • Reduced boilerplate: Primary constructors eliminate repetitive field declarations and assignments, making code more concise, especially for dependency injection scenarios.
    • Class-scoped parameters: Parameters are implicitly available throughout the entire class, including in property initializers, field initializers, and all methods.
    • Read-only by default: Primary constructor parameters are read-only, promoting immutability and thread safety without additional effort.
    • Inheritance support: Derived classes must explicitly call the base class primary constructor, maintaining clear dependency flow in inheritance hierarchies.
    • Optional explicit constructor body: You can still add custom constructor logic using explicit constructor syntax if needed beyond parameter capture.

    Common follow-up questions

    • Can primary constructors have default values? Yes, you can use default parameter values in primary constructors just like regular method parameters, but this pattern is less common since most frameworks expect all dependencies to be explicitly provided.
    • How do primary constructors interact with inheritance? Derived classes must explicitly invoke the base class primary constructor using the base() syntax; parameters are not inherited automatically.
    • Are primary constructor parameters the same as properties? No; they are implicitly captured as class-scoped variables. If you need public access, you must explicitly declare properties in addition to the primary constructor.

    Related interview questions

    Further reading

    .NETCsharpSecuritydependency-injection
  23. 23What are properties in C#?

    Beginner

    Properties are members of a class that expose controlled access to data through getter and setter accessors. They provide a clean, object-oriented way to read and write values while maintaining encapsulation. Auto-properties simplify syntax by automatically generating backing fields, and the init accessor allows values to be set only during object initialization, supporting immutability patterns.

    How it works

    Properties bridge the gap between fields and methods. When you access a property, you're actually calling methods (get and set accessors) behind the scenes, but the syntax looks like direct field access. This allows you to add validation, logging, or other logic without changing the public interface.

    A property has two main accessors: get returns a value and set assigns a value. You can control access levels independently—for example, a public getter with a private setter. Auto-properties (introduced in C# 3.0) automatically create a hidden backing field, eliminating boilerplate code. The init accessor (C# 9.0+) restricts assignment to the initialization phase, enabling immutable object patterns. Properties are part of encapsulation, a core principle of object-oriented design.

    Example

    csharp
    public class Person
    {
        // Auto-property with private setter
        public string Name { get; private set; }
        
        // Full property with validation
        private int _age;
        public int Age
        {
            get { return _age; }
            set { _age = value >= 0 ? value : throw new ArgumentException("Age cannot be negative"); }
        }
        
        // Init-only property (immutable after construction)
        public string Email { get; init; }
        
        public Person(string name, string email)
        {
            Name = name;
            Email = email;
        }
    }
    
    // Usage
    var person = new Person("Alice", "alice@example.com") { Email = "newemail@example.com" };
    // person.Email = "another@example.com"; // Error: init-only property cannot be set
    person.Age = 25; // Valid
    // person.Age = -5; // Error: throws ArgumentException
    

    This example demonstrates three property patterns: an auto-property with a private setter for controlled initialization, a full property with validation logic in the setter, and an init-only property for immutable data after construction.

    Key points to mention

    • Properties provide encapsulation by allowing you to control read/write access and add logic without exposing internal implementation details.
    • Auto-properties simplify syntax when no special logic is needed; the compiler generates a private backing field automatically.
    • get and set can have different access levels (e.g., public get, private set) to enforce controlled mutation.
    • The init accessor (C# 9+) enables immutability by allowing assignment only during object initialization, supporting modern patterns like immutable records.
    • Properties are different from fields; properties are computed/controlled access points, while fields are raw data storage.

    Common follow-up questions

    • When would you use a property versus a field? Always prefer properties for public members because they provide encapsulation and allow future validation without breaking the API.
    • Can a property be abstract or virtual? Yes, properties can be declared as abstract or virtual in base classes to force or allow derived classes to override their behavior.
    • What is the performance difference between properties and fields? Modern .NET compilers optimize auto-properties to near-field performance; any difference is negligible in real applications.

    Related interview questions

    Further reading

    .NETCsharp
  24. 24What is the difference between a field and a property?

    Beginner

    A field is a raw data member that directly stores a value, while a property is a wrapper around field access that provides controlled get and set accessors. Properties allow you to add logic, validation, and change implementation without breaking your public API, making them the preferred choice for class members in modern C#.

    How it works

    A field is simply a variable declared directly in a class or struct. It holds data and nothing more. When you access a field, you are directly reading from or writing to that memory location.

    A property is a member that wraps field access through accessor methods called get and set. The get accessor runs when you read the property; the set accessor runs when you assign a value. This indirection allows you to:

    • Add validation logic before accepting a new value
    • Perform side effects (logging, notifying observers, updating related fields)
    • Compute a value on-the-fly without storing it
    • Change the internal implementation without changing the public interface
    • Implement data binding in UI frameworks

    Because properties feel syntactically like fields (you use dot notation to access them), they provide a clean abstraction while maintaining encapsulation—a core object-oriented principle.

    Example

    csharp
    public class User
    {
        // Raw field – direct access, no control
        private int _age;
    
        // Property wrapping the field with validation
        public int Age
        {
            get { return _age; }
            set
            {
                if (value < 0 || value > 150)
                    throw new ArgumentException("Age must be between 0 and 150");
                _age = value;
            }
        }
    
        // Auto-property – no explicit backing field needed
        public string Name { get; set; }
    
        // Computed property – no backing field, returns a calculated value
        public bool IsAdult => Age >= 18;
    }
    
    // Usage
    var user = new User { Name = "Alice" };
    user.Age = 25;  // Validation runs
    Console.WriteLine(user.IsAdult);  // True – computed on access
    

    This example demonstrates that properties provide a single, consistent interface for accessing data while hiding validation and computation logic from the caller.

    Key points to mention

    • Encapsulation: Properties enforce the principle that internal state should be protected and accessed only through controlled interfaces
    • API stability: You can change a field to a property without recompiling consuming code, but changing a property's name or removing it is a breaking change
    • Flexibility: A property can be read-only (get only), write-only (set only), or use different access levels for each accessor (e.g., public get; private set;)
    • Auto-properties: C# 3.0+ introduced auto-properties like public string Name { get; set; }, which eliminate boilerplate while still providing property benefits
    • Best practice: Expose public data members as properties, not fields—fields are typically private and often back a public property

    Common follow-up questions

    • What is an auto-property, and when would you use one?
    • How do access modifiers apply to properties?
    • Can you have a read-only property, and how would you implement it?

    Related interview questions

    .NETCsharp
  25. 25What is method overloading?

    Beginner

    Method overloading is a feature that allows you to define multiple methods with the same name but different parameter lists (signatures). The compiler determines which method to call at compile time based on the arguments you pass. This is a form of compile-time (static) polymorphism.

    How it works

    When you overload a method, you create several versions of it that differ by their parameters. The differences can be:

    • Number of parameters: Calculate(int a) vs. Calculate(int a, int b)
    • Type of parameters: Display(string text) vs. Display(int number)
    • Order of parameters: Combine(string first, int second) vs. Combine(int first, string second)

    The return type alone cannot be used to differentiate overloaded methods—the compiler wouldn't know which version to call when it sees only the method name. At compile time, the C# compiler analyzes the argument types and counts to select the most specific matching method. If no exact match exists, it attempts implicit conversions to find a suitable candidate.

    Example

    csharp
    public class Calculator
    {
        // Overload 1: Two integers
        public int Add(int a, int b)
        {
            return a + b;
        }
        
        // Overload 2: Three integers
        public int Add(int a, int b, int c)
        {
            return a + b + c;
        }
        
        // Overload 3: Two doubles
        public double Add(double a, double b)
        {
            return a + b;
        }
        
        // Overload 4: Parameter array (variable length)
        public int Add(params int[] numbers)
        {
            return numbers.Sum();
        }
    }
    
    // Usage
    var calc = new Calculator();
    Console.WriteLine(calc.Add(5, 10));           // Calls Overload 1 → 15
    Console.WriteLine(calc.Add(5, 10, 15));       // Calls Overload 2 → 30
    Console.WriteLine(calc.Add(5.5, 10.5));       // Calls Overload 3 → 16.0
    Console.WriteLine(calc.Add(1, 2, 3, 4, 5));   // Calls Overload 4 → 15

    The compiler selects the correct Add method based on the argument types and count, making the API more intuitive and readable.

    Key points to mention

    • Overloading is resolved at compile time, not runtime—this differs from method overriding, which uses runtime polymorphism.
    • The method signature (name + parameters) must be unique; return type alone does not create a valid overload.
    • Overloading improves code readability and maintainability by using a single, intuitive method name for related operations.
    • Use optional parameters and default values carefully alongside overloading to avoid redundant method definitions.
    • Overloading works with constructors, operators, and regular methods.

    Common follow-up questions

    • What's the difference between overloading and overriding? — Overloading creates multiple methods with the same name but different signatures (compile-time); overriding replaces a base class method in a derived class (runtime polymorphism).
    • Can you overload a method based only on return type? — No, the compiler cannot determine which overload to call when it sees only the method name and arguments, making return-type-only differences invalid.
    • How does the compiler choose the best overload match? — The compiler performs overload resolution by finding the most specific match: exact type match first, then implicit conversions, then params arrays as a last resort.

    Related interview questions

    Further reading

    .NETCsharp
  26. 26What is method overriding?

    Beginner

    Method overriding is a mechanism that allows a derived class to provide a different implementation of a method that already exists in its base class. The derived class uses the override keyword to replace a virtual or abstract base method, enabling runtime polymorphism where the correct method is called based on the actual object type, not the reference type.

    How it works

    When you declare a method as virtual in a base class, you're signaling that derived classes are permitted to override it with their own implementation. The derived class then uses the override keyword to replace that method. At runtime, when you call the method through a base class reference pointing to a derived class object, .NET uses dynamic dispatch to invoke the overridden method in the derived class, not the base class version.

    This is different from method overloading, which creates multiple methods with the same name but different signatures in the same class. Overriding specifically replaces an inherited method with a new implementation.

    For virtual, abstract, and override, the relationship is clear: virtual provides a default implementation that can be overridden, abstract requires derived classes to provide an implementation, and override is how you provide that implementation in the derived class.

    Example

    csharp
    public class Animal
    {
        public virtual void MakeSound()
        {
            Console.WriteLine("Some generic sound");
        }
    }
    
    public class Dog : Animal
    {
        public override void MakeSound()
        {
            Console.WriteLine("Woof!");
        }
    }
    
    public class Cat : Animal
    {
        public override void MakeSound()
        {
            Console.WriteLine("Meow!");
        }
    }
    
    // Usage
    Animal myPet = new Dog();
    myPet.MakeSound(); // Output: Woof!
    
    myPet = new Cat();
    myPet.MakeSound(); // Output: Meow!
    

    This example demonstrates polymorphism in action: the same method call produces different results depending on the actual object type, not the reference type.

    Key points to mention

    • The base method must be marked virtual or abstract to be overridable; you cannot override a non-virtual method.
    • The overridden method must have the same signature (name, parameters, and return type) as the base method.
    • Overriding enables runtime polymorphism through late binding—the correct method is resolved at runtime based on the actual object type.
    • You can use the sealed keyword to prevent further overriding in derived classes, or mark a method as sealed override to block overriding in subclasses while still overriding the base method.
    • The base keyword allows you to call the base class implementation from within the overridden method if needed.

    Common follow-up questions

    • What is the difference between overriding and overloading?
    • Can you override a static method? (Answer: No, static methods are resolved by reference type, not runtime type.)
    • What happens if you forget the virtual keyword in the base class? (Answer: The method cannot be overridden; it will be hidden if you use a similar method name in the derived class.)

    Related interview questions

    Further reading

    .NETCsharp
  27. 27What is the difference between virtual, abstract and override?

    Beginner

    Virtual allows derived classes to optionally override a method while providing a default implementation in the base class. Abstract requires derived classes to implement a method—there is no default implementation. Override is the keyword used in derived classes to replace a virtual or abstract method with a new implementation.

    How it works

    Virtual methods enable polymorphic behavior with a safety net. When you mark a method as virtual, you're saying "derived classes may override this, but they don't have to." The base class provides working code, so if a derived class doesn't override it, the base implementation runs. This is useful when you have sensible default behavior that some subclasses might customize.

    Abstract methods are the opposite. They define a contract that every derived class must fulfill. An abstract method has no body—it's purely a signature. The derived class is forced to provide an implementation using the override keyword. Abstract methods must live inside an abstract class, and you cannot instantiate an abstract class directly.

    Override is the mechanism that fulfills the contract. When a derived class implements a virtual or abstract method, it uses the override keyword to signal that it's intentionally replacing the base class behavior. Without override, you're just creating a new method that hides the base method—which is usually a mistake.

    Example

    csharp
    abstract class Animal
    {
        // Virtual: optional to override, has default behavior
        public virtual void MakeSound()
        {
            Console.WriteLine("Some generic animal sound");
        }
    
        // Abstract: must override, no implementation
        public abstract void Move();
    }
    
    class Dog : Animal
    {
        // Override virtual method
        public override void MakeSound()
        {
            Console.WriteLine("Woof!");
        }
    
        // Must override abstract method
        public override void Move()
        {
            Console.WriteLine("Dog runs on four legs");
        }
    }
    
    class Bird : Animal
    {
        // Don't override MakeSound—uses base implementation implicitly
        // But could override if needed
    
        // Must override abstract Move
        public override void Move()
        {
            Console.WriteLine("Bird flies with wings");
        }
    }
    
    // Usage
    Animal myDog = new Dog();
    myDog.MakeSound(); // Output: Woof!
    myDog.Move();      // Output: Dog runs on four legs
    
    Animal myBird = new Bird();
    myBird.MakeSound(); // Output: Some generic animal sound
    myBird.Move();      // Output: Bird flies with wings
    

    This example demonstrates that Dog overrides both methods, while Bird only overrides Move and inherits the default MakeSound—showing the difference between mandatory (abstract) and optional (virtual) overrides.

    Key points to mention

    • Virtual = optional override: Provides default behavior; derived classes can customize but don't have to.
    • Abstract = mandatory override: No implementation provided; every derived class must implement it.
    • Override keyword = fulfillment: Used in derived classes to replace base behavior and avoid the accidental method hiding that occurs without it.
    • Abstract classes only: Abstract methods can only exist inside abstract classes; you cannot instantiate abstract classes directly.
    • Polymorphism enabler: Both virtual and abstract enable runtime polymorphism—the correct override is called based on the actual object type, not the reference type. See method overriding for more details.

    Common follow-up questions

    • Can you use override without virtual or abstract? No—you can only override a virtual or abstract member. If you define a new method with the same signature without override, you're hiding (shadowing) the base method, which is usually unintended.
    • What's the difference between abstract classes and interfaces? See abstract class vs. interface. In short: abstract classes can have state and constructors; interfaces define contracts only.
    • Can an abstract class have non-abstract methods? Yes—abstract classes can mix abstract methods (that must be overridden) with virtual or concrete methods (that provide default behavior).

    Related interview questions

    .NETCsharp
  28. 28What is an interface?

    Beginner

    An interface defines a contract that specifies which members (methods, properties, events, and indexers) a type must implement. Classes and structs can implement one or more interfaces, allowing you to define a blueprint for behavior without providing implementation details. Interfaces enable loose coupling, polymorphism, and make code more testable and maintainable.

    How it works

    An interface is declared using the interface keyword and contains only member signatures—no implementation. When a class or struct implements an interface, it must provide concrete implementations for all members defined by that interface. This creates a contract: "if you claim to implement this interface, you must provide these capabilities."

    Interfaces support multiple inheritance—a single type can implement multiple interfaces simultaneously. This is one of the key differences from abstract classes, which allow only single inheritance. Interfaces promote loose coupling because code can depend on an interface rather than a concrete implementation, making it easier to swap implementations and write unit tests with mock objects.

    Starting with C# 8.0, interfaces can include default implementations for methods, allowing you to add new members to an interface without breaking existing implementations. However, the core purpose remains defining a contract.

    Example

    csharp
    // Define an interface contract
    public interface IPaymentProcessor
    {
        decimal ProcessPayment(decimal amount);
        string GetStatus();
    }
    
    // Implement the interface in a class
    public class CreditCardProcessor : IPaymentProcessor
    {
        public decimal ProcessPayment(decimal amount)
        {
            Console.WriteLine($"Processing credit card payment: ${amount}");
            return amount;
        }
    
        public string GetStatus()
        {
            return "Credit card ready";
        }
    }
    
    // Another implementation of the same interface
    public class PayPalProcessor : IPaymentProcessor
    {
        public decimal ProcessPayment(decimal amount)
        {
            Console.WriteLine($"Processing PayPal payment: ${amount}");
            return amount * 0.99m; // 1% fee
        }
    
        public string GetStatus()
        {
            return "PayPal connected";
        }
    }
    
    // Use any implementation through the interface
    class Program
    {
        static void Main()
        {
            IPaymentProcessor processor = new CreditCardProcessor();
            processor.ProcessPayment(100);
            
            processor = new PayPalProcessor();
            processor.ProcessPayment(100);
        }
    }

    This example demonstrates how two different classes implement the same IPaymentProcessor interface, providing their own versions of payment processing. Code using the interface doesn't need to know which implementation it's working with—it only depends on the contract.

    Key points to mention

    • Contract definition: Interfaces define what members must exist, not how they work.
    • Multiple implementation: A class can implement multiple interfaces, unlike abstract classes which support only single inheritance.
    • Loose coupling: Code depending on interfaces rather than concrete classes is more flexible, testable, and maintainable.
    • Polymorphism: Different implementations can be used interchangeably wherever the interface type is expected.
    • Default implementations (C# 8+): Interfaces can now include method bodies, though this is optional and less common.

    Common follow-up questions

    Related interview questions

    .NETCsharpTesting
  29. 29What is the difference between an abstract class and an interface?

    Beginner

    Abstract classes define a base template for derived classes with shared state and logic, allowing only single inheritance. Interfaces define contracts that classes must implement and support multiple inheritance, making them ideal for defining capabilities without enforcing implementation details. In modern C#, the distinction has blurred somewhat with default interface methods, but the intent remains: abstract classes represent "is-a" relationships, while interfaces represent "can-do" capabilities.

    How it works

    An abstract class serves as a blueprint for a family of related classes. It can contain abstract methods (which must be overridden), concrete methods (with implementations), fields, constructors, and access modifiers like protected or private. When a class inherits from an abstract class, it commits to implementing all abstract members and gains access to shared logic and state. However, C# allows a class to inherit from only one abstract class.

    An interface, by contrast, is a contract that specifies what methods and properties a class must provide, but not necessarily how. Interfaces cannot hold state (no instance fields) and traditionally contained only method signatures. Since C# 8.0, interfaces can include default implementations and static members, giving them more flexibility. Critically, a class can implement multiple interfaces, allowing it to adopt many different contracts simultaneously.

    Use abstract classes when you have a clear hierarchy with shared code, constructor logic, or state management. Use interfaces when you want to define a capability that unrelated classes should provide, or when you need multiple inheritance of contract.

    Example

    csharp
    // Abstract class: represents a family of animals with shared behavior
    public abstract class Animal
    {
        protected string Name { get; set; } // State
        
        protected Animal(string name)
        {
            Name = name; // Constructor logic
        }
        
        // Abstract method: derived classes must implement
        public abstract void Speak();
        
        // Concrete method: shared logic
        public void Sleep()
        {
            Console.WriteLine($"{Name} is sleeping.");
        }
    }
    
    // Interface: represents the capability to fly
    public interface IFlyable
    {
        void Fly();
        void Land();
    }
    
    // Concrete class: inherits from one abstract class, implements multiple interfaces
    public class Bird : Animal, IFlyable
    {
        public Bird(string name) : base(name) { }
        
        public override void Speak()
        {
            Console.WriteLine($"{Name} chirps.");
        }
        
        public void Fly()
        {
            Console.WriteLine($"{Name} is flying.");
        }
        
        public void Land()
        {
            Console.WriteLine($"{Name} lands.");
        }
    }
    
    // Usage
    var bird = new Bird("Sparrow");
    bird.Speak();   // Output: Sparrow chirps.
    bird.Sleep();   // Output: Sparrow is sleeping.
    bird.Fly();     // Output: Sparrow is flying.
    

    This example shows that Bird inherits from Animal (gaining state and shared logic) and implements IFlyable (satisfying a capability contract). It can do this because C# supports multiple interface implementation but single class inheritance.

    Key points to mention

    • Inheritance model: Abstract classes support single inheritance; interfaces support multiple implementation.
    • State management: Abstract classes can hold instance fields and maintain state; interfaces cannot hold instance state (though they can have constants and, since C# 8, static fields).
    • Constructors: Abstract classes can have constructors to initialize state; interfaces cannot.
    • Access modifiers: Abstract class members can be private, protected, internal, or public; interface members are implicitly public (with private implementation in C# 8+).
    • Purpose: Abstract classes model "is-a" hierarchies with shared behavior; interfaces define "can-do" capabilities independent of hierarchy.

    Common follow-up questions

    • Can an interface have constructors? (No, but a class implementing an interface can.)
    • Can an abstract class implement an interface? (Yes, and often does to define default behavior for that interface.)
    • What is the difference between a virtual method in a base class and a method in an interface? (Virtual methods provide optional overrides with a default implementation; interface methods enforce a contract, though C# 8+ defaults blur this distinction.)

    Related interview questions

    Further reading

    .NETCsharp
  30. 30What are the four pillars of OOP?

    Beginner

    The four pillars of Object-Oriented Programming are encapsulation, abstraction, inheritance, and polymorphism. These foundational concepts enable developers to write maintainable, scalable, and reusable code by organizing data and behavior into logical, self-contained units. Together, they form the backbone of how we design and structure applications in languages like C#.

    How it works

    Encapsulation bundles data (fields) and methods together into a class, hiding internal implementation details behind access modifiers (private, protected, public). This protects the object's state from unintended external modification and allows controlled access through properties or methods.

    Abstraction exposes only the essential features of an object while concealing complex implementation details. It allows you to interact with objects at a high level without needing to understand how they work internally. Abstract classes and interfaces are key tools for achieving abstraction.

    Inheritance enables a class (derived class) to inherit properties and methods from another class (base class), promoting code reuse and establishing hierarchical relationships. A child class can extend or override parent behavior, reducing duplication and improving maintainability.

    Polymorphism allows objects of different types to be treated through a common interface or base class. This enables flexible, extensible code where the specific implementation is determined at runtime based on the actual object type, supporting both method overriding and method overloading.

    Example

    csharp
    // Encapsulation: private fields, public property
    // Abstraction: abstract base class hides implementation details
    public abstract class Animal
    {
        private string _name;
        
        public string Name
        {
            get => _name;
            set => _name = value;
        }
        
        // Abstraction: abstract method defines contract
        public abstract void MakeSound();
    }
    
    // Inheritance: Dog inherits from Animal
    public class Dog : Animal
    {
        // Polymorphism: override abstract method
        public override void MakeSound()
        {
            Console.WriteLine($"{Name} barks!");
        }
    }
    
    public class Cat : Animal
    {
        public override void MakeSound()
        {
            Console.WriteLine($"{Name} meows!");
        }
    }
    
    // Usage
    Animal dog = new Dog { Name = "Buddy" };
    Animal cat = new Cat { Name = "Whiskers" };
    
    dog.MakeSound(); // Output: Buddy barks!
    cat.MakeSound(); // Output: Whiskers meows!
    

    This example demonstrates all four pillars: encapsulation via the private _name field and public Name property, abstraction through the Animal abstract class, inheritance with Dog and Cat extending Animal, and polymorphism by calling MakeSound() on different animal types through the common Animal interface.

    Key points to mention

    • Encapsulation protects object integrity by controlling access to internal state, reducing bugs and unintended side effects.
    • Abstraction simplifies code by hiding complexity, making systems easier to understand and maintain.
    • Inheritance promotes the DRY (Don't Repeat Yourself) principle, allowing shared functionality to be defined once in a base class.
    • Polymorphism enables flexible, loosely-coupled designs where new types can be added without changing existing code that uses the base interface.
    • These pillars work together: encapsulation and abstraction reduce complexity, inheritance enables code reuse, and polymorphism creates extensibility.

    Common follow-up questions

    • What is the difference between method overloading and method overriding? (Both are forms of polymorphism; overloading occurs at compile-time, overriding at runtime.)
    • How do abstract classes differ from interfaces in achieving abstraction? (See this related question for a detailed comparison.)
    • Can you explain the SOLID principles in relation to OOP pillars? (SOLID builds on these pillars to create even more maintainable designs.)

    Related interview questions

    Further reading

    .NETCsharp
  31. 31What is encapsulation?

    Beginner

    Encapsulation is the practice of bundling data (fields) and behavior (methods) together while hiding internal implementation details behind a public interface. It protects an object's state from unauthorized or invalid changes by controlling access through properties and methods, making code more maintainable and reducing unintended side effects.

    How it works

    Encapsulation operates on the principle of information hiding. You declare fields as private so external code cannot directly access or modify them. Instead, you expose controlled access through public properties, methods, or events. This creates a boundary between the internal state of an object and the outside world.

    The key benefit is protecting invariants—the rules that must always be true for your object to work correctly. For example, a bank account balance should never be negative. By hiding the balance field and only allowing withdrawals through a validated method, you guarantee this invariant is maintained. If the balance were public, any code could set it to an invalid value.

    Encapsulation also makes code easier to refactor. If the internal implementation changes—say you switch from storing a single balance to storing transactions and computing balance on-demand—external code doesn't break because it only depends on the public interface, not the private details.

    Example

    csharp
    public class BankAccount
    {
        private decimal _balance;
        
        public string AccountHolder { get; }
        public decimal Balance 
        { 
            get => _balance;
            private set => _balance = value;
        }
        
        public BankAccount(string accountHolder, decimal initialBalance)
        {
            AccountHolder = accountHolder;
            Balance = initialBalance;
        }
        
        public void Deposit(decimal amount)
        {
            if (amount <= 0)
                throw new ArgumentException("Deposit must be positive");
            
            Balance += amount;
        }
        
        public bool Withdraw(decimal amount)
        {
            if (amount <= 0 || amount > Balance)
                return false;
            
            Balance -= amount;
            return true;
        }
    }
    

    The _balance field is private and can only change through validated Deposit and Withdraw methods, ensuring the balance never becomes invalid. The Balance property has a private setter so external code cannot bypass these rules.

    Key points to mention

    • Encapsulation is one of the four pillars of OOP, alongside inheritance, polymorphism, and abstraction.
    • Use access modifiers (private, public, protected) to control visibility and enforce boundaries.
    • Properties with getters and setters provide a cleaner syntax than traditional getter/setter methods while still allowing validation logic.
    • Encapsulation protects object invariants and reduces coupling between classes, making code more maintainable and testable.
    • It enables you to change internal implementation without affecting code that depends on the public interface.

    Common follow-up questions

    • Why use properties instead of just public fields?
    • How does encapsulation relate to enforcing aggregate invariants in domain-driven design?
    • What is the difference between encapsulation and abstraction?

    Related interview questions

    Further reading

    .NETCsharpSecurity
  32. 32What is polymorphism?

    Beginner

    Polymorphism is the ability of objects to take on multiple forms and allows you to write code that can work with objects of different types through a common base type or interface. At runtime, the actual object's type determines which method implementation is executed, enabling flexible and extensible code design.

    How it works

    Polymorphism operates through the principle that a variable of a base class or interface type can reference objects of derived classes. When you call a method on that variable, the CLR (Common Language Runtime) uses late binding to determine which implementation to execute based on the actual runtime type of the object, not the variable's declared type.

    There are two main forms of polymorphism in C#:

    • Compile-time (Static) Polymorphism: Method overloading, where multiple methods share the same name but have different parameters. The compiler resolves which method to call based on the arguments provided.
    • Runtime (Dynamic) Polymorphism: Method overriding, where a derived class provides a different implementation of a base class method marked as virtual. The runtime determines which implementation to execute.

    Polymorphism works hand-in-hand with method overriding to create extensible and maintainable code that adheres to the Open/Closed Principle—open for extension through inheritance, closed for modification.

    Example

    csharp
    using System;
    
    abstract class Animal
    {
        public virtual void Speak()
        {
            Console.WriteLine("Animal makes a sound");
        }
    }
    
    class Dog : Animal
    {
        public override void Speak()
        {
            Console.WriteLine("Dog barks: Woof!");
        }
    }
    
    class Cat : Animal
    {
        public override void Speak()
        {
            Console.WriteLine("Cat meows: Meow!");
        }
    }
    
    class Program
    {
        static void Main()
        {
            Animal dog = new Dog();
            Animal cat = new Cat();
            
            dog.Speak();  // Outputs: Dog barks: Woof!
            cat.Speak();  // Outputs: Cat meows: Meow!
            
            // Process any animal
            ProcessAnimal(dog);
            ProcessAnimal(cat);
        }
        
        static void ProcessAnimal(Animal animal)
        {
            animal.Speak();  // Correct method called based on actual type
        }
    }
    

    This example shows runtime polymorphism: variables of type Animal hold Dog and Cat objects, and calling Speak() executes the correct overridden method for each actual type.

    Key points to mention

    • Polymorphism requires a base class or interface; derived classes override or implement methods to provide specific behavior.
    • The actual runtime type of the object determines which method implementation executes, not the variable's declared type.
    • Polymorphism reduces code duplication and makes code more flexible—you can add new types without changing existing code that uses the base type.
    • Use virtual keyword in the base class and override in derived classes to enable runtime polymorphism.
    • Interfaces are a powerful way to achieve polymorphism without inheritance; multiple types can implement the same interface differently.

    Common follow-up questions

    • What's the difference between virtual and abstract methods? (Abstract forces implementation in derived classes; virtual provides a default that can be overridden.)
    • How does polymorphism relate to the four pillars of OOP? (It's one of the core pillars; the others are encapsulation, inheritance, and abstraction.)
    • Can you use polymorphism with interfaces? (Yes, interfaces are even more flexible than base classes for achieving polymorphic behavior.)

    Related interview questions

    Further reading

    .NETCsharp
  33. 33What is the sealed keyword?

    Beginner

    The sealed keyword prevents a class from being inherited or a method from being overridden further. It explicitly communicates that a type is not designed for extension and can unlock small JIT compilation optimizations by allowing devirtualization.

    How it works

    When you mark a class as sealed, the compiler prevents any derived class from inheriting it. When you mark a method (or property) as sealed, you prevent further overrides in subclasses—though the method must already be marked virtual or override to seal it.

    The sealed keyword serves two purposes. First, it clarifies design intent: the author is saying "this class is complete and not meant to be extended." This makes code easier to reason about and safer to refactor. Second, it enables the JIT compiler to devirtualize method calls and apply inlining more aggressively, since there's no possibility of a derived implementation being called at runtime.

    Most .NET framework classes are sealed by default (like string, int, DateTime) for both safety and performance reasons. You should consider sealing your own classes unless you explicitly design them for inheritance.

    Example

    csharp
    // Sealed class—cannot be inherited
    public sealed class PaymentProcessor
    {
        public virtual void Process(decimal amount) { }
    }
    
    // This will not compile
    // public class CreditCardProcessor : PaymentProcessor { }  // Error!
    
    // Sealed method—prevents further override in subclasses
    public class DataService
    {
        public virtual void Fetch() { }
    }
    
    public class CachedDataService : DataService
    {
        public sealed override void Fetch()
        {
            // This is the final implementation; no subclass can override it
        }
    }
    
    // This will not compile
    // public class LoggedCachedDataService : CachedDataService
    // {
    //     public override void Fetch() { }  // Error!
    // }

    The first class prevents any inheritance; the sealed method in the subclass prevents further overrides down the chain, while still allowing the class itself to be inherited.

    Key points to mention

    • sealed class prevents inheritance entirely; sealed override prevents further overrides of a specific method in derived types.
    • Sealing enables JIT optimizations by removing the possibility of polymorphic dispatch, allowing devirtualization and inlining.
    • Most built-in .NET types (String, DateTime, Int32) are sealed to protect their invariants and improve performance.
    • Seal by default unless you explicitly design a class for inheritance; this follows the principle of "make it hard to misuse."
    • Sealed does not affect static members, constructors, or internal implementation—only inheritance and override relationships.

    Common follow-up questions

    • Why would you seal a method but not the class? — To allow subclasses to exist and override some members, but lock down a specific method that has no safe alternative implementation path.
    • Does sealing improve performance in all cases? — Not dramatically in everyday code, but it removes a source of virtual dispatch overhead and signals to the JIT that devirtualization is safe, which matters in hot paths.
    • Can you seal a static member or constructor? — No, static members and constructors are not virtual by nature, so sealing does not apply to them.

    Related interview questions

    Further reading

    .NETCsharpPerformanceArchitecture
  34. 34What is a namespace?

    Beginner

    A namespace is a container that organizes types (classes, interfaces, structs, enums, delegates) and prevents naming conflicts by creating a logical hierarchy. In C#, namespaces allow you to group related code and avoid collisions when multiple developers or libraries define types with the same name. Modern C# (7.10+) supports file-scoped namespaces, which eliminate excessive indentation.

    How it works

    Namespaces create a fully qualified name for each type by prefixing it with the namespace path. For example, a class User in the namespace Company.Services is fully qualified as Company.Services.User. Two different namespaces can both contain a User class without conflict because they are distinguishable by their full names.

    You access types in a namespace either by using their fully qualified name or by importing the namespace with a using directive at the top of your file. Namespaces can be nested, creating a hierarchical organization that mirrors your project structure. The .NET Framework itself uses deep namespace hierarchies—for example, System.Collections.Generic or Microsoft.AspNetCore.Mvc—to organize its vast library of types.

    File-scoped namespaces, introduced in C# 11, simplify syntax by allowing you to declare a single namespace for an entire file with namespace MyNamespace; instead of wrapping all code in braces. This reduces indentation and improves readability, especially for single-namespace files.

    Example

    csharp
    // File-scoped namespace (C# 11+)
    namespace Company.Services;
    
    public class UserService
    {
        public void CreateUser(string name)
        {
            Console.WriteLine($"Creating user: {name}");
        }
    }
    
    // Another file
    namespace Company.Data;
    
    public class UserRepository
    {
        public void SaveUser(string name)
        {
            Console.WriteLine($"Saving user: {name}");
        }
    }
    
    // Usage in main program
    using Company.Services;
    using Company.Data;
    
    var service = new UserService();
    var repo = new UserRepository();
    
    service.CreateUser("Alice");
    repo.SaveUser("Alice");

    This example demonstrates two separate namespaces (Company.Services and Company.Data) each containing different types, and how using directives make them accessible without fully qualified names.

    Key points to mention

    • Namespaces prevent naming collisions—multiple libraries can define a Logger class as long as they are in different namespaces.
    • The fully qualified name includes the complete namespace path; using directives allow you to omit the prefix for brevity.
    • Namespaces are hierarchical and typically mirror your project folder structure for maintainability (e.g., MyApp.Features.Auth.Services).
    • File-scoped namespaces (C# 11+) use namespace X; syntax and apply to the entire file, reducing nesting and indentation.
    • Namespaces are purely a logical organization tool; they do not affect runtime performance or memory usage.

    Common follow-up questions

    • What is the difference between a namespace and a class? A namespace is a logical grouping container; a class is a type that can be instantiated and contains members.
    • How do global using directives work? They allow you to import a namespace once in a central file so it is available to all files in your project.
    • Can namespaces span multiple files? Yes; you can declare the same namespace in several files, and all types from those files are considered part of that namespace.

    Related interview questions

    Further reading

    .NETCsharpArchitecturedependency-injection
  35. 35What are global using directives?

    Beginner

    Global using directives are a C# 10+ feature that imports a namespace for your entire project, eliminating the need to repeat using statements in every file. In SDK-style projects (the modern standard), implicit usings automatically add common namespaces like System, System.Linq, and System.Collections.Generic without you writing anything at all.

    How it works

    When you declare a global using directive with the global keyword, the compiler makes that namespace available throughout your entire project. You typically place these in a dedicated file (often called GlobalUsings.cs or Usings.cs) rather than scattering them across multiple files.

    Implicit usings go a step further. When you create a new SDK-style project (which uses a modern .csproj file), the project is configured with <ImplicitUsings>enable</ImplicitUsings>. This automatically adds a set of common namespaces based on your project type. For example, a console application automatically includes System, System.IO, System.Threading, and others—you don't have to write them. You can see exactly which namespaces are implicitly included by examining the generated *.GlobalUsings.g.cs file in your obj folder.

    This feature reduces boilerplate and makes code cleaner, especially in larger projects where the same namespaces appear in every file.

    Example

    csharp
    // GlobalUsings.cs
    global using System;
    global using System.Collections.Generic;
    global using System.Linq;
    global using MyProject.Models;
    global using MyProject.Services;
    
    // Program.cs (no using statements needed!)
    var items = new List<string> { "apple", "banana", "cherry" };
    var filtered = items.Where(x => x.Length > 5).ToList();
    Console.WriteLine($"Found {filtered.Count} items");
    

    The GlobalUsings.cs file declares all namespaces once for the entire project, so Program.cs can use List<T>, LINQ methods, and custom project namespaces without any using statements.

    Key points to mention

    • C# 10+ feature: Global usings are only available in .NET 6+ projects; they don't work in older Framework projects.
    • Implicit usings are default: New SDK-style projects have implicit usings enabled by default, so you automatically get System, System.Linq, and other common namespaces without writing anything.
    • Centralized management: Putting all global usings in one file makes it easy to see and manage project-wide dependencies.
    • Can be disabled: If you prefer explicit control, you can set <ImplicitUsings>disable</ImplicitUsings> in your .csproj.
    • Scope is project-wide: Global usings apply only to the project they're declared in; they don't leak to other projects that reference it.

    Common follow-up questions

    • Can I disable implicit usings? Yes—add <ImplicitUsings>disable</ImplicitUsings> to your .csproj if you want full control over every namespace.
    • How do I see what namespaces are implicitly included? Build your project and look at obj/Debug/net8.0/ProjectName.GlobalUsings.g.cs (the exact path depends on your target framework).
    • Are global usings the same as using static? No—global using imports a namespace, while using static imports static members from a specific type.

    Related interview questions

    Further reading

    .NETCsharp
  36. 36What is the difference between == and Equals?

    Beginner

    The core difference is that == is an operator that compares references by default for reference types, while Equals() is a virtual method that can be overridden to implement custom equality logic. For value types, == and Equals() both compare values, but strings and records override both operators to compare by value despite being reference types.

    How it works

    == is an operator defined on specific types. For most reference types like classes, it performs reference equality by default—checking if both variables point to the same object in memory. However, the behavior of == can be overloaded, and many built-in types do override it. For example, string overloads == to compare the actual text content rather than object references.

    Equals() is a virtual method inherited from System.Object. By default, it performs reference equality for classes and value equality for structs. Unlike ==, Equals() can always be overridden in derived classes to implement custom comparison logic. This makes it more flexible for defining what "equality" means in your domain.

    Strings override both == and Equals() to compare content. Records, introduced in C# 9, automatically override both to provide value-based equality by comparing their properties rather than their references.

    Example

    csharp
    // Reference type (class)
    class Person
    {
        public string Name { get; set; }
        
        public override bool Equals(object obj)
        {
            return obj is Person p && p.Name == Name;
        }
        
        public override int GetHashCode() => Name.GetHashCode();
    }
    
    // Test
    var person1 = new Person { Name = "Alice" };
    var person2 = new Person { Name = "Alice" };
    var person3 = person1;
    
    // Different references, same content
    Console.WriteLine(person1 == person2);        // False (different objects)
    Console.WriteLine(person1.Equals(person2));   // True (overridden for value equality)
    
    // Same reference
    Console.WriteLine(person1 == person3);        // True (same object)
    Console.WriteLine(person1.Equals(person3));   // True
    
    // String comparison (overloaded == and Equals)
    var str1 = new string(new[] { 'A', 'B' });
    var str2 = new string(new[] { 'A', 'B' });
    Console.WriteLine(str1 == str2);              // True (content comparison)
    Console.WriteLine(str1.Equals(str2));         // True
    

    This example shows how == defaults to reference equality for custom classes, while Equals() can be overridden to compare by value. Strings behave differently due to their overloaded operators.

    Key points to mention

    • == is an operator that can be overloaded; by default it compares references for classes and values for structs.
    • Equals() is a virtual method from object that can always be overridden to define custom equality regardless of type.
    • Strings and records override both == and Equals() to provide value-based comparison.
    • If you override Equals(), you should also override GetHashCode() and consider overloading == for consistency.
    • Equals() is safer for polymorphic comparisons because it's virtual and cannot cause unexpected behavior through operator overloading.

    Common follow-up questions

    • Why should you override GetHashCode() when you override Equals()?
    • What's the difference between Equals() and ReferenceEquals()?
    • How do classes and structs differ in their default equality behavior?

    Related interview questions

    Further reading

    .NETCsharp
  37. 37Why are strings immutable in .NET?

    Beginner

    Strings in .NET are immutable because once created, their value cannot be changed. Every operation that appears to modify a string actually creates a new string object. This design choice provides significant benefits including thread safety, performance optimization through interning, and safe usage as dictionary keys without worrying about value changes.

    How it works

    When you declare a string in .NET, it's stored in memory with a fixed character sequence. The System.String class is sealed and its internal character array cannot be modified after creation. This is enforced at the CLR level—there are no public methods that allow you to change a string's content in place.

    When you perform operations like concatenation, substring extraction, or case conversion, the CLR creates an entirely new string object with the result and returns a reference to it. The original string remains unchanged in memory. This immutability guarantee means multiple references can safely point to the same string without fear that one caller might change it for another.

    The .NET runtime also implements string interning—a optimization where identical string literals and many string values are stored only once in the intern pool. Since strings cannot change, the runtime can confidently reuse the same memory location for multiple references, reducing memory pressure and improving performance.

    Example

    csharp
    string original = "Hello";
    string modified = original.ToUpper();
    
    Console.WriteLine(original);  // Output: Hello (unchanged)
    Console.WriteLine(modified);  // Output: HELLO (new string)
    
    // Each operation creates a new string
    string s1 = "dotnet";
    string s2 = s1 + " rocks";
    string s3 = s1 + " rocks";
    
    Console.WriteLine(ReferenceEquals(s2, s3));  // Output: True (interned)
    Console.WriteLine(ReferenceEquals(s1, s2));  // Output: False (different strings)

    This demonstrates that ToUpper() returns a new string while the original remains "Hello", and identical strings may share the same memory through interning.

    Key points to mention

    • Thread safety—multiple threads can safely access the same string reference without synchronization because no thread can modify it.
    • Dictionary key safety—strings can be reliably used as dictionary keys since their hash code won't change unexpectedly during the key's lifetime.
    • Performance cost—frequent string modifications through concatenation create many intermediate objects; use StringBuilder for heavy concatenation to avoid this overhead.
    • Memory efficiency—string interning means duplicate strings share memory, reducing overall allocation.
    • Predictable behavior—immutability eliminates entire classes of bugs where unexpected string mutations cause hard-to-trace issues.

    Common follow-up questions

    • What is the difference between string and StringBuilder, and when should each be used?
    • How does string interning work, and can you control it programmatically?
    • If strings are immutable, why can string interpolation appear to modify a string?

    Related interview questions

    Further reading

    .NETCsharpasync
  38. 38When should you use StringBuilder?

    Beginner

    Use StringBuilder when concatenating strings in loops or combining many string pieces. Since strings are immutable in .NET, each concatenation creates a new string object. StringBuilder buffers changes in memory and builds the final string once, dramatically reducing memory allocations and improving performance.

    How it works

    Strings in .NET are immutable, meaning once created, they cannot be changed. When you use the + operator or string concatenation, the runtime creates a brand new string object containing the combined result. This is inefficient when done repeatedly because each operation allocates memory, copies data, and discards the old string.

    StringBuilder, part of the System.Text namespace, maintains an internal character buffer that can grow dynamically. Instead of creating new objects with each addition, StringBuilder appends content to this mutable buffer. Only when you call ToString() does it create the final immutable string. This approach minimizes allocations and garbage collection pressure, resulting in significant performance gains for operations involving many concatenations.

    Example

    csharp
    // Bad: Creates 100 string objects
    string result = "";
    for (int i = 0; i < 100; i++)
    {
        result += "Item " + i + ", ";
    }
    
    // Good: Uses StringBuilder efficiently
    var sb = new StringBuilder();
    for (int i = 0; i < 100; i++)
    {
        sb.Append("Item ").Append(i).Append(", ");
    }
    string result = sb.ToString();

    The StringBuilder version performs the same loop but creates only one final string object, while the concatenation version creates approximately 100 intermediate strings that must be garbage collected.

    Key points to mention

    • StringBuilder is most beneficial in loops where you perform many concatenations; for simple one-time operations, the performance difference is negligible.
    • StringBuilder maintains a resizable internal buffer (capacity) that grows as needed; you can optimize by setting initial capacity if you know the approximate final length.
    • StringBuilder provides Append(), Insert(), Remove(), and Replace() methods for flexible string manipulation before converting to a final string.
    • Modern string interpolation with $"" syntax is optimized by the compiler and can be nearly as efficient as StringBuilder in many cases, but StringBuilder is still preferred for complex loops.
    • StringBuilder is not thread-safe; use lock statements or other synchronization if accessed from multiple threads.

    Common follow-up questions

    • When should I set StringBuilder's capacity? If you know the approximate final string length beforehand, set capacity in the constructor to avoid internal buffer resizing: new StringBuilder(1000).
    • Is StringBuilder necessary with string interpolation? String interpolation is compiler-optimized and adequate for most scenarios, but StringBuilder remains superior for dynamic loops where the number of concatenations is unknown or large.
    • What's the performance difference? With 1000+ concatenations, StringBuilder can be orders of magnitude faster; with small operations (2-3 concatenations), the difference is imperceptible.

    Related interview questions

    Further reading

    .NETCsharp
  39. 39What is string interpolation?

    Beginner

    String interpolation is a C# feature that lets you embed expressions directly inside string literals using the $ prefix and curly braces {}. For example, $"Hello {name}" produces a cleaner, more readable alternative to older string concatenation or formatting methods. The compiler optimizes interpolated strings at compile time, making them both performant and expressive.

    How it works

    When you prefix a string with the $ symbol, the C# compiler treats the string as a template. Any expression inside curly braces {} is evaluated at runtime and its result is inserted into the string. Behind the scenes, the compiler generates efficient code—often calling string.Format() or using interpolated string handlers in modern .NET versions for optimal performance.

    You can embed simple variables, object properties, method calls, arithmetic expressions, and even complex logic inside the braces. The compiler also supports format specifiers (like {price:C2} for currency) and alignment (like {name,10} for padding). String interpolation respects string immutability in .NET—each interpolated string creates a new string object.

    Example

    csharp
    string name = "Alice";
    int age = 30;
    decimal salary = 75000m;
    DateTime startDate = new DateTime(2023, 1, 15);
    
    // Basic interpolation
    string greeting = $"Hello, {name}!";
    Console.WriteLine(greeting); // Output: Hello, Alice!
    
    // Multiple expressions and format specifiers
    string profile = $"{name} is {age} years old and earns {salary:C2} annually.";
    Console.WriteLine(profile); 
    // Output: Alice is 30 years old and earns $75,000.00 annually.
    
    // Expressions inside braces
    string summary = $"In {DateTime.Now.Year}, {name} will be {age + 1}.";
    Console.WriteLine(summary);
    // Output: In 2024, Alice will be 31.
    
    // Alignment and padding
    string aligned = $"|{name,15}| {age,3}|";
    Console.WriteLine(aligned);
    // Output: |           Alice|  30|

    This example demonstrates basic interpolation, format specifiers like :C2 for currency, embedded expressions like age + 1, and alignment padding with ,10 syntax.

    Key points to mention

    • Readability: Interpolation is far more readable than string concatenation (+) or string.Format(), reducing cognitive load and errors.
    • Compiler optimization: Modern .NET uses interpolated string handlers to avoid allocating intermediate objects, improving performance compared to older formatting methods.
    • Format specifiers: You can use format specifiers like {price:F2}, {date:yyyy-MM-dd}, or {value:P} for precise formatting without additional method calls.
    • Expression support: Any valid C# expression can go inside braces—method calls, ternary operators, LINQ queries, and arithmetic all work seamlessly.
    • Null-coalescing and null-conditional: You can combine interpolation with null operators like {user?.Name ?? "Guest"} for safe, concise null handling.

    Common follow-up questions

    • How does string interpolation compare to string.Format() or StringBuilder? String interpolation is more readable and compile-time optimized; use StringBuilder when building strings in loops to avoid excessive allocations.
    • Can you use interpolation in older .NET versions? String interpolation was introduced in C# 6.0 (.NET Framework 4.6 and .NET Core). Earlier versions require string.Format() or concatenation.
    • What happens if you use $@ together? The $@ prefix combines interpolation ($) with verbatim strings (@, which ignore escape sequences), allowing multi-line interpolated strings with literal backslashes and newlines.

    Related interview questions

    Further reading

    .NETCsharp
  40. 40What are nullable value types?

    Beginner

    Nullable value types allow value types like int, bool, and DateTime to hold a null value in addition to their normal range of values. The syntax int? is shorthand for Nullable<int>, and you can check whether a value is present using the HasValue property or use the null-coalescing operator ?? to provide a default when null is encountered.

    How it works

    By default, value types cannot be null—they always contain a value. For example, an int defaults to 0, and a bool defaults to false. However, in many real-world scenarios, you need to represent the absence of a value (not just a default). This is where nullable value types come in.

    When you declare a variable as int?, you're actually creating an instance of the generic struct Nullable<T>. This struct wraps the value type and adds a boolean flag that indicates whether a value is present. Internally, Nullable<int> contains two fields: a bool HasValue property and a int Value property.

    You interact with nullable value types in three main ways: checking HasValue before accessing Value, using the null-coalescing operator ?? to supply a default, or relying on implicit unwrapping when you're confident a value exists. Boxing a nullable value type with a null value results in a null reference; boxing a nullable value type with a value results in a boxed version of the underlying value (not the Nullable<T> wrapper).

    Example

    csharp
    int? age = null;
    string? name = null;
    DateTime? birthDate = new DateTime(1990, 5, 15);
    
    // Checking HasValue
    if (age.HasValue)
    {
        Console.WriteLine($"Age: {age.Value}");
    }
    else
    {
        Console.WriteLine("Age is unknown");
    }
    
    // Using null-coalescing operator
    int displayAge = age ?? 0;
    Console.WriteLine($"Display age: {displayAge}");
    
    // Using GetValueOrDefault
    int safeAge = age.GetValueOrDefault(18);
    Console.WriteLine($"Safe age: {safeAge}");
    
    // Direct comparison with null
    if (birthDate != null)
    {
        Console.WriteLine($"Birth date: {birthDate}");
    }

    This example demonstrates declaring nullable value types, checking for null with HasValue, providing defaults with ??, and using GetValueOrDefault() to safely extract values.

    Key points to mention

    • int? is syntactic sugar for Nullable<T>, a generic struct that wraps a value type and a boolean flag.
    • Always check HasValue or use the ?? operator before assuming a nullable value type contains a value; accessing Value when HasValue is false throws an InvalidOperationException.
    • Nullable value types are still value types—they live on the stack (when local variables) and are passed by value, unlike nullable reference types.
    • You can use GetValueOrDefault() to safely extract a value with a fallback, or use pattern matching in C# 7+ with if (age is int ageValue).
    • Boxing a nullable value type with null produces a null reference; boxing a nullable value type with a value produces a boxed instance of the underlying type.

    Common follow-up questions

    • What is the difference between int? and int in terms of memory or performance? — A nullable int requires 5 bytes (4 bytes for the int plus 1 byte for the boolean flag), versus 4 bytes for a regular int. The performance cost is minimal but noticeable in performance-critical scenarios or large collections.
    • Can I use nullable value types with null in LINQ or database queries? — Yes, nullable value types are essential for ORM frameworks like Entity Framework; they allow you to model optional columns in databases where a value may genuinely be missing.
    • How do nullable value types differ from null reference types? — Nullable value types are opt-in per variable and use the Nullable<T> wrapper, while nullable reference types (C# 8+) are a static analysis feature that helps prevent null reference exceptions on reference types.

    Related interview questions

    Further reading

    .NETCsharp
  41. 41What are nullable reference types?

    Beginner

    Nullable reference types (NRTs) are a C# feature that enables the compiler to track and warn about potential null reference exceptions before runtime. By enabling this feature, you can annotate reference types with ? to indicate they may be null, while unmarked references are treated as non-nullable and should never be null. This shifts null-safety checking from runtime to compile-time.

    How it works

    Nullable reference types were introduced in C# 8.0 as an optional feature and became enabled by default in .NET 6+. When enabled (via the <nullable>enable</nullable> project setting or #nullable enable directive), the compiler performs static analysis to detect when you might dereference a null reference.

    The system works as follows: a variable declared as string name is considered non-nullable, meaning the compiler expects it to never be null and will warn if you assign null to it or pass it where a non-nullable string is expected. In contrast, string? optionalName is nullable, signaling that the variable may contain null, and the compiler requires you to null-check before dereferencing it.

    This is purely a compile-time safety mechanism. At runtime, the ? annotation has no effect—it does not prevent null from being assigned. Rather, it communicates intent to the compiler and serves as documentation for other developers. The compiler uses flow analysis to understand when a nullable variable has been checked and is safe to use.

    Example

    csharp
    #nullable enable
    
    public class UserService
    {
        public void ProcessUser(string? email)
        {
            // Compiler warning: Cannot convert null literal to non-nullable string type
            // string name = email;  // ❌ Error
    
            // Compiler accepts this after null-check
            if (email != null)
            {
                string name = email;  // ✓ Safe
                Console.WriteLine(name.Length);
            }
    
            // Without null-check, compiler warns
            // Console.WriteLine(email.Length);  // ⚠️ Warning: possible null reference
        }
    
        public string GetUserEmail(User? user)
        {
            // Requires null-check before accessing property
            return user?.Email ?? "unknown@example.com";
        }
    }
    
    public class User
    {
        public required string Id { get; init; }        // Non-nullable, must be provided
        public string? Email { get; set; }              // Nullable, may be null
    }

    This example shows how the compiler warns when assigning a nullable reference to a non-nullable variable without a null-check, and how the null-conditional operator ?. safely handles potential null values.

    Key points to mention

    • Compile-time feature: NRTs are a static analysis tool; they do not affect runtime behavior or IL code generation.
    • Opt-in (historically): While enabled by default in newer templates, projects can enable or disable NRTs per file or project-wide via the <nullable> project setting.
    • Flow-sensitive: The compiler tracks whether a nullable variable has been null-checked within a code path, allowing you to use it safely after the check without additional casts.
    • Works with nullable value types: NRTs apply similar logic to reference types that nullable value types provide for structs.
    • Helps prevent NullReferenceException: By catching potential null dereferences at compile-time, NRTs reduce a common class of runtime errors.

    Common follow-up questions

    • Can you disable nullable reference types in a .NET 6+ project, and why might you want to?
    • How do nullable reference types interact with inheritance and interface implementation?
    • What is the difference between the null-coalescing operator ?? and the null-conditional operator ?. when working with NRTs?

    Related interview questions

    Further reading

    .NETCsharp
  42. 42What do the ?. and ?? operators do?

    Beginner

    The ?. operator (null-conditional operator) safely accesses members on an object only when that object is not null, returning null if the object is null. The ?? operator (null-coalescing operator) returns a fallback value when the left operand is null, and ??= (null-coalescing assignment) assigns a value only when the variable is currently null. Together, these operators provide a concise, readable way to handle null scenarios without explicit null checks.

    How it works

    The ?. operator allows you to chain member access (properties, methods, indexers) without throwing a NullReferenceException. If the object is null, the entire expression short-circuits and returns null. This is especially useful when working with nested objects or method chains where any level might be null.

    The ?? operator evaluates the right operand only if the left operand is null. This is called null coalescing and provides a clean syntax for supplying default values. It works with both reference and nullable value types.

    The ??= operator (introduced in C# 8.0) combines assignment with null coalescing. It assigns the right-hand value to the left-hand variable only if that variable is currently null, useful for lazy initialization patterns.

    Example

    csharp
    public class User
    {
        public string? Name { get; set; }
        public Address? Address { get; set; }
    }
    
    public class Address
    {
        public string? City { get; set; }
    }
    
    // Usage
    User? user = GetUser(); // might be null
    
    // ?. operator - safe navigation
    string? city = user?.Address?.City; // Returns null if user or Address is null
    
    // ?? operator - null coalescing
    string displayName = user?.Name ?? "Unknown";
    
    // ??= operator - null coalescing assignment
    user ??= new User { Name = "Guest" }; // Assigns only if user is null
    
    // Combining them
    string message = user?.Address?.City ?? "No city available";
    Console.WriteLine(message); // Prints city name or "No city available"

    This example demonstrates all three operators: safely accessing nested properties with ?., providing fallback values with ??, and conditionally assigning with ??=.

    Key points to mention

    • The ?. operator returns null if the object is null; otherwise, it proceeds with the member access. It prevents NullReferenceException from being thrown.
    • The ?? operator is right-associative, so a ?? b ?? c evaluates as a ?? (b ?? c), allowing easy chaining of fallback values.
    • The ??= operator only assigns if the left side is null, making it perfect for lazy initialization and default value setup patterns.
    • These operators work with nullable reference types (available with the nullable reference types feature in C# 8.0+), improving code safety by distinguishing between nullable and non-nullable references at compile time.
    • You can combine ?. with indexers and methods: list?[0] or person?.GetName() are both valid.

    Common follow-up questions

    • What is the difference between ?? and the ternary operator? (The ternary operator requires an explicit condition, while ?? specifically checks for null.)
    • Can you use ?. with methods that have side effects? (Yes, but be cautious—the method only executes if the object is not null.)
    • What does user?.Method()?. Property do? (It calls Method() on user if user is not null, then accesses Property only if the returned object is not null.)

    Related interview questions

    Further reading

    .NETCsharp
  43. 43What is an enum?

    Beginner

    An enum (enumeration) is a named set of integral constants that allows you to define a group of related constant values with meaningful names. Instead of using magic numbers or strings throughout your code, enums provide type safety, readability, and maintainability. They are value types (structs) that inherit from System.Enum and are compiled into integer values at runtime.

    How it works

    When you declare an enum, you define a new type with a fixed set of named constants. Each constant has an underlying integral value—by default, an int starting at 0 and incrementing. The compiler treats each enum member as a constant and enforces that only valid enum values can be used in code.

    Enums provide several benefits: they replace magic numbers with self-documenting names, enable compile-time validation (you can't accidentally pass an invalid status), and work seamlessly with switch statements. For combinable values (like permissions or flags), the [Flags] attribute lets you combine multiple enum values using bitwise operations, treating each member as a power of two.

    Under the hood, enums are just syntactic sugar over integers. When you reference an enum member, the compiler substitutes its underlying integer value. This makes enums both type-safe at compile time and efficient at runtime.

    Example

    csharp
    // Simple enum for order status
    public enum OrderStatus
    {
        Pending = 0,
        Processing = 1,
        Shipped = 2,
        Delivered = 3,
        Cancelled = 4
    }
    
    // Flags enum for file permissions
    [Flags]
    public enum FilePermissions
    {
        None = 0,
        Read = 1,
        Write = 2,
        Execute = 4,
        Delete = 8
    }
    
    class Program
    {
        static void Main()
        {
            // Simple enum usage
            OrderStatus status = OrderStatus.Processing;
            Console.WriteLine($"Order status: {status}"); // Output: Order status: Processing
    
            // Switch with enum
            if (status == OrderStatus.Processing)
                Console.WriteLine("Your order is being processed.");
    
            // Flags enum usage
            FilePermissions perms = FilePermissions.Read | FilePermissions.Write;
            Console.WriteLine($"Has read: {perms.HasFlag(FilePermissions.Read)}"); // Output: True
            Console.WriteLine($"Has execute: {perms.HasFlag(FilePermissions.Execute)}"); // Output: False
        }
    }

    This example shows a simple enum for order statuses and a [Flags] enum for file permissions that can be combined using bitwise OR and checked with HasFlag().

    Key points to mention

    • Enums are value types derived from System.Enum with an underlying integral type (default is int); you can also use byte, short, long, etc.
    • They provide type safety—the compiler prevents invalid values and makes code self-documenting compared to magic numbers or strings.
    • Use [Flags] attribute for enums representing combinable bit flags; each value should be a power of two (1, 2, 4, 8, etc.).
    • Enums work efficiently in switch statements and can be converted to/from their underlying integer values with casting or Enum.Parse().
    • Unlike const fields, enum members are implicitly static and cannot be changed after compilation.

    Common follow-up questions

    • Can you change an enum's underlying type, and why would you?
    • What is the difference between a simple enum and a [Flags] enum?
    • How do you parse a string into an enum value, and how do you convert an enum to a string?

    Related interview questions

    .NETCsharp
  44. 44What are generics?

    Beginner

    Generics are a language feature that allows you to write type-safe, reusable code by using type parameters (placeholders) instead of specific types. A generic class or method works with any data type while maintaining compile-time type checking and avoiding the need for casting or boxing and unboxing.

    How it works

    Generics use angle brackets with a type parameter (usually T, but can be any name) to define a placeholder for a concrete type. When you create an instance or call a method, you specify what type should replace that parameter. The compiler generates type-specific code at compile time, so you get both flexibility and safety.

    Instead of writing separate classes for ListOfInts, ListOfStrings, and ListOfObjects, you write one generic List<T>. The runtime and compiler handle the rest. This eliminates:

    • Casting: No need to cast retrieved items back to their original type.
    • Boxing overhead: Value types don't get boxed when stored in generic containers.
    • Type errors: The compiler catches type mismatches at build time, not runtime.

    You can constrain generics too. For example, <T where T : IComparable> ensures T implements IComparable, so you can safely call comparison methods.

    Example

    csharp
    // Generic class that works with any type
    public class Container<T>
    {
        private T _value;
    
        public void Set(T value) => _value = value;
        public T Get() => _value;
    }
    
    // Generic method
    public static void PrintArray<T>(T[] items)
    {
        foreach (var item in items)
            Console.WriteLine(item);
    }
    
    // Usage
    var intContainer = new Container<int>();
    intContainer.Set(42);
    Console.WriteLine(intContainer.Get()); // Output: 42
    
    var stringContainer = new Container<string>();
    stringContainer.Set("Hello");
    Console.WriteLine(stringContainer.Get()); // Output: Hello
    
    PrintArray(new[] { 1, 2, 3 });
    PrintArray(new[] { "a", "b", "c" });

    The Container<T> class works with any type—integers, strings, objects—without casting. When you create Container<int>, the compiler generates type-safe code just for integers.

    Key points to mention

    • Generics provide type safety at compile time, catching errors early rather than at runtime.
    • They eliminate casting—you retrieve items in their original type without as or (Type).
    • They improve performance by avoiding boxing of value types; an int in a List<int> stays on the stack or inline in memory.
    • Generics work with constraints (e.g., <T where T : class>, <T where T : struct>, <T where T : IComparable>) to restrict what types can be used.
    • List<T>, Dictionary<TKey, TValue>, and IEnumerable<T> are built-in generic types you use constantly in .NET.

    Common follow-up questions

    • What is the difference between generics and object? — Using object requires casting every time you retrieve a value and causes boxing for value types, losing type safety and performance. Generics avoid both.
    • Can you have multiple type parameters? — Yes: Dictionary<TKey, TValue> or Tuple<T1, T2, T3> use multiple parameters separated by commas.
    • What is covariance and contravariance? — Advanced topic: out T (covariance) allows IEnumerable<Derived> to be used where IEnumerable<Base> is expected; in T (contravariance) does the opposite.

    Related interview questions

    Further reading

    .NETCsharp
  45. 45What are generic constraints?

    Beginner

    Generic constraints are restrictions placed on type parameters in generic classes, methods, or interfaces that specify what types are allowed to be substituted. They enable you to write type-safe generic code by ensuring that only types meeting certain criteria can be used, allowing the compiler to verify that specific operations or member accesses are valid. Common constraints include where T : class, where T : struct, where T : new(), and where T : SomeInterface.

    How it works

    Without constraints, a generic type parameter T is treated as the most basic type possible—essentially object. This means you can only call methods that exist on object (like ToString(), GetHashCode(), etc.). Constraints tell the compiler and developers what assumptions can be safely made about T.

    When you apply a constraint using the where keyword, the compiler enforces it at compile time. If someone tries to use a type that doesn't satisfy the constraint, they'll get a compilation error rather than a runtime failure. This allows you to call methods specific to the constrained type without casting or reflection.

    Multiple constraints can be combined. For example, where T : class, IComparable, new() means T must be a reference type that implements IComparable and has a parameterless constructor. There are also base type constraints (like where T : Animal) and unmanaged constraints for low-level programming.

    Example

    csharp
    // Generic repository with constraints
    public class Repository<T> where T : class, new()
    {
        private List<T> _items = new();
    
        public void Add(T item)
        {
            if (item != null)
                _items.Add(item);
        }
    
        public T CreateNew()
        {
            return new T(); // Valid because of new() constraint
        }
    }
    
    public class User
    {
        public string Name { get; set; }
    }
    
    // Usage
    var userRepo = new Repository<User>();
    var newUser = userRepo.CreateNew(); // Works fine
    
    // var stringRepo = new Repository<string>(); 
    // Compiler error: string doesn't satisfy constraints (can't instantiate with new())
    

    This example demonstrates a repository pattern where T must be a reference type with a parameterless constructor, allowing us to safely call new T() and work with objects predictably.

    Key points to mention

    • Compile-time safety: Constraint violations are caught at compilation, not at runtime, preventing type-related errors before deployment.
    • Reference vs. value types: The class constraint restricts to reference types, while struct restricts to value types, giving you control over object allocation and behavior.
    • Interface and base type constraints: You can require that T implements a specific interface (e.g., where T : IComparable) or derives from a base class, enabling you to call interface or base class methods confidently.
    • Constructor constraint: new() ensures T has a parameterless constructor, allowing the generic code to instantiate new instances.
    • Multiple constraints: Constraints are combined with commas and follow a specific order: base type/class/struct first, then interfaces, then new() last.

    Common follow-up questions

    • Can you have multiple constraints on a single type parameter? Yes, you can combine them (e.g., where T : class, IDisposable, new()), but the order matters and there are limitations—for example, you cannot have both class and struct constraints together.
    • What is the difference between where T : class and where T : struct? class restricts to reference types, while struct restricts to value types, affecting memory allocation and null-ability.
    • Can you use constraints with multiple type parameters? Yes, each type parameter can have its own set of constraints; for example, public class Pair<T, U> where T : class where U : struct applies different constraints to different parameters.

    Related interview questions

    .NETCsharp
  46. 46What is the difference between an array and a List<T>?

    Beginner

    Arrays have a fixed size determined at creation and are slightly faster due to direct memory allocation, while List<T> is a dynamic collection that grows automatically as you add elements. List<T> provides convenient methods like Add, Remove, and Insert, whereas arrays require manual index management. For most modern C# applications, List<T> is the preferred choice because its dynamic nature and rich API outweigh the minor performance difference.

    How it works

    An array is a contiguous block of memory with a predetermined length. Once created, its size cannot change—if you need more space, you must create a new array and copy all elements over manually. This fixed nature makes array access extremely fast because the runtime can calculate the memory address of any element in constant time.

    List<T> is a generic wrapper around an internal array. When you create a List<T>, it allocates an internal array with initial capacity (typically 4 elements). As you add items beyond this capacity, List<T> automatically creates a larger internal array (usually doubling the capacity) and copies elements to it. This dynamic resizing incurs a small overhead, but it happens infrequently enough that most operations remain very efficient.

    Both arrays and List<T> maintain elements in memory sequentially, providing fast index-based access. The key difference lies in flexibility: arrays demand that you know the exact size upfront, while List<T> adapts to your needs.

    Example

    csharp
    // Array: fixed size
    int[] numbers = new int[5]; // Must specify size upfront
    numbers[0] = 10;
    numbers[1] = 20;
    // numbers[5] = 30; // IndexOutOfRangeException!
    
    // List: dynamic size
    List numbersList = new(); // No size needed
    numbersList.Add(10);
    numbersList.Add(20);
    numbersList.Add(30); // Grows automatically
    numbersList.Insert(1, 15); // Easy to insert at specific position
    numbersList.Remove(20); // Easy to remove by value
    
    Console.WriteLine($"Array length: {numbers.Length}"); // 5
    Console.WriteLine($"List count: {numbersList.Count}"); // 4

    This example shows that arrays require you to specify length upfront and crash if you exceed bounds, while List<T> grows seamlessly and provides convenient methods for common operations.

    Key points to mention

    • Fixed vs. Dynamic: Arrays have immutable length; List<T> grows and shrinks automatically.
    • Performance: Arrays are marginally faster for pure access patterns; List<T> adds minimal overhead through internal resizing, which is amortized across many operations.
    • API: List<T> offers rich methods (Add, Remove, Insert, Contains, ForEach) that require manual loops with arrays.
    • Memory efficiency: Arrays use only as much memory as needed; List<T> may allocate more internal capacity than current item count to reduce resizing frequency.
    • Use cases: Use arrays when size is truly fixed (e.g., a deck of 52 cards); use List<T> for collections of unknown or variable size.

    Common follow-up questions

    • "What happens internally when a List exceeds its capacity?" The list doubles its internal array capacity, allocates a new array, copies all elements, and discards the old array. This is an O(n) operation, but it happens rarely enough that the amortized cost per Add is still O(1).
    • "When would you use an array instead of List<T>?" Use arrays for fixed-size collections, performance-critical code paths where the small overhead matters, interop scenarios, or when working with legacy code. Modern C# favors List<T> or IEnumerable<T> for flexibility.
    • "How does List<T> compare to other collections like Dictionary or HashSet?" List<T> maintains insertion order and allows duplicates with fast index access; Dictionary<K, V> uses key-value pairs; HashSet<T> removes duplicates and offers fast lookups. Choose based on your access patterns.

    Related interview questions

    .NETCsharp
  47. 47What is a Dictionary<TKey,TValue>?

    Beginner

    A Dictionary<TKey, TValue> is a generic, hash-based collection that stores key-value pairs and provides O(1) average-time lookups by key. It is part of the System.Collections.Generic namespace and is the most commonly used collection for fast data retrieval when you need to associate unique keys with values. Unlike HashSet<T>, which stores only keys, a Dictionary stores both a key and its corresponding value.

    How it works

    A Dictionary uses a hash table internally. When you add a key-value pair, the key is passed through a hash function to compute a hash code, which determines where in an internal array the pair is stored. This hash-based approach enables constant-time (O(1)) average lookup, insertion, and deletion.

    The dictionary maintains an array of "buckets." If two different keys hash to the same bucket (a hash collision), the dictionary uses chaining or linear probing to store both entries. Modern implementations typically use chaining with linked lists or similar techniques.

    Keys must be unique; attempting to add a duplicate key will throw an ArgumentException. Values, however, can be duplicated. The dictionary is mutable—you can add, remove, or update entries after creation. For thread-safe scenarios, consider using ConcurrentDictionary<TKey, TValue> from System.Collections.Concurrent.

    Example

    csharp
    
    var users = new Dictionary<int, string>
    {
        { 1, "Alice" },
        { 2, "Bob" },
        { 3, "Charlie" }
    };
    
    // Lookup by key (may throw KeyNotFoundException)
    string name = users[1]; // "Alice"
    
    // Safe lookup using TryGetValue
    if (users.TryGetValue(2, out string? value))
    {
        Console.WriteLine($"Found: {value}");
    }
    
    // Add or update
    users[4] = "Diana";
    users[1] = "Alicia"; // Updates existing key
    
    // Iterate over key-value pairs
    foreach (var kvp in users)
    {
        Console.WriteLine($"{kvp.Key}: {kvp.Value}");
    }
    
    // Remove
    users.Remove(2);
    

    This example creates a dictionary mapping user IDs to names, demonstrates safe lookups with TryGetValue, shows updates, iteration, and removal.

    Key points to mention

    • O(1) average lookup: Fast key-based retrieval makes dictionaries ideal for caches, lookups, and associations.
    • Use TryGetValue to avoid exceptions: Indexer access (dict[key]) throws KeyNotFoundException if the key is missing; TryGetValue(key, out var value) is safer.
    • Keys must be unique and override GetHashCode/Equals: Keys must implement meaningful GetHashCode() and Equals() for correct behavior with custom types.
    • Mutable and not thread-safe: Modifying a dictionary while iterating throws InvalidOperationException; use ConcurrentDictionary<TKey, TValue> for multi-threaded scenarios.

    Common follow-up questions

    • What is the difference between Dictionary and Hashtable? Hashtable is a non-generic, legacy collection that requires boxing/unboxing. Dictionary is generic, type-safe, and faster.
    • How do hash collisions affect performance? Excessive collisions degrade average lookup time. A good hash function minimizes collisions; the dictionary automatically resizes when load factor exceeds a threshold.
    • Can I use a custom class as a key? Yes, but it must override GetHashCode() and Equals() correctly. Immutable types are safer for keys to prevent hash code changes after insertion.

    Related interview questions

    .NETCsharp
  48. 48What is the difference between IEnumerable and IList?

    Beginner

    IEnumerable<T> is a contract for forward-only iteration through a collection, while IList<T> extends that capability with random access via indexing, a Count property, and the ability to add, remove, or modify elements. In other words, every IList<T> is an IEnumerable<T>, but not every IEnumerable<T> is an IList<T>.

    How it works

    IEnumerable<T> defines a single method: GetEnumerator(), which returns an IEnumerator<T>. This enumerator lets you move forward through items one at a time using MoveNext() and Current. You cannot go backward, jump to a specific position, or modify the collection during iteration (in most cases). This minimal interface is lightweight and can represent virtually any data source: arrays, lists, database queries, files, or even infinite streams.

    IList<T>, on the other hand, inherits from IEnumerable<T>> and ICollection<T>, adding:

    • this[int index] — get or set an element by position
    • int IndexOf(T item) — find an element's position
    • void Insert(int index, T item) — insert at a specific position
    • void RemoveAt(int index) — remove by position
    • Count property — know how many elements exist

    The trade-off is clear: IEnumerable<T> is simpler and more flexible (it can wrap lazy-evaluated streams), while IList<T> is more powerful but requires the underlying collection to support random access and modification.

    Example

    csharp
    // IEnumerable - iteration only
    IEnumerable<int> numbers = GetNumbers();
    foreach (var n in numbers)
    {
        Console.WriteLine(n);  // Works
    }
    // var first = numbers[0];  // ❌ Compile error: no indexer
    // numbers.Add(5);  // ❌ Compile error: no Add method
    
    // IList - full control
    IList<int> list = new List<int> { 1, 2, 3 };
    Console.WriteLine(list[0]);  // ✅ 1 - direct access
    list.Add(4);  // ✅ Now { 1, 2, 3, 4 }
    list.RemoveAt(1);  // ✅ Now { 1, 3, 4 }
    
    foreach (var n in list)  // ✅ IList is also IEnumerable
    {
        Console.WriteLine(n);
    }
    
    IEnumerable<int> GetNumbers() => Enumerable.Range(1, 3);  // Deferred execution

    This example shows that IList gives you indexing and modification, while IEnumerable only lets you iterate; IList is always usable where IEnumerable is expected.

    Key points to mention

    • IEnumerable<T> is the base contract; IList<T> inherits from it and adds indexing and modification capabilities.
    • Use IEnumerable<T> when you only need to read and iterate; it's cheaper and works with LINQ queries, lazy sequences, and streams.
    • Use IList<T> when you need random access, counting, inserting, or removing elements; concrete types like List<T> and array implementations support it.
    • IEnumerable<T> does not guarantee the collection is re-enumerable; some sources (like query results) may be consumed once.
    • Methods accepting IEnumerable<T> are more flexible and reusable; methods accepting IList<T> are more specialized but more powerful.

    Common follow-up questions

    • What about ICollection<T>? — It sits between them, adding Count, Add, Remove, and Contains, but no indexing. IList inherits from both ICollection and IEnumerable.
    • Can I use LINQ on both? — Yes, both work with LINQ because LINQ extension methods target IEnumerable<T>. However, .ToList() materializes the sequence into an IList<T>.
    • What about HashSet<T>? — It implements IEnumerable<T> and ICollection<T>, but not IList<T> (no indexing, no guaranteed order).

    Related interview questions

    .NETCsharplinq
  49. 49What is a HashSet<T>?

    Beginner

    A HashSet<T> is an unordered collection of unique elements that uses a hash table for fast lookups and insertions. It enforces uniqueness automatically and provides O(1) average-case performance for Contains, Add, and Remove operations, making it ideal for deduplication and membership testing.

    How it works

    HashSet<T> uses a hash table data structure internally. When you add an item, it computes a hash code for that item and stores it in a bucket based on that hash. When you check if an item exists (Contains), it computes the same hash code and checks that bucket—without scanning the entire collection. This gives you constant-time lookups.

    The key characteristic is uniqueness. If you try to add a duplicate item, the Add method returns false and the item is not added again. Equality is determined by the item's GetHashCode() and Equals() methods. Two objects are considered the same if they have the same hash code and return true from Equals().

    Unlike Dictionary<TKey,TValue>, a HashSet has no key-value pairs—it just stores values. It's also unordered, so you cannot rely on iteration order.

    Example

    csharp
    // Deduplication and membership testing
    HashSet<int> uniqueNumbers = new HashSet<int> { 1, 2, 3, 4, 5 };
    
    // Fast Contains check
    if (uniqueNumbers.Contains(3))
    {
        Console.WriteLine("3 is in the set");
    }
    
    // Add only adds if not present
    uniqueNumbers.Add(3);  // Returns false, not added
    uniqueNumbers.Add(6);  // Returns true, added
    
    // Remove
    uniqueNumbers.Remove(5);
    
    // Set operations
    HashSet<int> other = new HashSet<int> { 3, 6, 7 };
    uniqueNumbers.IntersectWith(other);  // Keep only common elements
    Console.WriteLine(string.Join(", ", uniqueNumbers));  // Output: 3, 6
    

    This example demonstrates adding unique numbers, checking membership in constant time, and performing set operations like intersection—all operations that would be inefficient with a List.

    Key points to mention

    • HashSet enforces uniqueness; duplicate adds are silently ignored (Add returns false)
    • Provides O(1) average-case performance for Add, Remove, and Contains—much faster than List for membership tests
    • Unordered collection; iteration order is not guaranteed and may change as the set is modified
    • Supports efficient set operations: Union, Intersect, Except, SymmetricExcept, IsSubsetOf, IsSupersetOf
    • Items must implement GetHashCode() and Equals() correctly for proper behavior; custom types should override both methods

    Common follow-up questions

    • How does HashSet handle hash collisions? — Multiple items with the same hash code are stored in the same bucket using a linked list or chain; Equals() is used to find the exact item within the bucket.
    • What's the difference between HashSet and List for checking if an item exists? — HashSet.Contains() is O(1) average; List.Contains() is O(n) because it must scan every element. For large collections and frequent lookups, HashSet is far superior.
    • When should I use HashSet versus Dictionary? — Use HashSet when you only need to store and check for unique values; use Dictionary when you need to store key-value pairs and look up values by key.

    Related interview questions

    Further reading

    .NETCsharpTesting
  50. 50What is LINQ?

    Beginner

    LINQ (Language Integrated Query) is a .NET feature that provides a unified, strongly-typed syntax for querying data from any source—collections, databases, XML, or web services. It introduces standard query operators like Where, Select, GroupBy, and OrderBy directly into the C# language, eliminating the need to learn different query syntaxes for different data sources.

    How it works

    LINQ bridges the gap between the imperative world of C# and the declarative world of data queries. When you write a LINQ query, the compiler translates it into method calls on objects that implement specific interfaces like IEnumerable or IQueryable.

    For in-memory collections, LINQ to Objects works directly with IEnumerable<T>. For databases, LINQ providers (such as Entity Framework Core) translate your query into SQL before executing it on the server. The key benefit is that you write the same query syntax regardless of the underlying data source—the provider handles the translation.

    LINQ supports two syntaxes: query syntax (resembling SQL) and method syntax (using lambda expressions and extension methods). Both are equally powerful; the choice depends on readability and preference. You can also learn more about deferred execution in LINQ, which means queries often don't execute until you enumerate the results.

    Example

    csharp
    var employees = new List<Employee>
    {
        new(1, "Alice", 75000),
        new(2, "Bob", 65000),
        new(3, "Charlie", 85000)
    };
    
    // Query syntax
    var highEarners = from emp in employees
                      where emp.Salary > 70000
                      orderby emp.Name
                      select emp.Name;
    
    // Method syntax (equivalent)
    var highEarnersMethod = employees
        .Where(e => e.Salary > 70000)
        .OrderBy(e => e.Name)
        .Select(e => e.Name);
    
    foreach (var name in highEarners)
        Console.WriteLine(name); // Output: Alice, Charlie
    
    record Employee(int Id, string Name, decimal Salary);

    This example demonstrates querying an in-memory list to find employees earning more than £70,000, ordered by name, returning only their names.

    Key points to mention

    • Language integration: LINQ is built into C# syntax, not a separate library, making queries feel natural and IntelliSense-friendly.
    • Provider-based: Different LINQ providers handle different data sources—LINQ to Objects, LINQ to SQL, Entity Framework (Entity Framework Core), LINQ to XML—using the same query syntax.
    • Strongly typed: Queries are checked at compile time, catching errors early and enabling refactoring with confidence.
    • Composable: You can chain multiple operators together, building complex queries incrementally.
    • Query and method syntax are interchangeable: Choose whichever reads better for your use case.

    Common follow-up questions

    • What is the difference between IEnumerable and IQueryable in LINQ?
    • How does LINQ to SQL differ from LINQ to Objects?
    • What are deferred execution and eager execution in LINQ, and when would you use ToList() or ToArray()?

    Related interview questions

    Further reading

    .NETCsharplinq
  51. 51What is the difference between LINQ query syntax and method syntax?

    Beginner

    LINQ query syntax and method syntax are two different ways to write the same LINQ queries. Query syntax resembles SQL with keywords like from, where, and select, while method syntax chains extension methods like Where(), Select(), and OrderBy(). Both compile to identical IL code—the compiler translates query syntax into method calls behind the scenes.

    How it works

    Query syntax is a declarative language integrated into C# that mimics SQL. It uses contextual keywords and reads naturally from left to right, making complex queries more readable. The compiler parses this syntax and converts it into method calls at compile time.

    Method syntax (also called fluent syntax) chains extension methods directly on collections. Each method returns an IEnumerable or IQueryable, allowing you to chain operations. This approach is more functional and gives you access to every overload of each method.

    Both syntaxes ultimately generate the same delegates and lambda expressions. The choice between them is largely about readability and personal preference. Method syntax offers more flexibility because you can use any extension method available, while query syntax is limited to keywords the compiler recognizes. Many developers mix both approaches in the same codebase—using query syntax for simple filters and method syntax for complex operations.

    Example

    csharp
    var employees = new List<Employee>
    {
        new Employee { Id = 1, Name = "Alice", Department = "IT", Salary = 75000 },
        new Employee { Id = 2, Name = "Bob", Department = "HR", Salary = 55000 },
        new Employee { Id = 3, Name = "Charlie", Department = "IT", Salary = 85000 }
    };
    
    // Query syntax
    var queryResult = from emp in employees
                      where emp.Department == "IT"
                      orderby emp.Salary descending
                      select new { emp.Name, emp.Salary };
    
    // Method syntax (identical result)
    var methodResult = employees
        .Where(emp => emp.Department == "IT")
        .OrderByDescending(emp => emp.Salary)
        .Select(emp => new { emp.Name, emp.Salary });
    
    foreach (var item in queryResult)
    {
        Console.WriteLine($"{item.Name}: ${item.Salary}");
    }
    

    Both approaches filter IT employees, sort by salary in descending order, and project only the name and salary—they compile to identical executable code.

    Key points to mention

    • Query syntax and method syntax produce the same compiled IL code; the difference is purely syntactic.
    • Query syntax reads more like SQL and is often preferred for complex filtering and joins; method syntax provides more control and flexibility.
    • LINQ deferred execution applies to both syntaxes—the query doesn't run until enumerated.
    • Method syntax allows access to all overloads and gives you granular control; query syntax is limited to recognized keywords.
    • Most real-world codebases mix both styles depending on what makes the code more readable in context.

    Common follow-up questions

    • Can you mix query and method syntax in one LINQ statement? Yes—you can start with query syntax and chain method calls, or vice versa. The compiler will handle the mix seamlessly.
    • Which syntax performs better? Both compile to identical code, so there is no performance difference. The choice is purely about readability and maintainability.
    • When should I use one over the other? Use query syntax for readable, multi-step filtering and joins; use method syntax for complex transformations, grouping, or when you need specific method overloads.

    Related interview questions

    Further reading

    .NETCsharpefcorelinq
  52. 52What is deferred execution in LINQ?

    Beginner

    Deferred execution in LINQ means that query execution is delayed until the results are actually enumerated or materialized. When you write a LINQ query, it builds an expression tree but does not run immediately. The query executes only when you call a materializing method like ToList(), Count(), ForEach(), or iterate through results with a foreach loop.

    How it works

    When you construct a LINQ query using LINQ methods or query syntax, you are building an IEnumerable<T> or IQueryable<T> object that represents the query logic. This is not yet executed. The query is held as a set of instructions, waiting to be evaluated. Execution occurs only when something consumes the enumerable—typically a foreach loop, a materializing method, or an aggregate operation. This design allows LINQ to be lazy and efficient, enabling features like filtering at the source and combining multiple operations before running. It also means the same query can produce different results if enumerated at different times, since the underlying data may have changed.

    Example

    csharp
    var numbers = new List<int> { 1, 2, 3, 4, 5 };
    
    // This query does NOT execute yet—it's deferred
    var evenNumbers = numbers.Where(n => n % 2 == 0);
    
    Console.WriteLine("Query defined, not executed");
    
    // Execution happens here when we enumerate
    foreach (var num in evenNumbers)
    {
        Console.WriteLine(num); // Output: 2, 4
    }
    
    // Executing again produces results again
    var count = evenNumbers.Count(); // Query runs a second time
    Console.WriteLine($"Count: {count}"); // Output: Count: 2
    
    // Materializing to a list—query runs a third time
    var list = evenNumbers.ToList(); // Execution happens here

    The query is defined but not executed on the third line; it runs each time we enumerate or materialize it, showing that deferred execution allows multiple evaluations of the same query expression.

    Key points to mention

    • Lazy evaluation: LINQ queries are lazy and execute only when consumed, reducing memory usage and improving performance.
    • Materializing methods: ToList(), ToArray(), Count(), First(), and ForEach() force immediate execution.
    • Multiple executions: Enumerating the same query multiple times can run the query repeatedly, potentially with different results if the data changes.
    • Side effects and debugging: Deferred execution can be surprising when debugging; a breakpoint in a Where clause may not be hit if the query is never enumerated.
    • Query compilation: Compiled queries in EF Core and PLINQ provide ways to optimize or eagerly execute queries when needed.

    Common follow-up questions

    • What is the difference between deferred and immediate execution? Deferred execution delays query evaluation until enumeration; immediate execution runs the query right away. Methods like ToList() make execution immediate.
    • Why might enumerating the same LINQ query multiple times be a problem? If the query runs against a changeable data source each time, results may differ. Also, repeated enumerations waste resources; it's often better to materialize once with ToList().
    • How does deferred execution affect performance in Entity Framework? It allows EF to combine filters and projections into a single database query, improving efficiency, but can also lead to unexpected N+1 queries if not managed carefully.

    Related interview questions

    Further reading

    .NETCsharplinq
  53. 53What is the difference between First and FirstOrDefault?

    Beginner

    First throws an InvalidOperationException if the sequence is empty or no element matches the condition, while FirstOrDefault returns the default value (typically null for reference types or the zero value for value types) when the sequence is empty or no match is found. Choose First when you expect a result and want to fail fast on missing data; choose FirstOrDefault when an empty result is a valid outcome.

    How it works

    Both First and FirstOrDefault are LINQ methods that retrieve a single element from a sequence. They differ fundamentally in error handling:

    First() immediately throws an exception if:

    • The sequence is empty
    • No element satisfies an optional predicate

    This design enforces fail-fast semantics—if you call First, you're asserting that a matching element must exist. If it doesn't, an exception bubbles up immediately, alerting you to an unexpected state.

    FirstOrDefault() handles absence gracefully by returning:

    • null for reference types
    • The default value (0, false, default) for value types
    • A custom default value if provided as an overload parameter

    This design is useful when missing data is anticipated or acceptable. You then check the returned value before proceeding, allowing your code to continue execution.

    The distinction also relates to nullable value types, since FirstOrDefault on a non-nullable value type returns its zero value, which may be ambiguous—you cannot distinguish between "no element found" and "an element with the zero value exists."

    Example

    csharp
    var numbers = new List<int> { 10, 20, 30 };
    
    // First - throws if no match
    try
    {
        var result = numbers.First(n => n > 50);
        Console.WriteLine(result);
    }
    catch (InvalidOperationException ex)
    {
        Console.WriteLine($"Exception: {ex.Message}");
        // Output: Exception: Sequence contains no matching element
    }
    
    // FirstOrDefault - returns default value
    var resultOrDefault = numbers.FirstOrDefault(n => n > 50);
    Console.WriteLine(resultOrDefault);  // Output: 0
    
    // FirstOrDefault with custom default
    var custom = numbers.FirstOrDefault(n => n > 50, -1);
    Console.WriteLine(custom);  // Output: -1
    
    // With reference types - returns null
    var words = new List<string> { "apple", "banana" };
    var found = words.FirstOrDefault(w => w.StartsWith("z"));
    Console.WriteLine(found ?? "Not found");  // Output: Not found
    

    The code demonstrates that First throws when no element matches, while FirstOrDefault safely returns a default value—zero for integers, null for strings—allowing continued execution.

    Key points to mention

    • First is safer when you expect data to exist; an exception signals a genuine problem early.
    • FirstOrDefault is safer when absence is valid; it lets you handle the null or zero value explicitly.
    • With value types, FirstOrDefault returning 0 or false does not tell you whether no element was found or if an element with that zero value exists—consider using nullable types or checking the count first.
    • Both methods accept an optional predicate for filtering: First(x => x.Property == value) or FirstOrDefault(x => x.Property == value).
    • Overloads of FirstOrDefault allow specifying a custom default value to return instead of the implicit zero value.

    Common follow-up questions

    • How does FirstOrDefault behave with nullable value types? — If you use List<int?>, FirstOrDefault returns null by default, making it clearer that no element was found versus finding an actual zero or null value.
    • What is the difference between First and Single? — First returns the first matching element; Single throws if there is more than one match or if the sequence is empty, guaranteeing exactly one result.
    • When should I use First versus FirstOrDefault in production code? — Use First for required data; use FirstOrDefault for optional data, then validate the result before using it.

    Related interview questions

    Further reading

    .NETCsharp
  54. 54What is the difference between Single and First?

    Beginner

    Single expects exactly one element in a sequence and throws an exception if there are zero or multiple matches, while First simply returns the first element it encounters and ignores any others. Use Single when you need to guarantee uniqueness; use First when you only care about the first match.

    How it works

    LINQ provides these two methods for extracting elements, but they have fundamentally different expectations and behaviors:

    Single() validates that the sequence contains exactly one element matching the predicate (or exactly one element total if no predicate is provided). If the sequence is empty, it throws InvalidOperationException. If the sequence contains more than one matching element, it also throws InvalidOperationException. This makes Single ideal for scenarios where you expect and enforce a one-to-one relationship.

    First() returns the first element in a sequence that matches the predicate (or the first element overall if no predicate is provided). If the sequence is empty, it throws InvalidOperationException. However, First does not care if there are additional matching elements—it stops after finding the first one and returns immediately.

    The key distinction is intent: Single asserts a constraint, while First simply retrieves. This difference affects performance, safety, and how you should use each method depending on your data expectations.

    Example

    csharp
    var users = new List<User>
    {
        new User { Id = 1, Email = "alice@example.com" },
        new User { Id = 2, Email = "bob@example.com" },
        new User { Id = 3, Email = "charlie@example.com" }
    };
    
    // Using First - returns the first user, ignores others
    var firstUser = users.First();
    Console.WriteLine(firstUser.Email); // Output: alice@example.com
    
    // Using First with predicate - returns first user with Id > 1
    var firstMatching = users.First(u => u.Id > 1);
    Console.WriteLine(firstMatching.Email); // Output: bob@example.com
    
    // Using Single - expects exactly one match
    try
    {
        var singleUser = users.Single(u => u.Id == 2);
        Console.WriteLine(singleUser.Email); // Output: bob@example.com
    }
    catch (InvalidOperationException ex)
    {
        Console.WriteLine("Error: " + ex.Message);
    }
    
    // This will throw because multiple users have Id > 1
    try
    {
        var singleMatching = users.Single(u => u.Id > 1);
    }
    catch (InvalidOperationException ex)
    {
        Console.WriteLine("Error: Sequence contains more than one element"); // This executes
    }
    

    In this example, First returns the first matching user regardless of how many others exist, while Single validates that exactly one match exists and throws an exception if that constraint is violated.

    Key points to mention

    • Exception handling: Both throw InvalidOperationException on empty sequences, but Single also throws if multiple matches exist.
    • Performance: First is generally faster because it stops after finding one element, while Single must scan the entire sequence to verify uniqueness.
    • Use cases: Use Single when enforcing business rules (e.g., "get the admin user" where only one should exist); use First when simply retrieving the earliest match.
    • Alternatives: Consider FirstOrDefault and SingleOrDefault to avoid exceptions on empty sequences.
    • Predicate behavior: Both methods accept optional predicates to filter before matching; the filtering logic is identical, only the validation differs.

    Common follow-up questions

    • What is the difference between FirstOrDefault and SingleOrDefault?
    • When would you choose Single over First in a real application?
    • What performance impact does Single have compared to First on large datasets?

    Related interview questions

    .NETCsharp
  55. 55What is a lambda expression?

    Beginner

    A lambda expression is an inline, anonymous function that uses the => operator to define a concise syntax for creating delegates or expression trees. Lambdas are fundamental to modern C# and are extensively used with LINQ, event handlers, and asynchronous operations.

    How it works

    A lambda expression is essentially a shorthand way to write an anonymous method without the verbose delegate keyword. The syntax is: (parameters) => expression or statement body.

    When you write a lambda, the compiler infers the parameter types and return type based on context. The lambda is converted into either a Func<T, TResult>, Action<T>, or an expression tree depending on how it's used.

    Lambdas enable functional programming patterns in C#. They capture variables from their enclosing scope (closure), allowing the function to access and modify outer state. When a lambda captures variables, the compiler creates a hidden class to store those values, which is important to understand for memory management.

    The expression form (single line, no braces) returns a value implicitly. The statement form (with braces) can contain multiple statements and requires an explicit return keyword if needed.

    Example

    csharp
    // Simple lambda with LINQ
    var numbers = new[] { 1, 2, 3, 4, 5 };
    var squared = numbers.Where(x => x > 2).Select(x => x * x);
    
    // Lambda with multiple parameters
    Func<int, int, int> add = (a, b) => a + b;
    var result = add(5, 3); // Returns 8
    
    // Lambda with statement body
    Func<int, string> getGrade = score =>
    {
        if (score >= 90) return "A";
        if (score >= 80) return "B";
        return "C";
    };
    
    // Lambda capturing outer variable
    int multiplier = 10;
    Func<int, int> multiplyBy = x => x * multiplier;
    Console.WriteLine(multiplyBy(5)); // Outputs 50
    

    This code demonstrates simple filtering and transformation with lambdas, multi-parameter lambdas assigned to delegates, statement-body lambdas with complex logic, and variable capturing from the outer scope.

    Key points to mention

    • Lambdas are anonymous functions that provide concise syntax compared to named methods or anonymous delegates
    • They are primarily used with LINQ (Where, Select, GroupBy, etc.) and make data queries more readable
    • Lambdas capture variables from their enclosing scope, creating closures that can lead to unexpected behavior if you're not careful with mutable captured state
    • The compiler infers parameter and return types automatically, but you can specify them explicitly if needed
    • Lambdas can be converted to expression trees, which allows LINQ providers to analyze and execute them as database queries (LINQ to SQL, Entity Framework)

    Common follow-up questions

    • What is the difference between Func<T, TResult> and Action<T>? (Func returns a value; Action returns void)
    • How do captured variables in lambdas relate to memory leaks in managed code?
    • What is the difference between lambda expressions and expression trees, and when would you use each?

    Related interview questions

    .NETCsharpArchitecturelinq
  56. 56What is a delegate?

    Beginner

    A delegate is a type-safe reference type that defines the signature of a method. It allows you to pass methods as parameters to other methods, store them in variables, and invoke them later. Delegates form the foundation of the event system in .NET and enable callback patterns, functional programming styles, and decoupling of components.

    How it works

    A delegate acts as a contract that specifies the return type and parameters a method must have. When you declare a delegate, you're creating a new type that can hold a reference to any method matching that signature. This is similar to a function pointer in C++, but type-safe.

    At runtime, when you assign a method to a delegate variable, you're creating a delegate instance that wraps that method. You can then invoke the delegate as if you were calling the method directly. Multiple methods can be combined into a single delegate using the += operator, creating a multicast delegate that invokes all registered methods in sequence.

    Delegates enable several important patterns: callbacks (passing a method to be called later), event handling (notifying subscribers of state changes), and functional composition (chaining operations). The .NET Framework provides built-in generic delegates like Action and Func that cover most common scenarios, reducing the need to declare custom delegate types.

    Example

    csharp
    // Define a delegate type
    public delegate void NotificationDelegate(string message);
    
    // Method matching the delegate signature
    public static void SendEmail(string message) => Console.WriteLine($"Email: {message}");
    public static void SendSms(string message) => Console.WriteLine($"SMS: {message}");
    
    // Usage
    NotificationDelegate notify = SendEmail;
    notify("Hello!");  // Output: Email: Hello!
    
    // Add another method (multicast)
    notify += SendSms;
    notify("Alert");  // Invokes both SendEmail and SendSms
    
    // Modern approach using Action (no need to declare delegate type)
    Action<string> modernNotify = SendEmail;
    modernNotify("Modern way");
    

    This example shows a custom delegate type, assigning a method to it, invoking it, and combining multiple methods. The modern approach uses the built-in Action<T> generic delegate, which is cleaner and more commonly used today.

    Key points to mention

    • Type-safety: The compiler ensures only methods with matching signatures can be assigned to a delegate
    • Multicast capability: Multiple methods can be combined using += and -=, creating chains of invocations
    • Foundation for events: Events in .NET are built on delegates; an event is essentially a delegate with restricted access (can only be invoked from the declaring class)
    • Generic alternatives: Action<T> (no return value) and Func<T, TResult> (returns a value) cover most use cases and are preferred over custom delegate declarations
    • Enables functional programming: Delegates allow passing behavior as data, supporting lambda expressions and LINQ operations

    Common follow-up questions

    • What is the difference between a delegate and an event? (Events restrict who can invoke the delegate)
    • How do lambda expressions relate to delegates? (Lambdas are anonymous functions commonly assigned to delegates)
    • What is the difference between Action and Func? (Action performs an operation with no return value; Func returns a result)

    Related interview questions

    .NETCsharpArchitecture
  57. 57What are Func, Action and Predicate?

    Beginner

    Func, Action, and Predicate are built-in generic delegate types in .NET that simplify working with methods as first-class objects. Action represents a method that returns void, Func represents a method that returns a value, and Predicate represents a method that returns a boolean. They eliminate the need to declare custom delegate types for common scenarios.

    How it works

    Delegates are type-safe function pointers that allow you to pass methods around as parameters or store them as variables. Before generics, you'd define a custom delegate type for each method signature. The framework team recognized three patterns were so common they built them in as generic delegates.

    Action<T> represents a method that takes parameters of type T and returns nothing (void). It has 16 overloads: Action (no parameters), Action<T>, Action<T1, T2>, and so on up to 16 parameters.

    Func<T, TResult> represents a method that takes parameters and returns a value of type TResult. The last type parameter is always the return type. Like Action, it supports overloads from zero to 16 input parameters.

    Predicate<T> is a specialized version of Func that takes a single parameter and returns bool. It's commonly used for filtering and searching operations. Under the hood, Predicate<T> is equivalent to Func<T, bool>, but its name makes the intent clearer.

    All three are reference types stored on the heap, allowing them to be passed between methods and stored in collections.

    Example

    csharp
    // Action: takes parameters, returns void
    Action<string> greet = name => Console.WriteLine($"Hello, {name}!");
    greet("Alice"); // Output: Hello, Alice!
    
    // Func: takes parameters, returns a value
    Func<int, int, int> add = (a, b) => a + b;
    int result = add(5, 3); // result = 8
    
    // Predicate: takes one parameter, returns bool
    Predicate<int> isEven = x => x % 2 == 0;
    bool check = isEven(4); // check = true
    
    // Practical usage with LINQ
    List<int> numbers = [1, 2, 3, 4, 5];
    var evenNumbers = numbers.Where(x => x % 2 == 0).ToList(); // [2, 4]
    var squared = numbers.Select(x => x * x).ToList(); // [1, 4, 9, 16, 25]
    

    This example shows how Action executes an operation with side effects, Func transforms input into output, and Predicate filters collections based on a condition.

    Key points to mention

    • Action returns void, Func returns a value, Predicate<T> returns bool—choose based on what your method needs to do
    • They support lambda expressions and method groups, making code more concise than declaring custom delegates
    • Predicate<T> is semantically equivalent to Func<T, bool> but signals intent when used for filtering or validation
    • All three integrate seamlessly with LINQ methods like Where, Select, Find, and ForEach
    • Type parameters are generic, so they work with any type—primitives, classes, structs, or custom types

    Common follow-up questions

    • What's the difference between Predicate<T> and Func<T, bool>? When would you use one over the other?
    • Can you pass an async method as an Action or Func? What happens?
    • How do delegates relate to the delegate keyword versus lambda expressions?

    Related interview questions

    Further reading

    .NETCsharp
  58. 58What is an event in C#?

    Beginner

    An event in C# is a wrapper around a delegate that allows a class to notify subscribers when something happens. It enforces encapsulation by restricting external code to only subscribe (+=) or unsubscribe (-=), preventing them from reassigning or invoking the delegate directly. Events are a core pattern for implementing the observer design pattern and enabling loose coupling between components.

    How it works

    An event is built on top of delegates. A delegate defines the signature (parameters and return type) that event handlers must match. When you declare an event, you specify a delegate type, and only the class that owns the event can invoke it. External subscribers can only attach their handlers (methods) to the event using the += operator or detach them using the -= operator.

    This differs from exposing a delegate directly. If you expose a public delegate field, external code could reassign it entirely (wiping out all subscribers) or invoke it from outside. Events prevent both of these problems by making the delegate accessible only within the class, while providing controlled subscription and unsubscription access to the outside world.

    Events follow a publish-subscribe pattern: the publisher (the class that owns the event) raises the event when a condition occurs, and all subscribers receive notification by having their handler methods called automatically.

    Example

    csharp
    public delegate void OrderEventHandler(string message);
    
    public class OrderProcessor
    {
        // Event declaration
        public event OrderEventHandler OrderPlaced;
        public event OrderEventHandler OrderShipped;
    
        public void PlaceOrder(string orderId)
        {
            Console.WriteLine($"Processing order: {orderId}");
            // Raise the event (only possible within this class)
            OnOrderPlaced($"Order {orderId} has been placed");
        }
    
        public void ShipOrder(string orderId)
        {
            Console.WriteLine($"Shipping order: {orderId}");
            OnOrderShipped($"Order {orderId} has been shipped");
        }
    
        // Protected method to raise the event
        protected virtual void OnOrderPlaced(string message)
        {
            OrderPlaced?.Invoke(message);
        }
    
        protected virtual void OnOrderShipped(string message)
        {
            OrderShipped?.Invoke(message);
        }
    }
    
    // Usage
    var processor = new OrderProcessor();
    
    // Subscribe to events
    processor.OrderPlaced += (message) => Console.WriteLine($"[Notification] {message}");
    processor.OrderPlaced += (message) => Console.WriteLine($"[Email] Sending email: {message}");
    processor.OrderShipped += (message) => Console.WriteLine($"[SMS] Sending SMS: {message}");
    
    processor.PlaceOrder("ORD-123");
    processor.ShipOrder("ORD-123");
    
    // This would cause a compile error:
    // processor.OrderPlaced = null;  // Cannot reassign
    // processor.OrderPlaced.Invoke("test");  // Cannot invoke from outside

    This example demonstrates a publisher (OrderProcessor) raising events when orders are placed or shipped, and multiple subscribers receiving notifications through their subscribed handlers.

    Key points to mention

    • Built on delegates: Events use a delegate type to define the signature that handlers must match.
    • Encapsulation: Only the class owning the event can raise it; external code can only subscribe and unsubscribe.
    • Loose coupling: Publishers don't need to know the details of subscribers, promoting maintainability and extensibility.
    • Thread safety: Modern C# uses the null-conditional operator (?.Invoke()) to safely invoke events even if no subscribers exist.
    • Event naming convention: Event names typically describe what happened (past tense), like OrderPlaced or ButtonClicked, and handlers follow the pattern On[EventName].

    Common follow-up questions

    • What is the difference between an event and a delegate? (Events prevent external reassignment; delegates don't.)
    • How do you unsubscribe from an event, and why is it important? (Use -= to unsubscribe and prevent memory leaks from dangling event handlers.)
    • What is the EventHandler and EventHandler<T> pattern? (Standard delegate types used for events in the .NET framework, with specific signatures.)

    Related interview questions

    Further reading

    .NETCsharpArchitecture
  59. 59What is an extension method?

    Beginner

    An extension method allows you to add new methods to existing types without modifying their source code or creating a derived class. It is a static method defined in a static class with the this keyword on its first parameter, which specifies the type being extended.

    How it works

    Extension methods provide a way to extend functionality of sealed types, built-in types, and third-party classes that you cannot modify directly. When you define an extension method, the compiler translates the call into a static method invocation. For example, when you call myString.IsNullOrEmpty(), the compiler converts it to StringExtensions.IsNullOrEmpty(myString). This syntactic sugar makes the code more readable and intuitive, as if the method belonged to the original type.

    The this keyword in the first parameter is crucial—it tells the compiler which type this method extends. The remaining parameters are regular method parameters that can be passed when calling the extension method. Extension methods must be defined in a static class and must themselves be static.

    Extension methods are discovered through the namespace in which they are defined. If you want to use an extension method, you must either have it in the same namespace or explicitly import the namespace with a using statement. This prevents accidental pollution of the global method space.

    Example

    csharp
    using System;
    
    // Extension method defined in a static class
    public static class StringExtensions
    {
        public static bool IsValidEmail(this string value)
        {
            return value.Contains("@") && value.Contains(".");
        }
    
        public static string Reverse(this string value)
        {
            char[] charArray = value.ToCharArray();
            Array.Reverse(charArray);
            return new string(charArray);
        }
    }
    
    class Program
    {
        static void Main()
        {
            string email = "user@example.com";
            
            // Calling extension methods as if they belong to string
            if (email.IsValidEmail())
                Console.WriteLine("Valid email");
            
            Console.WriteLine(email.Reverse()); // Output: moc.elpmaxe@resu
        }
    }

    This example defines two extension methods for the string type: one to validate email format and one to reverse a string. The methods can be called directly on any string variable, making the code more fluent and readable.

    Key points to mention

    • Extension methods must be declared in a static class and must be static themselves
    • The first parameter uses the this keyword to specify which type is being extended
    • They work on any type: built-in types, sealed classes, third-party libraries, and your own classes
    • Extension methods are resolved at compile time, not runtime, so they do not affect performance
    • They are commonly used in LINQ, where methods like Where(), Select(), and GroupBy() are extensions on IEnumerable<T>

    Common follow-up questions

    • Can you use extension methods with interfaces or only classes? (Yes, both—you extend the interface type itself)
    • What happens if an extension method has the same name as an instance method on the type? (Instance methods take precedence; the extension is ignored)
    • Can extension methods access private members of the class they extend? (No, they have the same access restrictions as any external code)

    Related interview questions

    Further reading

    .NETCsharp
  60. 60What is exception handling in C#?

    Beginner

    Exception handling in C# is a mechanism to gracefully manage runtime errors using try-catch-finally blocks. It allows you to detect errors (exceptions), handle them appropriately, and ensure cleanup code runs regardless of success or failure. Proper exception handling prevents application crashes and enables recovery strategies.

    How it works

    When an error occurs in a try block, the runtime creates an exception object and looks for a matching catch block. If found, the catch block executes; if not, the exception propagates up the call stack. The finally block (if present) always executes, whether an exception occurred or not, making it ideal for resource cleanup.

    C# uses a hierarchical exception model where all exceptions inherit from System.Exception. You should catch specific exceptions (like FileNotFoundException or ArgumentNullException) before general ones (like Exception). This specificity allows targeted recovery—for example, retrying a failed network request differently than handling invalid input.

    Modern C# offers the using statement and using declaration for automatic resource disposal, which work alongside exception handling to ensure that resources like file streams or database connections are released even when exceptions occur. The difference between Results.Problem and throwing exceptions relates to how APIs signal errors: exceptions are for exceptional, unrecoverable conditions, while Result patterns are often preferred in modern API design.

    Example

    csharp
    public class FileProcessor
    {
        public string ReadConfigFile(string path)
        {
            try
            {
                using var reader = new StreamReader(path);
                return reader.ReadToEnd();
            }
            catch (FileNotFoundException ex)
            {
                Console.WriteLine($"Config file not found: {ex.Message}");
                return string.Empty;
            }
            catch (UnauthorizedAccessException ex)
            {
                Console.WriteLine($"Access denied: {ex.Message}");
                return string.Empty;
            }
            catch (Exception ex)
            {
                Console.WriteLine($"Unexpected error: {ex.Message}");
                throw;
            }
            finally
            {
                Console.WriteLine("File read attempt completed.");
            }
        }
    }
    
    // Usage
    var processor = new FileProcessor();
    string config = processor.ReadConfigFile("appsettings.json");

    This example demonstrates catching specific exceptions first (file not found, access denied), then a general catch-all that rethrows to propagate unexpected errors. The finally block logs completion, and the using declaration automatically disposes the StreamReader.

    Key points to mention

    • Catch specific exceptions first—let unexpected exceptions bubble up rather than silently swallowing them with a bare catch (Exception).
    • Finally always runs—use it (or using statements) for guaranteed cleanup of resources, connections, or file handles.
    • Exceptions are for exceptional cases—don't use them for normal control flow; they're expensive and indicate something went wrong.
    • Exception details matter—include the message, stack trace, and inner exception when logging or rethrowing so debugging is easier.
    • Consider using Result patterns in modern APIs instead of throwing exceptions for expected error conditions, improving clarity and testability.

    Common follow-up questions

    • What's the difference between throw and throw ex?
    • When should I use finally vs. using statements?
    • How do you implement global exception handling?

    Related interview questions

    Further reading

    .NETCsharp
  61. 61What is the difference between throw and throw ex?

    Beginner

    throw; rethrows the current exception while preserving its original stack trace, making debugging easier. throw ex; rethrows the exception but resets the stack trace to the point where throw ex; is called, losing information about where the error originally occurred. Always use throw; without arguments when rethrowing exceptions in a catch block.

    How it works

    When an exception is thrown in .NET, the runtime captures a stack trace showing the exact sequence of method calls that led to the error. This stack trace is invaluable for debugging because it shows the complete call chain from where the exception originated to where it was caught.

    When you use throw; (bare throw statement) inside a catch block, the CLR rethrows the same exception object with its original stack trace intact. The stack trace still points to the original location where the exception was first thrown, preserving the complete debugging information.

    When you use throw ex; (where ex is the caught exception variable), you are explicitly throwing the exception object again, but this creates a new throw point. The runtime updates the stack trace to start from this new throw statement, effectively erasing information about all the intermediate calls between the original throw and the catch block. This makes it significantly harder to trace back to the root cause of the problem.

    Modern versions of .NET and Visual Studio partially mitigate this issue by preserving an "inner" exception reference, but using throw ex; still loses critical stack trace information that was present in the original exception.

    Example

    csharp
    public class StackTraceExample
    {
        public static void Main()
        {
            try
            {
                MethodA();
            }
            catch (Exception ex)
            {
                Console.WriteLine("Stack trace:");
                Console.WriteLine(ex.StackTrace);
            }
        }
    
        public static void MethodA() => MethodB();
        public static void MethodB() => MethodC();
    
        public static void MethodC()
        {
            try
            {
                throw new InvalidOperationException("Original error");
            }
            catch (Exception ex)
            {
                // GOOD: Preserves original stack trace
                throw;
                
                // BAD: Resets stack trace (uncomment to see difference)
                // throw ex;
            }
        }
    }
    

    When using throw;, the stack trace shows MethodA → MethodB → MethodC. With throw ex;, the stack trace only shows the catch block in MethodC, hiding the earlier methods in the call chain.

    Key points to mention

    • Use throw; (bare throw) to preserve the original stack trace when rethrowing in a catch block
    • throw ex; resets the stack trace, making it harder to debug and understand the root cause
    • The stack trace is critical diagnostic information that shows the complete call sequence leading to an exception
    • Only throw a new exception explicitly if you're transforming the error or wrapping it in a custom custom exception
    • Most exception handling best practices recommend avoiding unnecessary rethrows altogether

    Common follow-up questions

    • When should you create a new exception instead of rethrowing?
    • What is the difference between using throw; versus wrapping an exception in InnerException?
    • How does ExceptionDispatchInfo help preserve stack traces in async code?

    Related interview questions

    .NETCsharp
  62. 62What is a custom exception?

    Beginner

    A custom exception is a class that inherits from the Exception class (or a more specific exception type) and represents a domain-specific error condition in your application. Unlike built-in exceptions like ArgumentNullException or InvalidOperationException, custom exceptions allow you to throw and catch application-specific scenarios—such as OrderNotFoundException or InsufficientInventoryException—making error handling more precise and your code's intent clearer.

    How it works

    When you create a custom exception, you define a new class that inherits from Exception or another exception type. This allows you to encapsulate domain logic and error details relevant to your business context. Custom exceptions are typically thrown when a specific business rule is violated or an expected operation fails in a way that your application needs to handle differently.

    The benefit is semantic clarity: instead of throwing a generic InvalidOperationException when an order cannot be found, you throw OrderNotFoundException, signaling to callers exactly what went wrong. Calling code can then catch that specific exception and respond appropriately—perhaps logging, returning a 404 response, or retrying with different parameters.

    Custom exceptions follow the same exception handling mechanisms as built-in exceptions: they propagate up the call stack until caught by a try-catch block or handled by a global error handler. They can include additional properties to provide context—like an order ID, the requested quantity, or a custom error code—helping downstream handlers make informed decisions.

    Example

    csharp
    // Define a custom exception
    public class OrderNotFoundException : Exception
    {
        public int OrderId { get; }
    
        public OrderNotFoundException(int orderId)
            : base($"Order with ID {orderId} was not found.")
        {
            OrderId = orderId;
        }
    
        public OrderNotFoundException(int orderId, Exception innerException)
            : base($"Order with ID {orderId} was not found.", innerException)
        {
            OrderId = orderId;
        }
    }
    
    // Usage
    public class OrderService
    {
        public Order GetOrder(int orderId)
        {
            var order = _database.FindOrder(orderId);
            if (order == null)
                throw new OrderNotFoundException(orderId);
    
            return order;
        }
    }
    
    // Calling code catches the specific exception
    try
    {
        var order = orderService.GetOrder(999);
    }
    catch (OrderNotFoundException ex)
    {
        Console.WriteLine($"Could not retrieve order {ex.OrderId}");
    }
    

    This example defines OrderNotFoundException with a custom property (OrderId) and multiple constructors to support inner exceptions, making it easy for calling code to handle missing orders specifically and access context about what failed.

    Key points to mention

    • Inherit from Exception or a specific base: Always derive from Exception or a more targeted base class like InvalidOperationException; follow .NET naming conventions by ending with "Exception".
    • Provide constructors for flexibility: Include constructors that accept a message, an inner exception, or both, allowing callers to wrap or pass through root causes.
    • Include contextual properties: Add public properties (like OrderId) that give callers specific details about what failed, reducing the need to parse error messages.
    • Use for domain-specific errors: Reserve custom exceptions for business logic failures and expected edge cases, not for control flow or catching programming mistakes.
    • Keep them lightweight: Custom exceptions should not contain heavy business logic; they are signals, not solutions.

    Common follow-up questions

    • Should I always create a custom exception, or use built-in ones? Use built-in exceptions for framework-level errors (ArgumentNullException, InvalidOperationException). Create custom exceptions only when you need specific, domain-aware handling that callers will benefit from catching distinctly.
    • Can I add static methods or factory patterns to custom exceptions? Yes, though it is uncommon. You might create a static factory to standardize logging or wrap multiple error scenarios, but keep this minimal; exceptions should primarily be data carriers for error context.
    • How do custom exceptions interact with middleware or global error handlers? Middleware and error-handling filters can catch custom exceptions and translate them into appropriate HTTP responses—for example, converting OrderNotFoundException to a 404 status code.

    Related interview questions

    .NETCsharp
  63. 63What is the using statement?

    Beginner

    The using statement is a C# language construct that ensures the Dispose() method is called on IDisposable objects when execution leaves the using block, even if an exception occurs. This guarantees proper cleanup of unmanaged resources like file handles, database connections, and network streams without requiring explicit try-finally blocks. The modern using var declaration form (C# 8+) automatically disposes the object at the end of its scope.

    How it works

    When you declare a variable with using, the compiler generates code that wraps your block in an implicit try-finally statement. After the using block exits—whether normally, via return, or due to an exception—the finally block calls Dispose() on the resource.

    There are two forms of the using statement:

    1. Traditional using statement: using (Type variable = new Type()) { } — disposes when the block ends
    2. Using declaration (C# 8+): using Type variable = new Type(); — disposes at the end of the enclosing scope (method, class, etc.)

    The compiler essentially transforms using (var resource = GetResource()) { /* code */ } into:

    csharp
    var resource = GetResource();
    try
    {
        /* code */
    }
    finally
    {
        resource?.Dispose();
    }

    This pattern is critical because many .NET types manage unmanaged resources—file handles, database connections, streams—that won't be cleaned up by the garbage collector alone. Without proper disposal, you can leak resources, exhaust connection pools, or leave files locked.

    Example

    csharp
    // Traditional using statement
    using (var file = new StreamWriter("output.txt"))
    {
        file.WriteLine("Hello, World!");
    } // Dispose() called automatically here
    
    // Modern using declaration (C# 8+)
    using var connection = new SqlConnection("Server=localhost");
    connection.Open();
    // ... use connection
    // Dispose() called at end of method scope
    
    // Multiple resources
    using var reader = new StreamReader("input.txt");
    using var writer = new StreamWriter("output.txt");
    {
        string line = reader.ReadLine();
        if (line != null) writer.WriteLine(line);
    } // Both disposed automatically

    In this example, the StreamWriter and StreamReader objects are automatically disposed when their using scopes end, ensuring file handles are released even if an exception occurs during file operations.

    Key points to mention

    • The using statement requires the object to implement IDisposable interface; it calls Dispose() automatically
    • Exception safety is built-in: Dispose() is called even if an unhandled exception occurs within the block
    • Using declarations (C# 8+) dispose at the end of scope rather than at the end of a block, making the code cleaner and less nested
    • Multiple using declarations can be chained to manage several resources in order (disposed in reverse declaration order)
    • You can use resource?.Dispose() syntax to safely dispose nullable resources

    Common follow-up questions

    • What happens if Dispose() throws an exception? The exception propagates; if you need to dispose multiple resources safely despite exceptions, use nested using statements or consider a finally block
    • What's the difference between using statement and try-finally? Both call Dispose() in finally, but using is syntactic sugar that's cleaner and less error-prone
    • Do I need using for every IDisposable object? Yes, or you risk resource leaks; most .NET framework classes that manage resources implement IDisposable and should be wrapped in using

    Related interview questions

    .NETCsharpasync
  64. 64What is IDisposable?

    Beginner

    IDisposable is a .NET interface that defines a Dispose() method for deterministically releasing unmanaged resources like file handles, database connections, and network sockets. Instead of waiting for the garbage collector to run, you explicitly call Dispose() to free these resources immediately, preventing resource leaks and improving application performance.

    How it works

    The IDisposable interface contains a single method: void Dispose(). When a class implements this interface, it promises to clean up unmanaged resources when Dispose() is called. Unmanaged resources are anything not managed by the CLR—think file handles from the operating system, database connection sockets, or memory allocated via P/Invoke.

    The garbage collector doesn't know about these resources and won't clean them up automatically. Without Dispose(), a file might stay locked, a database connection might remain open, or system memory might leak until the application terminates. By implementing IDisposable, your class provides a way for consumers to explicitly free these resources when done.

    The pattern is often combined with the using statement (or declaration in C# 8+), which automatically calls Dispose() when the variable goes out of scope, even if an exception occurs.

    Example

    csharp
    public class FileProcessor : IDisposable
    {
        private FileStream? _fileStream;
    
        public void OpenFile(string path)
        {
            _fileStream = new FileStream(path, FileMode.Open);
        }
    
        public void Dispose()
        {
            _fileStream?.Dispose();
            GC.SuppressFinalize(this);
        }
    }
    
    // Usage
    using (var processor = new FileProcessor())
    {
        processor.OpenFile("data.txt");
        // File is open here
    } // Dispose() is called automatically here
    

    This example shows a class managing a FileStream (an unmanaged resource). The using statement automatically calls Dispose() when the block exits, closing the file handle immediately.

    Key points to mention

    • Unmanaged resources only: Use IDisposable for OS handles, file streams, database connections, and P/Invoke memory—not for regular managed objects.
    • Deterministic cleanup: Dispose() lets you control exactly when resources are freed, rather than waiting for garbage collection.
    • Using statement integration: The using keyword automatically calls Dispose(), making the pattern safe even if exceptions occur.
    • Best practice is the full pattern: The standard full Dispose pattern includes a finalizer and separate protected Dispose(bool) method for inheritance safety.
    • IAsyncDisposable: For async cleanup operations, IAsyncDisposable provides DisposeAsync() instead.

    Common follow-up questions

    • Why not just use a finalizer? Finalizers run unpredictably after garbage collection; Dispose() runs immediately when called.
    • What's the difference between Close() and Dispose()? Some classes (like FileStream) have both, but Dispose() is the standard interface and should always be preferred.
    • Do I need to implement both Dispose() and a finalizer? Only if your class holds unmanaged resources directly; if it only holds IDisposable objects, you don't need a finalizer.

    Related interview questions

    .NETCsharpPerformance
  65. 65What is garbage collection?

    Beginner

    Garbage collection (GC) is the automatic memory management process that frees heap memory occupied by objects that the application no longer needs. The .NET runtime tracks object references and periodically reclaims memory from objects that are unreachable, eliminating the need for manual memory deallocation like in C++. This prevents memory leaks and makes .NET development safer and more productive.

    How it works

    The garbage collector operates by marking objects as reachable or unreachable. It starts with root references (local variables, static fields, CPU registers) and traverses the object graph to identify all objects still in use. Any object not found in this traversal is considered unreachable and its memory can be reclaimed.

    .NET uses a generational garbage collection strategy to optimize performance. Objects are organized into three generations (0, 1, and 2). Generation 0 contains newly created objects and is collected frequently—most short-lived objects die here and are cleaned up cheaply. Generation 1 is an intermediate stage for objects that survived one collection. Generation 2 holds long-lived objects collected less frequently. This strategy works because most objects are short-lived; by focusing collection effort on Generation 0, the GC minimizes pause times.

    The garbage collector also compacts the heap after collection, moving surviving objects together in memory to reduce fragmentation and improve cache locality. This process is deterministic in the sense that you can't predict exactly when a collection occurs, but you can request one explicitly if needed (though rarely recommended).

    Example

    csharp
    public class MemoryExample
    {
        public static void Main()
        {
            // Create objects
            var person1 = new Person { Name = "Alice" };
            var person2 = new Person { Name = "Bob" };
            
            // person1 goes out of scope and becomes unreachable
            person1 = null;
            
            // At some point, GC runs and reclaims memory for the original person1 object
            // person2 remains reachable and its memory is not freed
            
            Console.WriteLine(person2.Name); // Output: Bob
        }
    }
    
    public class Person
    {
        public string Name { get; set; }
    }
    

    In this example, when person1 is set to null, the original Person object it referenced becomes unreachable. The garbage collector will eventually collect that object's memory, while the object referenced by person2 remains safe because it's still reachable.

    Key points to mention

    • Automatic memory management: Developers don't manually free memory; the runtime handles it transparently.
    • Generational strategy: Objects are divided into generations so young, short-lived objects are collected more frequently than old, long-lived ones.
    • Heap compaction: After collecting dead objects, the GC moves surviving objects together, reducing fragmentation and improving performance.
    • Heap allocation only: GC only manages heap memory; stack memory (value types) is freed when they go out of scope.
    • Non-deterministic timing: You cannot predict when GC runs, though you can request it with GC.Collect() (rarely recommended in production).

    Common follow-up questions

    • What causes memory leaks in managed code if garbage collection is automatic?
    • How does the garbage collector handle objects with destructors or IDisposable?
    • Can you force garbage collection, and when would you do it?

    Related interview questions

    .NETCsharpPerformance
  66. 66What are GC generations?

    Beginner

    GC generations are a memory management optimization in .NET where objects are categorized into three age groups—Generation 0, 1, and 2—based on how long they've survived garbage collection cycles. The garbage collector prioritizes collecting younger generations more frequently because most objects are short-lived, making the system more efficient than collecting all memory every time.

    How it works

    When the .NET garbage collector (GC) runs, it doesn't treat all objects equally. Instead, it uses a generational hypothesis: most objects die young, and those that survive collection are likely to live longer.

    Generation 0 is where all new objects are allocated. When memory pressure triggers a collection, Gen 0 is collected frequently and aggressively. Objects that survive a Gen 0 collection are promoted to Generation 1.

    Generation 1 acts as a buffer zone. It contains objects that survived at least one collection. Gen 1 is collected less frequently than Gen 0, and when it is collected, both Gen 0 and Gen 1 are examined. Objects surviving a Gen 1 collection move to Generation 2.

    Generation 2 holds long-lived objects—those that have survived multiple collections. Gen 2 is collected infrequently, typically only when memory is critically needed. A full garbage collection examines all three generations.

    This tiered approach means the GC spends most time on the youngest generation where most garbage is found, rather than repeatedly scanning long-lived objects that rarely need collection.

    Example

    csharp
    using System;
    
    class Program
    {
        static void Main()
        {
            // These objects start in Gen 0
            var temp1 = new byte[1000];
            var temp2 = new byte[1000];
            
            Console.WriteLine($"Before GC - Gen 0 objects: {GC.GetTotalMemory(false)}");
            
            // Force a Gen 0 collection
            GC.Collect(0, GCCollectionMode.Optimized);
            
            // temp1 and temp2 are unreachable, so they're collected
            // Objects that survive move to Gen 1
            Console.WriteLine($"After Gen 0 collection: {GC.GetTotalMemory(false)}");
            
            // Create a long-lived object
            var longLived = new byte[10000];
            
            // Multiple collections eventually promote longLived to Gen 2
            for (int i = 0; i < 3; i++)
            {
                GC.Collect(i);
            }
            
            Console.WriteLine($"longLived is now in Gen {GC.GetGeneration(longLived)}");
        }
    }
    

    This code demonstrates object promotion: newly created objects start in Gen 0, and GC.GetGeneration() shows which generation an object currently occupies after successive collections.

    Key points to mention

    • Three-tier structure: Gen 0 (new), Gen 1 (intermediate), Gen 2 (old) optimize collection frequency based on object lifespan.
    • The generational hypothesis: Most objects die young, so collecting Gen 0 frequently captures most garbage with minimal overhead.
    • Promotion on survival: Objects that survive a collection are promoted to the next generation, reducing re-scanning of long-lived objects.
    • Full collections are expensive: Gen 2 collections pause the application longer because they examine all objects, making them rare by design.
    • Predictable performance: The generational model provides more predictable pause times compared to collecting the entire heap at once.

    Common follow-up questions

    Related interview questions

    Further reading

    .NETCsharpPerformance
  67. 67What is async and await?

    Beginner

    async and await are keywords that enable asynchronous programming in C#. The async keyword marks a method as asynchronous, allowing it to use the await keyword. The await keyword pauses execution of the method, frees the thread to handle other work, and resumes when the awaited task completes, keeping applications responsive and scalable without blocking threads.

    How it works

    When you mark a method with async, the compiler transforms it into a state machine that can pause and resume. Inside an async method, await does three things:

    1. Checks if the awaited task is already complete. If yes, execution continues synchronously.
    2. If not complete, the method returns a Task to the caller and the thread is freed to process other requests.
    3. When the awaited operation finishes, a continuation is scheduled to resume the method from where it paused.

    This is fundamentally different from blocking. Traditional synchronous code holds a thread hostage while waiting for I/O (network, disk, database). Async/await releases the thread, allowing the thread pool to serve other requests. This multiplies throughput without creating new threads.

    The return type of an async method should be Task, Task<T>, or (in rare cases) void. Always prefer Task or Task<T> over async void because async void methods cannot be awaited and exceptions thrown in them crash the application domain.

    Example

    csharp
    public async Task<string> FetchUserNameAsync(int userId)
    {
        // Await frees the thread while the HTTP request is in flight
        var response = await httpClient.GetAsync($"https://api.example.com/users/{userId}");
        response.EnsureSuccessStatusCode();
        
        var content = await response.Content.ReadAsStringAsync();
        return content;
    }
    
    // Calling code
    var userName = await FetchUserNameAsync(42);
    Console.WriteLine(userName);

    This method makes an HTTP request without blocking the thread. While waiting for the server response, the thread handles other requests. Once the response arrives, the method resumes, reads the content, and returns it.

    Key points to mention

    • async is a compile-time marker; it doesn't create threads. It enables use of await and transforms the method into a state machine.
    • await only works inside an async method. It pauses execution and frees the thread, enabling better resource utilization.
    • Always use Task or Task<T> as return types, never async void (except for event handlers), because exceptions in async void cannot be caught.
    • Async doesn't make code faster; it makes applications more scalable by reducing thread starvation in high-concurrency scenarios.
    • Understand the difference between Task.Run and async/await—Task.Run creates a new thread, while await releases the current one.

    Common follow-up questions

    • Why should I avoid async void? (Because exceptions thrown in async void methods cannot be caught by the caller and will crash the application.)
    • What happens if I don't await an async method? (The method returns a Task that is never observed, potentially causing unhandled exceptions and unpredictable behavior.)
    • How does async/await compile under the hood? (The compiler generates a state machine that manages pause/resume logic and continuations.)

    Related interview questions

    Further reading

    .NETCsharpasync
  68. 68What is a Task?

    Beginner

    A Task is a .NET abstraction that represents an asynchronous operation that may or may not return a value. The Task class (representing no return value) and Task<T> generic class (representing a return value of type T) are fundamental to C# async/await programming. Tasks can be awaited, combined, cancelled, and monitored for completion.

    How it works

    A Task is a promise that work will be completed at some point in the future. When you call an async method, it returns a Task immediately without blocking the calling thread. The actual execution may happen on a thread pool thread, or it may be scheduled to run later. The Task itself acts as a handle to that ongoing work, allowing you to:

    • Await it — pause execution until the Task completes using the await keyword
    • Check its status — inspect whether it's running, completed, faulted, or cancelled via properties like IsCompleted, IsFaulted, and IsCanceled
    • Get a result — with Task<T>, retrieve the value via the Result property (blocking if necessary) or await and extract it
    • Combine multiple Tasks — use Task.WhenAll() or Task.WhenAny() to coordinate multiple asynchronous operations
    • Cancel it — request cancellation through a CancellationToken

    Under the hood, Tasks are built on top of the thread pool and compiler-generated state machines created by the async/await pattern. When you await a Task, the method pauses, and the runtime schedules a continuation to resume when the Task completes.

    Example

    csharp
    // Returning Task with no value
    async Task FetchDataAsync()
    {
        await Task.Delay(1000);
        Console.WriteLine("Data fetched");
    }
    
    // Returning Task<T> with a value
    async Task<int> GetCountAsync()
    {
        await Task.Delay(500);
        return 42;
    }
    
    // Usage
    async Task Main()
    {
        // Await a Task with no return value
        await FetchDataAsync();
        
        // Await a Task<T> and capture the result
        int count = await GetCountAsync();
        Console.WriteLine($"Count: {count}");
        
        // Combine multiple tasks
        var task1 = FetchDataAsync();
        var task2 = GetCountAsync();
        await Task.WhenAll(task1, task2);
    }

    This example shows how FetchDataAsync() returns a non-generic Task, while GetCountAsync() returns Task<int>; both can be awaited, and Task.WhenAll() waits for both to complete.

    Key points to mention

    • Task represents future work — it doesn't necessarily run on a different thread; it's a scheduling and coordination mechanism
    • Async methods should return Task or Task<T>, not void (see why you should avoid async void)
    • Tasks are allocated on the heap and are reference types
    • Use await to unwrap a Task rather than blocking with .Result or .Wait(), which can cause deadlocks
    • Tasks support cancellation through CancellationToken and exception handling via try/catch around await statements

    Common follow-up questions

    • What's the difference between Task and Thread? — Tasks are higher-level abstractions managed by the thread pool, while threads are lower-level OS constructs. Many Tasks can run on a single thread through cooperative multitasking.
    • When should you use Task.Run()? — Use Task.Run() to offload CPU-bound work to a thread pool thread, not for I/O-bound operations which already return Tasks.
    • Can I await a Task multiple times? — Yes, a completed Task can be awaited multiple times and will return the same result each time.

    Related interview questions

    .NETCsharpasync
  69. 69Why should you avoid async void?

    Beginner

    Avoid async void methods because they cannot be awaited, making it impossible to know when they complete, and any unhandled exceptions will crash your entire application. Async void should only be used for event handlers; everywhere else, return Task or Task<T> to ensure proper exception handling and composability.

    How it works

    When you declare a method as async void, the compiler generates a state machine that doesn't return anything to the caller. Unlike async Task, which returns a Task object that represents the asynchronous operation, async void returns control immediately and the caller has no handle on the ongoing work.

    This creates two critical problems. First, the caller cannot await the method or know when it finishes executing. If calling code needs to wait for completion or coordinate multiple operations, it's impossible. Second, any exception thrown inside an async void method is raised on the synchronization context that invoked it. If there's no handler, the exception propagates to the top level and crashes the entire process—often with a cryptic "An unhandled exception of type 'System.Exception' occurred in YourApp.exe" message.

    Async Task, by contrast, captures exceptions into the returned Task object. The caller can await it and handle exceptions with try-catch, or let them propagate as faulted tasks. This gives you control and predictability.

    Example

    csharp
    // ❌ BAD: async void – cannot await, exceptions crash the app
    public async void ProcessDataBad()
    {
        await Task.Delay(1000);
        throw new InvalidOperationException("Something went wrong!");
    }
    
    // ✅ GOOD: async Task – can await and handle exceptions
    public async Task ProcessDataGood()
    {
        await Task.Delay(1000);
        throw new InvalidOperationException("Something went wrong!");
    }
    
    // Usage
    public async void OnButtonClick()
    {
        // ❌ No way to await or catch exceptions
        ProcessDataBad();
        
        // ✅ Can await and handle errors
        try
        {
            await ProcessDataGood();
        }
        catch (InvalidOperationException ex)
        {
            Console.WriteLine($"Caught: {ex.Message}");
        }
    }

    The bad example throws an unhandled exception that crashes the application; the good example lets you catch and handle it gracefully in calling code.

    Key points to mention

    • No awaiting: Callers cannot await async void methods, breaking the async chain and making it impossible to coordinate work.
    • Uncontrolled exceptions: Exceptions in async void bypass Task exception handling and can crash the process.
    • Event handlers are the exception: The only acceptable use of async void is for event handlers (like button clicks) where the caller expects no return value.
    • Always return Task or Task<T>: Make async methods returnable so callers can await them, compose them with other operations, and handle errors.
    • Difficult to test: Unit tests cannot easily verify completion or await async void methods, making them hard to test reliably.

    Common follow-up questions

    • "When is async void acceptable?" – Only for event handlers (button clicks, form loaded, etc.) where the runtime expects no return value and fire-and-forget semantics are appropriate.
    • "What about using Task.Run with async void?" – Still avoid it; Task.Run should pair with async Task, not async void.
    • "How do I make an event handler properly async?" – Use async Task and handle any exceptions inside the handler or attach handlers to the returned task.

    Related interview questions

    Further reading

    .NETCsharpasync
  70. 70What is the difference between Task.Run and async/await?

    Beginner

    Task.Run explicitly queues a delegate to run on a thread pool thread, typically for CPU-bound work that needs a dedicated thread. async/await is a language feature for writing asynchronous code that returns control to the caller without blocking a thread, ideal for I/O-bound operations. The key difference: Task.Run uses a thread; async/await frees a thread while waiting.

    How it works

    Task.Run immediately schedules work on the ThreadPool and returns a Task representing that scheduled work. This is useful when you have synchronous CPU-intensive logic (like heavy calculations) that you want to offload from the current thread so it doesn't block the UI or request handler. The thread remains active for the duration of the operation.

    async/await is syntactic sugar over state machines that allows you to write asynchronous code that looks synchronous. When an async method hits an await point (like a network call or database query), it returns control immediately without consuming a thread. The continuation resumes when the I/O operation completes. This is much more efficient for I/O-bound work because you don't need a thread waiting idle for a network response—potentially thousands of concurrent I/O operations can be pending with just a handful of threads in the pool.

    In summary: use Task.Run to parallelize CPU work; use async/await to avoid blocking threads during I/O waits.

    Example

    csharp
    // Task.Run – CPU-bound work
    public static async Task DoCpuWork()
    {
        // Offload expensive calculation to thread pool
        int result = await Task.Run(() =>
        {
            int sum = 0;
            for (int i = 0; i < 1_000_000_000; i++)
                sum += i;
            return sum;
        });
        Console.WriteLine($"Result: {result}");
    }
    
    // async/await – I/O-bound work (no Task.Run needed)
    public static async Task FetchDataAsync()
    {
        using var client = new HttpClient();
        // This awaits without consuming a thread while waiting
        string response = await client.GetStringAsync("https://api.example.com/data");
        Console.WriteLine($"Data: {response}");
    }
    
    // Usage
    await DoCpuWork();
    await FetchDataAsync();

    In the first method, Task.Run explicitly pushes the CPU-intensive loop onto a thread pool thread so it doesn't block the caller. In the second method, GetStringAsync already returns a Task and is designed to not consume a thread while waiting for the HTTP response—no Task.Run is needed or beneficial.

    Key points to mention

    • Task.Run is for CPU-bound work; async/await is for I/O-bound work. Mixing them (e.g., Task.Run around I/O) wastes threads.
    • async/await preserves the thread pool thread for other work; Task.Run dedicates a thread to your operation, then returns it to the pool when done.
    • Overusing Task.Run on I/O operations can cause thread pool starvation and deadlocks if you block on async code.
    • async/await requires the underlying operation to be truly asynchronous (return a Task); wrapping synchronous code in Task.Run is the workaround.
    • Task.Run introduces scheduling overhead; use it only when you genuinely need parallel execution, not for every async call.

    Common follow-up questions

    • When should I use Task.Run in an ASP.NET Core handler? — Rarely. If you have a short CPU burst, just do it synchronously on the request thread. For long operations, offload to a background service. Task.Run on a web server often indicates poor architecture.
    • Is it ever correct to do Task.Run around an async method? — Only to change the SynchronizationContext (rare). Generally, no—you're wasting the benefit of async by consuming an extra thread.
    • Can I use Task.Run inside an async method for true parallelism? — Yes, if you have independent CPU-bound tasks and want to run them in parallel, e.g., await Task.WhenAll(Task.Run(...), Task.Run(...)).

    Related interview questions

    Further reading

    .NETCsharpasync
  71. 71What is ASP.NET Core?

    Beginner

    ASP.NET Core is a free, open-source, cross-platform framework for building web applications and APIs on the modern .NET runtime. It replaces the older ASP.NET Framework and is designed to be fast, lightweight, modular, and cloud-native. ASP.NET Core includes built-in features like dependency injection, middleware pipeline, configuration management, and logging, making it ideal for contemporary web development.

    How it works

    ASP.NET Core runs on top of the .NET runtime and uses a request-response model. When a client makes an HTTP request, it is received by Kestrel, the built-in web server, and passed through a pipeline of middleware components. Each middleware can process the request, modify it, and pass it to the next middleware, or short-circuit and return a response. This pipeline eventually reaches your application logic—controllers, handlers, or endpoints—which generates a response that flows back through the middleware stack to the client.

    ASP.NET Core is fundamentally modular. You start with a minimal base and add only the features you need via NuGet packages. The framework automatically handles HTTP protocol details, routing, model binding, validation, and serialization. The built-in dependency injection container makes it easy to manage dependencies and promote testability. Configuration can come from multiple sources (JSON files, environment variables, command-line arguments) and is automatically injected where needed.

    Example

    csharp
    var builder = WebApplication.CreateBuilder(args);
    
    // Add services
    builder.Services.AddScoped<IGreetingService, GreetingService>();
    
    var app = builder.Build();
    
    // Middleware pipeline
    app.UseRouting();
    
    app.MapGet("/hello/{name}", (string name, IGreetingService service) =>
    {
        var message = service.Greet(name);
        return Results.Ok(new { message });
    });
    
    app.Run();
    
    // Simple service
    interface IGreetingService
    {
        string Greet(string name);
    }
    
    class GreetingService : IGreetingService
    {
        public string Greet(string name) => $"Hello, {name}!";
    }

    This minimal ASP.NET Core application registers a service in the dependency injection container, sets up a middleware pipeline, defines a GET endpoint that receives a name parameter and injects the greeting service, and returns a JSON response.

    Key points to mention

    • Cross-platform: Runs on Windows, Linux, and macOS, unlike the older .NET Framework.
    • Modular and lightweight: Start with minimal features and add only what you need; no bloat.
    • High performance: Consistently ranks at the top in TechEmpower benchmarks due to efficient async/await patterns and minimal allocations.
    • Built-in dependency injection: No need for external containers; DI is first-class.
    • Cloud-native: Designed for containerization (Docker), microservices, and cloud platforms like Azure.

    Common follow-up questions

    • What is the difference between app.Use() and app.Run() in middleware?
    • How does the ASP.NET Core dependency injection container work, and what are the lifetime scopes (Transient, Scoped, Singleton)?
    • What is the role of Program.cs and how does the startup process work in modern ASP.NET Core?

    Related interview questions

    Further reading

    .NETCsharpaspnetcore
  72. 72What is Kestrel?

    Beginner

    Kestrel is the cross-platform web server built into ASP.NET Core that handles HTTP and HTTPS traffic directly. It ships with every ASP.NET Core application and can run on Windows, Linux, and macOS. While it can serve traffic directly to clients, it's commonly deployed behind a reverse proxy like Nginx or IIS for production workloads.

    How it works

    Kestrel is a managed HTTP server written in C# that runs on top of the .NET runtime. When you create an ASP.NET Core application, Kestrel is the default hosting server. It listens on specified ports and processes incoming HTTP/HTTPS requests, passing them through the ASP.NET Core request pipeline (middleware) where your application logic handles them.

    Unlike older IIS-based hosting, Kestrel doesn't require a separate server installation—it's part of your application runtime. This makes it extremely lightweight and portable. The server is optimized for performance and supports modern HTTP features including HTTP/1.1, HTTP/2, and HTTP/3.

    In production, Kestrel is typically deployed behind a reverse proxy. The reverse proxy (Nginx, Apache, or IIS) receives external traffic on standard ports (80, 443), forwards it to Kestrel running on a private port, and handles concerns like SSL termination, load balancing, and compression. This separation provides security benefits and allows you to run multiple Kestrel instances behind a single proxy.

    Example

    csharp
    var builder = WebApplication.CreateBuilder(args);
    
    // Configure Kestrel options
    builder.WebHost.ConfigureKestrel(serverOptions =>
    {
        serverOptions.ListenLocalhost(5000);  // HTTP on port 5000
        serverOptions.ListenLocalhost(5001, listenOptions =>
        {
            listenOptions.UseHttps();  // HTTPS on port 5001
        });
        
        serverOptions.Limits.MaxRequestLineSize = 16_384;
        serverOptions.Limits.MaxRequestHeadersTotalSize = 32_768;
    });
    
    var app = builder.Build();
    app.MapGet("/", () => "Hello from Kestrel!");
    app.Run();
    

    This example configures Kestrel to listen on both HTTP (port 5000) and HTTPS (port 5001) with custom request size limits, demonstrating how to customize the server's behavior before the application starts.

    Key points to mention

    • Kestrel is built into ASP.NET Core and requires no external dependencies or separate server installation
    • It supports multiple platforms (Windows, Linux, macOS) and modern HTTP protocols (HTTP/1.1, HTTP/2, HTTP/3)
    • Configuration happens in code via ConfigureKestrel() or through configuration files (appsettings.json)
    • In production, Kestrel typically runs behind a reverse proxy for security, SSL termination, and load balancing
    • You can tune performance through options like MaxRequestHeadersTotalSize, MaxConnections, and thread pool settings

    Common follow-up questions

    • Why would you use Kestrel behind a reverse proxy instead of directly? — Reverse proxies provide SSL termination, handle external traffic routing, protect Kestrel from direct attacks, and allow multiple application instances
    • Can Kestrel handle production traffic directly? — Technically yes for smaller applications, but industry best practice is to use a reverse proxy for security and operational benefits (see how to tune Kestrel for production)
    • How do you configure Kestrel for different environments? — Use environment-specific appsettings files (appsettings.Production.json) or conditional logic in ConfigureKestrel() based on IHostEnvironment

    Related interview questions

    Further reading

    .NETCsharpaspnetcore
  73. 73What is Program.cs in ASP.NET Core?

    Beginner

    Program.cs is the entry point of an ASP.NET Core application where you configure the application's services, middleware pipeline, and endpoints. It replaces the traditional Startup.cs file and uses the WebApplication builder pattern to set up dependency injection, logging, authentication, and request handling in a modern, minimal hosting model.

    How it works

    Program.cs follows a structured flow that initializes your ASP.NET Core application:

    1. Create the builder: The WebApplication.CreateBuilder(args) method instantiates a WebApplicationBuilder that provides access to services, configuration, and logging infrastructure.

    2. Register services: You add services to the dependency injection container using builder.Services.Add*() methods. This includes controllers, Entity Framework, authentication, CORS, and any custom services your application needs.

    3. Build the application: Calling builder.Build() creates the WebApplication instance with all registered services. After this point, the service container is locked and you cannot add new services (though you can access them).

    4. Configure middleware: You use app.Use*() and app.Map*() methods to add middleware components that handle requests. The order matters—each middleware processes the request and passes control to the next in the pipeline. This is where you configure routing, authentication, HTTPS redirection, and exception handling.

    5. Map endpoints: You define API routes and controllers using app.MapControllers(), app.MapGet(), app.MapPost(), and similar methods, which tell ASP.NET Core how to handle specific HTTP requests.

    6. Run the application: app.Run() starts the Kestrel web server and begins listening for requests.

    Example

    csharp
    var builder = WebApplication.CreateBuilder(args);
    
    // Register services
    builder.Services.AddControllers();
    builder.Services.AddScoped<IUserService, UserService>();
    builder.Services.AddDbContext<AppDbContext>();
    builder.Services.AddCors(options =>
    {
        options.AddPolicy("AllowAll", policy =>
            policy.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader());
    });
    
    var app = builder.Build();
    
    // Configure middleware pipeline
    if (app.Environment.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }
    else
    {
        app.UseExceptionHandler("/Error");
    }
    
    app.UseHttpsRedirection();
    app.UseCors("AllowAll");
    app.UseRouting();
    app.UseAuthentication();
    app.UseAuthorization();
    
    // Map endpoints
    app.MapControllers();
    app.MapGet("/health", () => Results.Ok("Healthy"));
    
    app.Run();

    This example demonstrates the complete lifecycle: creating a builder, adding services like a database context and CORS policy, configuring middleware in the correct order, mapping controllers and custom endpoints, and running the application.

    Key points to mention

    • Minimal hosting model: Modern ASP.NET Core (6.0+) uses Program.cs without a separate Startup class, reducing boilerplate and improving readability.
    • Service registration order doesn't matter: Services can be registered in any order during the builder phase, but middleware order is critical—it determines how requests flow through your application.
    • Top-level statements: Program.cs uses C# top-level statements, allowing code without a Main method or Program class wrapper.
    • Environment-aware configuration: Use app.Environment.IsDevelopment() and similar checks to conditionally register middleware and services based on your deployment environment.
    • Middleware pipeline immutability: Once you call app.Run(), you cannot add more middleware—the pipeline is locked and execution begins.

    Common follow-up questions

    • What is the difference between services and middleware? (Services are dependencies injected into components; middleware handles requests in a pipeline.)
    • Why does middleware order matter? (The request pipeline order determines which middleware processes requests first, affecting authentication, authorization, logging, and error handling behavior.)
    • Can you add services after calling builder.Build()? (No, the service container is locked; attempting to add services after building will throw an exception.)

    Related interview questions

    Further reading

    .NETCsharpaspnetcore
  74. 74What is middleware?

    Beginner

    Middleware are software components in ASP.NET Core that form a request processing pipeline. Each middleware component can inspect or modify HTTP requests, execute logic, pass control to the next middleware, or short-circuit the pipeline and return a response directly.

    How it works

    Middleware operates in a chain-of-responsibility pattern. When an HTTP request arrives, it flows through each middleware component in the order they're registered in the pipeline. Each middleware can:

    • Process the request — examine or modify the incoming HTTP request
    • Call the next middleware — invoke the next component in the chain using await next(context)
    • Short-circuit the pipeline — return a response without calling subsequent middleware
    • Process the response — modify the response after the next middleware has executed

    Think of middleware as a stack of interceptors. The request travels down through each layer, and the response travels back up. This creates an "onion" pattern where outer middleware can wrap inner middleware. Understanding how the ASP.NET Core middleware pipeline works internally helps clarify this layering.

    Example

    csharp
    
    var builder = WebApplication.CreateBuilder(args);
    var app = builder.Build();
    
    // Middleware 1: Logging
    app.Use(async (context, next) =>
    {
        Console.WriteLine($"Request: {context.Request.Path}");
        await next(context);
        Console.WriteLine($"Response: {context.Response.StatusCode}");
    });
    
    // Middleware 2: Authentication check
    app.Use(async (context, next) =>
    {
        if (!context.Request.Headers.ContainsKey("Authorization"))
        {
            context.Response.StatusCode = 401;
            await context.Response.WriteAsync("Unauthorized");
            return; // Short-circuit
        }
        await next(context);
    });
    
    // Middleware 3: Handle requests
    app.MapGet("/", () => "Hello, World!");
    
    app.Run();
    

    This example demonstrates three middleware: the first logs request and response, the second validates authentication (and short-circuits if missing), and the third handles the actual GET request. The Use method is the standard way to add custom middleware.

    Key points to mention

    • Middleware order matters — they execute in the order registered, and the request flows down while the response flows back up
    • You can use next() to pass control forward or omit it to terminate the pipeline
    • Common built-in middleware includes authentication, CORS, routing, and exception handling
    • Middleware is perfect for cross-cutting concerns like logging, error handling, and security checks
    • The Run() method terminates the pipeline, while Use() and Map() allow continuation — see the difference between Use, Run, and Map

    Common follow-up questions

    • What's the difference between middleware and filters?
    • How do you create custom middleware as a class rather than inline?
    • Can middleware access dependency-injected services?

    Related interview questions

    Further reading

    .NETCsharpaspnetcore
  75. 75What is the request pipeline order?

    Beginner

    The request pipeline is a chain of middleware components that process HTTP requests and responses in the order they are registered in Program.cs. Each middleware can inspect, modify, or short-circuit the request before passing it to the next middleware, and can also inspect or modify the response on the way back. The order matters critically—middleware registered first runs first for requests and last for responses.

    How it works

    ASP.NET Core uses a middleware pipeline architecture where each middleware component is a delegate that receives the next middleware in the chain. When a request arrives, it flows through each middleware in registration order. Each middleware can:

    • Process the request before calling next()
    • Pass control to the next middleware
    • Short-circuit the pipeline and return early (preventing subsequent middleware from running)
    • Process the response after the next middleware completes

    Typical pipeline order follows a logical flow: exception handling comes first (to catch errors from all downstream middleware), then HTTPS redirection, static file serving, routing, CORS, authentication, authorization, and finally the endpoint handler. This ordering ensures security policies are checked before endpoints execute and that errors are caught at the application level.

    Example

    csharp
    var builder = WebApplication.CreateBuilder(args);
    var app = builder.Build();
    
    // Middleware order matters
    app.UseExceptionHandler("/error");           // 1. Exception handling
    app.UseHttpsRedirection();                    // 2. HTTPS redirection
    app.UseStaticFiles();                         // 3. Static files
    app.UseRouting();                             // 4. Routing
    app.UseCors("AllowAll");                      // 5. CORS
    app.UseAuthentication();                      // 6. Authentication
    app.UseAuthorization();                       // 7. Authorization
    
    app.MapGet("/public", () => "Public endpoint");
    app.MapGet("/protected", () => "Protected endpoint")
        .RequireAuthorization();
    
    app.Run();

    This pipeline ensures that static files are served without authentication, public endpoints are accessible, but protected endpoints require authorization—all because middleware runs in this exact order.

    Key points to mention

    • Order is critical: Middleware must be registered in the correct sequence. For example, UseAuthentication() must come before UseAuthorization(), and both must come after UseRouting().
    • Request/response flow: For requests, middleware runs top-to-bottom; for responses, it unwinds bottom-to-top. A middleware can execute code before and after calling next().
    • Short-circuiting: Middleware like UseStaticFiles() or early validation can short-circuit the pipeline, preventing later middleware from running.
    • Exception handling first: UseExceptionHandler() should be registered early to catch errors from all downstream middleware.
    • UseRouting() requirement: Most middleware that needs route information must come after UseRouting(), as that's when route matching occurs.

    Common follow-up questions

    • Why must UseAuthentication() come before UseAuthorization()? — Because authorization requires knowing who the user is; authentication establishes identity first.
    • Can I create custom middleware and where should I place it in the pipeline? — Yes, custom middleware can be placed strategically; for example, logging middleware often goes early, while validation middleware might go after routing.
    • What happens if I call UseRouting() multiple times or forget it? — Routing matches endpoints efficiently only when explicitly called; forgetting it means routes won't be matched. Calling it multiple times is redundant and can cause issues.

    Related interview questions

    Further reading

    .NETCsharpaspnetcoreSecurity
  76. 76What is dependency injection?

    Beginner

    Dependency Injection (DI) is a design pattern where objects receive their dependencies from external sources rather than creating them internally. Instead of a class instantiating its own dependencies, those dependencies are provided—typically through a constructor, property, or method. ASP.NET Core has a built-in DI container that automatically resolves and injects registered services.

    How it works

    Dependency Injection inverts control over object creation and dependency management. Rather than a class being responsible for instantiating everything it needs, a central container manages object lifecycles and relationships.

    When you register a service in the DI container, you tell it: "When someone asks for this interface, create or return that implementation." At runtime, when a class requests a dependency through its constructor, the container automatically provides it. This decouples classes from their concrete implementations, making code more testable, maintainable, and flexible.

    ASP.NET Core's built-in DI container supports three service lifetimes: Transient (new instance every time), Scoped (one instance per HTTP request), and Singleton (one instance for the entire application). Understanding these lifetimes is crucial—for example, injecting a scoped service into a singleton can cause issues because the singleton will hold a stale reference.

    Example

    csharp
    // Without DI: class creates its own dependency
    public class OrderService
    {
        private readonly EmailSender _emailSender = new();
        public void PlaceOrder(string item) => _emailSender.Send($"Order: {item}");
    }
    
    // With DI: dependency is injected
    public interface IEmailSender
    {
        void Send(string message);
    }
    
    public class EmailSender : IEmailSender
    {
        public void Send(string message) => Console.WriteLine($"Sending: {message}");
    }
    
    public class OrderService
    {
        private readonly IEmailSender _emailSender;
        
        public OrderService(IEmailSender emailSender)
        {
            _emailSender = emailSender;
        }
        
        public void PlaceOrder(string item) => _emailSender.Send($"Order: {item}");
    }
    
    // In Program.cs (ASP.NET Core 8+)
    var builder = WebApplication.CreateBuilder(args);
    builder.Services.AddScoped();
    builder.Services.AddScoped();
    var app = builder.Build();

    The OrderService no longer creates EmailSender directly; instead, it receives IEmailSender through its constructor, making it easy to swap implementations for testing or different environments.

    Key points to mention

    • Loose coupling: Classes depend on abstractions (interfaces) rather than concrete implementations, making code more flexible and testable.
    • Service lifetimes matter: Transient, Scoped, and Singleton have different implications for performance and behavior.
    • Constructor injection is standard: In .NET, dependencies are typically provided via constructor parameters, though property and method injection are also supported.
    • ASP.NET Core has built-in support: You don't need a third-party container like Autofac for basic scenarios; the framework provides everything you need in IServiceCollection.
    • Testability: DI makes unit testing simpler because you can inject mock implementations instead of real ones.

    Common follow-up questions

    Related interview questions

    Further reading

    .NETCsharpSecuritydependency-injection
  77. 77What are the service lifetimes in ASP.NET Core?

    Beginner

    Service lifetimes in ASP.NET Core define how long an instance of a service persists in memory and how it is reused. There are three lifetimes: Transient (new instance every time), Scoped (one instance per HTTP request), and Singleton (one instance for the entire application lifetime). Choosing the right lifetime is critical for performance, memory usage, and correctness in your application.

    How it works

    ASP.NET Core's dependency injection container manages service lifetimes automatically. When you register a service, you specify how it should be instantiated and cached.

    Transient services create a fresh instance every time they are requested. This is useful for stateless services or when you need a clean slate each time. The downside is potential memory overhead and slower performance if many instances are created.

    Scoped services create one instance per logical scope, typically one per HTTP request in web applications. Within a single request, the same instance is injected everywhere it is needed. This is ideal for request-specific data like database contexts—see How do you manage DbContext lifetime? for more details. When the request completes, the instance is disposed.

    Singleton services create one instance when first requested and reuse it for the entire application lifetime. They are created lazily or at application startup, depending on configuration. Singletons are efficient for expensive-to-create, thread-safe services like caches or configuration objects. However, they must be thread-safe and stateless (or carefully managed) to avoid concurrency issues across requests.

    Example

    csharp
    public interface ILogger { void Log(string message); }
    public class ConsoleLogger : ILogger { public void Log(string message) => Console.WriteLine(message); }
    
    public class Program
    {
        public static void Main(string[] args)
        {
            var builder = WebApplication.CreateBuilder(args);
            
            builder.Services.AddTransient<ILogger, ConsoleLogger>();  // New instance each time
            builder.Services.AddScoped<IUserService, UserService>();   // One per request
            builder.Services.AddSingleton<ICache, MemoryCache>();      // One for app lifetime
            
            var app = builder.Build();
            app.MapGet("/", (ILogger logger) => { logger.Log("Request received"); return "OK"; });
            app.Run();
        }
    }
    

    This example registers three services with different lifetimes: a transient logger (creates a new instance per injection), a scoped user service (one per HTTP request), and a singleton cache (shared across all requests).

    Key points to mention

    • Transient: Always creates a new instance. Use for stateless services or when you need isolated state. Be aware of AddTransient services that hold state issues.
    • Scoped: One instance per request. Ideal for DbContext and other request-scoped resources. Perfect for isolating data between concurrent requests.
    • Singleton: One instance forever. Must be thread-safe. Best for immutable or thread-safe shared resources like caches, loggers, or configuration.
    • Disposing: Scoped and Singleton services are disposed when their scope ends or the application shuts down. Transient instances may not be disposed by the container unless explicitly tracked.
    • Memory and performance: Singletons are fastest but require careful design; Scoped offers a good balance; Transient is safest but slower and memory-hungry.

    Common follow-up questions

    • Can you inject a Singleton service into a Scoped or Transient service? Yes, but the reverse (injecting Scoped into Singleton) is dangerous and throws an error by design to prevent capturing request-specific state in long-lived services.
    • What happens if I dispose a Singleton manually? It can cause issues for subsequent requests. Singletons should only be disposed once at application shutdown.
    • How does DbContext lifetime relate to Scoped services? DbContext is registered as Scoped by default because each request should have its own database connection and object tracking context.

    Related interview questions

    Further reading

    .NETCsharpaspnetcoredependency-injection
  78. 78What is appsettings.json?

    Beginner

    appsettings.json is a configuration file in ASP.NET Core applications that stores application settings in JSON format, such as database connection strings, logging levels, and custom options. Environment-specific files like appsettings.Development.json and appsettings.Production.json override the base configuration based on the current environment, enabling different settings for development, testing, and production without code changes.

    How it works

    ASP.NET Core uses a configuration provider system that reads appsettings.json during application startup. The framework loads the base appsettings.json file first, then overlays environment-specific files on top of it. This layered approach means values in appsettings.Development.json will override matching keys from appsettings.json when running in development.

    The configuration is injected into the dependency container and made available throughout your application via the IConfiguration interface. You can access settings directly by key, or bind entire sections to strongly-typed options classes using the Options pattern. This approach keeps sensitive data (like production connection strings) out of source control by using different files per environment or external secret management.

    The configuration system also supports multiple sources beyond JSON files, including environment variables, command-line arguments, and User Secrets for sensitive development data. These are layered in a defined configuration precedence order, allowing environment variables and secrets to override file-based settings.

    Example

    csharp
    // appsettings.json
    {
      "ConnectionStrings": {
        "DefaultConnection": "Server=localhost;Database=myapp;"
      },
      "Logging": {
        "LogLevel": {
          "Default": "Information"
        }
      },
      "AppSettings": {
        "ApiTimeout": 30,
        "MaxRetries": 3
      }
    }
    
    // appsettings.Development.json (overrides for development)
    {
      "Logging": {
        "LogLevel": {
          "Default": "Debug"
        }
      }
    }
    
    // Program.cs - accessing configuration
    var builder = WebApplication.CreateBuilder(args);
    
    // Access via IConfiguration
    var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
    
    // Or bind to a strongly-typed class
    var appSettings = new AppSettings();
    builder.Configuration.GetSection("AppSettings").Bind(appSettings);
    
    builder.Services.Configure<AppSettings>(
        builder.Configuration.GetSection("AppSettings"));
    
    var app = builder.Build();
    
    // Inject IConfiguration or IOptions<AppSettings> into controllers/services
    app.Run();
    
    public class AppSettings
    {
        public int ApiTimeout { get; set; }
        public int MaxRetries { get; set; }
    }

    This example shows how appsettings.json stores configuration, how environment-specific files override values, and how to access settings via IConfiguration or the Options pattern in your application.

    Key points to mention

    • appsettings.json is the default configuration file; environment-specific files (Development, Production) overlay on top based on ASPNETCORE_ENVIRONMENT
    • Use the Options pattern with IOptions<T> to bind configuration sections to strongly-typed classes for better type safety and validation
    • Never commit sensitive data like production connection strings or API keys to appsettings.json; use User Secrets in development and environment variables or secret management services in production
    • Configuration is read-only after application startup; changes to the file require an application restart
    • The configuration system supports nested JSON objects and arrays, making it flexible for complex settings

    Common follow-up questions

    • How do you handle environment-specific configuration? — Use environment-specific appsettings files (appsettings.{Environment}.json) and set the ASPNETCORE_ENVIRONMENT variable via environment variables, launch settings, or Docker. The framework automatically merges these with the base file.
    • What's the difference between IConfiguration and IOptions? — IConfiguration provides direct access to raw configuration values and supports dynamic reloading; IOptions<T> binds configuration to strongly-typed classes, provides validation, and is recommended for the Options pattern.
    • How do you access nested configuration values? — Use colon-separated keys like Configuration["ConnectionStrings:DefaultConnection"] or the Options pattern to bind entire sections to classes with matching property names.

    Related interview questions

    Further reading

    .NETCsharp
  79. 79What are environments in ASP.NET Core?

    Beginner

    Environments in ASP.NET Core are a way to conditionally configure and run your application differently based on where it's deployed. The ASPNETCORE_ENVIRONMENT variable specifies which environment is active—typically Development, Staging, or Production—allowing you to load different configuration, logging levels, and middleware behavior for each.

    How it works

    ASP.NET Core uses the ASPNETCORE_ENVIRONMENT environment variable to determine the current runtime environment. When your application starts, the framework checks this variable and uses it to:

    • Load environment-specific configuration files (e.g., appsettings.Development.json, appsettings.Production.json)
    • Enable or disable middleware and features conditionally
    • Apply environment-specific dependency injection registrations
    • Control logging verbosity and output
    • Manage which secrets or connection strings are used

    The three standard environments are Development (local machine), Staging (pre-production testing), and Production (live). You can also define custom environments. The environment name is case-insensitive and accessible via IHostEnvironment.EnvironmentName throughout your application.

    When you use appsettings.json, the configuration system automatically merges settings from environment-specific files, with later sources overriding earlier ones. This layering approach makes it easy to maintain environment-specific secrets and settings without duplicating common configuration.

    Example

    csharp
    var builder = WebApplication.CreateBuilder(args);
    
    // Access the current environment
    var isDevelopment = builder.Environment.IsDevelopment();
    var isProduction = builder.Environment.IsProduction();
    
    // Configure services conditionally
    if (isDevelopment)
    {
        builder.Services.AddScoped<ILogger, ConsoleLogger>();
        builder.Logging.SetMinimumLevel(LogLevel.Debug);
    }
    else
    {
        builder.Services.AddScoped<ILogger, FileLogger>();
        builder.Logging.SetMinimumLevel(LogLevel.Warning);
    }
    
    // Load environment-specific settings
    var environment = builder.Configuration["Environment"];
    Console.WriteLine($"Running in: {builder.Environment.EnvironmentName}");
    
    var app = builder.Build();
    
    // Use middleware conditionally
    if (app.Environment.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }
    else
    {
        app.UseExceptionHandler("/Error");
    }
    
    app.Run();
    

    This code demonstrates how to conditionally register services and middleware based on the current environment, enabling verbose logging in Development but minimal logging in Production.

    Key points to mention

    • Set ASPNETCORE_ENVIRONMENT via system environment variables, launch settings (launchSettings.json), or deployment configuration
    • Environment-specific configuration files follow the naming pattern appsettings.{Environment}.json and override base appsettings.json settings
    • Use IHostEnvironment to inject environment information into your services and middleware for runtime decisions
    • Helper methods like IsDevelopment(), IsProduction(), and IsEnvironment("custom") make environment checks readable and safe
    • The default environment is Production if not explicitly set; always explicitly configure your target environment in deployment

    Common follow-up questions

    • How do you set the ASPNETCORE_ENVIRONMENT in different deployment scenarios (local, Docker, Azure)?
    • What's the difference between configuration precedence and environment-specific overrides?
    • How should you handle secrets in each environment, particularly Production? (See storing secrets safely in .NET apps)

    Related interview questions

    .NETCsharpaspnetcore
  80. 80What is the Options pattern?

    Beginner

    The Options pattern is a configuration management approach in .NET that binds configuration sections from appsettings.json (or other sources) to strongly typed classes, which are then injected via dependency injection using IOptions<T>, IOptionsSnapshot<T>, or IOptionsMonitor<T>. This makes configuration type-safe, validated, and testable instead of relying on magic strings or dynamic dictionaries.

    How it works

    The Options pattern follows a simple three-step process:

    1. Define a configuration class: Create a Plain Old CLR Object (POCO) that mirrors your configuration structure. Property names should match the JSON keys in appsettings.json.

    2. Register in Dependency Injection: In Program.cs (or Startup.cs), bind the configuration section to your POCO class using services.Configure<YourOptionsClass>(configuration.GetSection("SectionName")).

    3. Inject and use: Inject IOptions<YourOptionsClass> into your services, accessing the configuration through the Value property.

    This approach provides immediate benefits: the compiler validates property names, you get IntelliSense support, and you can apply data annotations for validation. Unlike string-based configuration access, this catches typos at compile time or during startup validation.

    Example

    csharp
    // appsettings.json
    {
      "EmailService": {
        "SmtpServer": "smtp.gmail.com",
        "Port": 587,
        "SenderEmail": "noreply@example.com"
      }
    }
    
    // Configuration class
    public class EmailServiceOptions
    {
        public string SmtpServer { get; set; }
        public int Port { get; set; }
        public string SenderEmail { get; set; }
    }
    
    // Program.cs
    var builder = WebApplication.CreateBuilder(args);
    builder.Services.Configure<EmailServiceOptions>(
        builder.Configuration.GetSection("EmailService"));
    builder.Services.AddScoped<IEmailService, EmailService>();
    var app = builder.Build();
    
    // Using the options
    public class EmailService : IEmailService
    {
        private readonly EmailServiceOptions _options;
        
        public EmailService(IOptions<EmailServiceOptions> options)
        {
            _options = options.Value;
        }
        
        public async Task SendEmailAsync(string to, string subject, string body)
        {
            using var client = new SmtpClient(_options.SmtpServer, _options.Port);
            await client.SendMailAsync(_options.SenderEmail, to, subject, body);
        }
    }

    This example demonstrates binding the "EmailService" configuration section to EmailServiceOptions, then injecting it into EmailService where it's used to configure an SMTP client.

    Key points to mention

    • Three injection types: IOptions<T> (singleton, immutable), IOptionsSnapshot<T> (scoped, reloads per request), and IOptionsMonitor<T> (singleton, reloads dynamically) serve different reload requirements.
    • Validation support: Add DataAnnotations attributes (like [Required], [Range]) to your options class for automatic validation; use ValidateDataAnnotations() in Program.cs.
    • Type safety: Configuration is checked at compile time and startup, preventing runtime string-key errors common with IConfiguration.GetValue("SomeKey").
    • Testability: Mock IOptions<T> easily in unit tests using Options.Create(new MyOptions { ... }).
    • Flexibility: Supports multiple configuration sources (JSON, environment variables, Azure Key Vault) through the standard IConfiguration abstraction.

    Common follow-up questions

    • What's the difference between IOptions, IOptionsSnapshot, and IOptionsMonitor? IOptions is a singleton and never reloads; IOptionsSnapshot reloads per request scope and is useful for multi-tenant scenarios; IOptionsMonitor reloads dynamically and supports change callbacks.
    • How do you validate options at startup? Use .ValidateDataAnnotations(), implement IValidateOptions<T>, or call .Validate(options => !string.IsNullOrEmpty(options.SmtpServer)) during configuration.
    • Can you use Options pattern with environment-specific settings? Yes—combine appsettings.json with appsettings.{Environment}.json or environment variables, and the IConfiguration layer merges them automatically before binding to your options class.

    Related interview questions

    Further reading

    .NETCsharpArchitecture
  81. 81What is routing in ASP.NET Core?

    Beginner

    Routing in ASP.NET Core is the mechanism that maps incoming HTTP requests to the appropriate endpoint handler based on the request URL and HTTP method. It determines which controller action, Minimal API handler, or Razor Page processes each request. Routing uses template patterns like /products/{id} to extract data from URLs and pass it to your application logic.

    How it works

    When an HTTP request arrives, the routing system evaluates it against a collection of route patterns defined in your application. Each route template can include static segments (like /api/products) and dynamic segments (like {id}) that capture values from the URL.

    ASP.NET Core supports three main routing approaches:

    • Conventional Routing – Uses MapControllerRoute to define patterns that map to controller and action names
    • Attribute Routing – Routes defined directly on controllers and actions using [Route] attributes
    • Minimal APIs – Routes defined fluently in Program.cs using Map methods

    The router matches the incoming request against each route in order until it finds a match. If a match is found, route values (like id, controller, action) are extracted and made available to the endpoint handler. If no match is found, a 404 response is returned.

    Example

    csharp
    // Program.cs - Minimal API routing example
    var builder = WebApplication.CreateBuilder(args);
    var app = builder.Build();
    
    app.MapGet("/products", () => "Get all products");
    
    app.MapGet("/products/{id}", (int id) => 
    {
        return $"Get product with ID: {id}";
    });
    
    app.MapPost("/products", (CreateProductDto dto) => 
    {
        return Results.Created($"/products/1", new { id = 1, name = dto.Name });
    });
    
    app.Run();
    
    public record CreateProductDto(string Name);

    This example defines three routes: one to list all products, one to get a specific product by ID (with the ID extracted from the URL), and one to create a new product. The {id} segment is automatically parsed as an integer and passed to the handler method.

    Key points to mention

    • Route patterns are processed in order; the first match wins, so place more specific routes before general ones
    • Route parameters can be constrained using route constraints like {id:int} or {slug:regex(^[a-z]+$)} to ensure only matching requests reach the endpoint
    • Controllers use a combination of conventional and attribute routing to map URLs to action methods
    • Minimal APIs provide a lightweight alternative for defining routes without controllers, ideal for APIs and microservices
    • The router integrates with dependency injection to resolve handler parameters and inject services

    Common follow-up questions

    • What's the difference between conventional routing and attribute routing?
    • How do route constraints work and when should you use them?
    • Can you mix Minimal API routes with controller-based routes in the same application?

    Related interview questions

    Further reading

    .NETCsharpaspnetcore
  82. 82What is a controller?

    Beginner

    A controller is a class that handles HTTP requests and returns responses in an ASP.NET Core application. API controllers inherit from ControllerBase and use attributes like [HttpGet], [HttpPost], and [Route] to map incoming requests to specific action methods. Controllers are the core building block of the MVC pattern and serve as the entry point for processing client requests and returning data.

    How it works

    When a client makes an HTTP request, the ASP.NET Core routing system matches the request URL and HTTP method to a controller action. The controller receives the request, processes business logic (often by calling services), and returns a response—typically JSON data wrapped in an IActionResult. Controllers can accept parameters from the URL, query string, request body, or headers. The framework automatically deserializes request data and serializes response data using configured formatters (usually JSON).

    Controllers separate concerns by keeping HTTP handling logic distinct from business logic. A controller orchestrates the flow: it receives input, delegates work to services, and formats output. This makes code testable, maintainable, and follows the Single Responsibility Principle. For APIs, ControllerBase is preferred over Controller because Controller includes view-rendering features needed only for traditional MVC web applications.

    Example

    csharp
    [ApiController]
    [Route("api/[controller]")]
    public class ProductsController : ControllerBase
    {
        private readonly IProductService _productService;
    
        public ProductsController(IProductService productService)
        {
            _productService = productService;
        }
    
        [HttpGet("{id}")]
        public async Task<ActionResult<ProductDto>> GetProduct(int id)
        {
            var product = await _productService.GetProductByIdAsync(id);
            if (product == null)
                return NotFound();
            
            return Ok(product);
        }
    
        [HttpPost]
        public async Task<ActionResult<ProductDto>> CreateProduct(CreateProductDto dto)
        {
            var product = await _productService.CreateProductAsync(dto);
            return CreatedAtAction(nameof(GetProduct), new { id = product.Id }, product);
        }
    }

    This controller handles GET and POST requests for products; GetProduct retrieves a single product by ID, and CreateProduct creates a new product and returns a 201 Created response with the location of the new resource.

    Key points to mention

    • Controllers inherit from ControllerBase for APIs (or Controller for traditional MVC with views)
    • HTTP method attributes ([HttpGet], [HttpPost], etc.) and [Route] map requests to actions
    • The [ApiController] attribute enables automatic model validation and consistent error responses
    • Dependency injection is used to inject services into controller constructors, promoting testability
    • Action methods return IActionResult subclasses like Ok(), NotFound(), BadRequest(), and CreatedAtAction()

    Common follow-up questions

    Related interview questions

    Further reading

    .NETCsharpaspnetcore
  83. 83What are Minimal APIs?

    Beginner

    Minimal APIs are a lightweight approach to building HTTP endpoints in ASP.NET Core that use lambda expressions and fluent method calls instead of controller classes. Introduced in .NET 6 and refined through .NET 8, they enable you to define routes, handlers, and middleware with minimal boilerplate code, making them ideal for microservices, REST APIs, and rapid prototyping.

    How it works

    Minimal APIs eliminate the traditional controller-based architecture by allowing you to map HTTP endpoints directly on the WebApplication object using methods like MapGet, MapPost, MapPut, and MapDelete. Each method accepts a route pattern and a handler function (typically a lambda). The handler receives dependencies through parameter injection, processes the request, and returns a response. ASP.NET Core automatically handles serialization, model validation, and dependency resolution, so you write less infrastructure code and focus on business logic.

    The model binding system works the same as controllers—route parameters, query strings, request bodies, and services are automatically injected into your handler parameters. You can also apply filters, middleware, and authorization declaratively using extension methods on the endpoint definition.

    Example

    csharp
    var builder = WebApplication.CreateBuilder(args);
    builder.Services.AddScoped<IProductService, ProductService>();
    
    var app = builder.Build();
    
    app.MapGet("/products", async (IProductService service) =>
        await service.GetAllAsync())
        .WithName("GetProducts")
        .WithOpenApi();
    
    app.MapGet("/products/{id}", async (int id, IProductService service) =>
        await service.GetByIdAsync(id))
        .WithName("GetProductById")
        .Produces<Product>(StatusCodes.Status200OK)
        .Produces(StatusCodes.Status404NotFound);
    
    app.MapPost("/products", async (CreateProductRequest request, IProductService service) =>
    {
        var product = await service.CreateAsync(request);
        return Results.Created($"/products/{product.Id}", product);
    })
        .WithName("CreateProduct")
        .Accepts<CreateProductRequest>("application/json")
        .RequireAuthorization();
    
    app.Run();
    

    This example defines three endpoints: fetching all products, retrieving a single product by ID, and creating a new product with authorization. The service is injected automatically, and Results helper methods provide strongly-typed responses.

    Key points to mention

    • Low ceremony: No controller classes, attributes, or action methods—just route patterns and handlers, reducing scaffolding.
    • Dependency injection: Services are injected directly into handler parameters, keeping code clean and testable.
    • OpenAPI/Swagger: Built-in support for generating OpenAPI documentation via WithOpenApi() without extra configuration.
    • Performance: Minimal APIs are fast because they skip the controller routing layer and use direct method invocation.
    • Scalability trade-offs: Ideal for small to medium APIs; larger systems often benefit from controllers for organization and consistency.

    Common follow-up questions

    • How do you validate input in Minimal APIs? Use Validate(), fluent validators, or data annotations on your request DTOs; ASP.NET Core validates before invoking the handler.
    • Can you group related endpoints? Yes, use MapGroup() to create route groups and apply middleware or authorization to multiple endpoints at once.
    • How do you handle errors and exceptions? Use middleware, exception handlers, or return Results.BadRequest(), Results.InternalServerError(), and other helper methods.

    Related interview questions

    Further reading

    .NETCsharpaspnetcore
  84. 84What is model binding?

    Beginner

    Model binding is the automatic mechanism in ASP.NET Core that converts incoming request data (from route, query strings, headers, and body) into strongly-typed action method parameters. It eliminates the need to manually parse HTTP requests, making controller actions cleaner and more maintainable. The framework inspects the action method signature and populates those parameters from various data sources according to a configurable priority order.

    How it works

    When a request arrives at an action method, ASP.NET Core's model binding system follows a predictable sequence to locate and convert data:

    1. Inspection: The framework examines the action method's parameters and their names.
    2. Source Resolution: It searches for matching data in sources in this order: route values, query string, form data (body), headers, and cookies.
    3. Type Conversion: Once found, the data is converted to the parameter's declared type using type converters or custom binding logic.
    4. Validation: After binding, model validation attributes are applied to verify the data meets requirements.

    You can control binding behavior using attributes like [FromRoute], [FromQuery], [FromBody], and [FromHeader] to explicitly specify where data should come from, or use [BindNever] to exclude properties entirely.

    Example

    csharp
    public class Product
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public decimal Price { get; set; }
    }
    
    [ApiController]
    [Route("api/[controller]")]
    public class ProductsController : ControllerBase
    {
        // GET /api/products/5?name=Widget
        [HttpGet("{id}")]
        public IActionResult GetProduct(int id, string name)
        {
            // id is bound from route, name from query string
            return Ok(new { id, name });
        }
    
        // POST /api/products
        // Body: { "id": 1, "name": "Widget", "price": 29.99 }
        [HttpPost]
        public IActionResult CreateProduct([FromBody] Product product)
        {
            // Entire Product object is bound from JSON body
            return CreatedAtAction(nameof(GetProduct), new { id = product.Id }, product);
        }
    }
    

    In the first method, id is automatically bound from the route segment and name from the query string. In the second method, the entire JSON body is deserialized into a Product object because of the [FromBody] attribute.

    Key points to mention

    • Automatic and configurable: Model binding works automatically for primitive types and complex objects, but you can override behavior with binding attributes.
    • Priority matters: Route parameters take precedence over query strings, which take precedence over form data—unless you explicitly specify a source.
    • Works with complex types: Model binding can reconstruct nested objects and collections from flattened request data (e.g., Product.Supplier.Name from form field Product.Supplier.Name).
    • Type safety: Invalid data types are caught during binding before your action executes, providing automatic client validation feedback.
    • Integrates with validation: Model binding feeds directly into model validation annotations like [Required] and [Range].

    Common follow-up questions

    • How do I bind a property from a custom source? Implement IModelBinder or IModelBinderProvider to create custom binding logic for specific types.
    • What happens if binding fails? The parameter receives its default value (null for reference types, default for value types), and ModelState.IsValid becomes false, triggering validation errors.
    • Can I prevent certain properties from being bound? Yes, use the [BindNever] attribute on properties you want excluded from automatic binding, which is important for security in MVC scenarios.

    Related interview questions

    .NETCsharpefcore
  85. 85What is model validation?

    Beginner

    Model validation is the process of checking that data submitted to your application meets defined rules and constraints before processing. In .NET, you use data annotations (attributes like [Required], [StringLength], [Range]) to declare validation rules on model properties, and the framework automatically enforces them during model binding. With the [ApiController] attribute, invalid models automatically return a 400 Bad Request response with validation error details.

    How it works

    Model validation happens after model binding completes. The ASP.NET Core runtime examines all data annotations on your model properties and runs the corresponding validators. The ModelState.IsValid property tells you whether all validations passed. When you mark a controller with [ApiController], the framework intercepts requests with invalid models and returns a structured 400 response automatically—you don't need to manually check ModelState.IsValid in most cases. You can also implement IValidatableObject for complex, multi-property validation logic that annotations alone cannot express.

    Example

    csharp
    using System.ComponentModel.DataAnnotations;
    
    public class CreateUserRequest
    {
        [Required]
        [StringLength(100, MinimumLength = 2)]
        public string Name { get; set; }
    
        [Required]
        [EmailAddress]
        public string Email { get; set; }
    
        [Range(18, 120)]
        public int Age { get; set; }
    
        [Phone]
        public string PhoneNumber { get; set; }
    }
    
    [ApiController]
    [Route("api/[controller]")]
    public class UsersController : ControllerBase
    {
        [HttpPost]
        public IActionResult CreateUser(CreateUserRequest request)
        {
            // ModelState is automatically valid here; invalid requests return 400
            return Ok(new { message = "User created", user = request });
        }
    }

    When a client sends a request with missing or invalid data (e.g., an empty Name or invalid Email format), ASP.NET Core automatically rejects it with a 400 response containing validation error details; the action method never executes.

    Key points to mention

    • Data annotations like [Required], [StringLength], [EmailAddress], [Range], and [RegularExpression] provide declarative validation rules.
    • Validation occurs during model binding, before your action method runs.
    • The [ApiController] attribute enables automatic 400 responses for invalid models, eliminating boilerplate validation checks.
    • IValidatableObject allows you to implement custom validation logic that involves multiple properties or complex business rules.
    • Custom validation attributes let you create domain-specific validators (e.g., [ValidCreditCard]) that can be reused across models.

    Common follow-up questions

    • How do you implement custom validation? — Derive from ValidationAttribute and override IsValid, or implement IValidatableObject on your model for cross-property rules.
    • What's the difference between server-side and client-side validation? — Server-side validation (data annotations) is mandatory for security; client-side validation (HTML5 or JavaScript) improves user experience but can be bypassed.
    • How do you access validation errors in your action method? — Without [ApiController], check ModelState.IsValid and iterate ModelState.Values; with [ApiController], the framework handles this for you and includes errors in the 400 response payload.

    Related interview questions

    Further reading

    .NETCsharpaspnetcore
  86. 86What are the common HTTP status codes for APIs?

    Beginner

    HTTP status codes are standardized three-digit numbers that indicate the outcome of an API request. The most common codes for APIs are 200 OK, 201 Created, 204 No Content, 400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found, 409 Conflict, and 500 Internal Server Error. These codes help clients understand whether their request succeeded, and if not, what went wrong and how to handle the failure.

    How it works

    HTTP status codes are grouped into five categories based on their first digit. The 2xx range indicates success, 3xx indicates redirection, 4xx indicates client errors (the request was malformed or invalid), and 5xx indicates server errors (the server failed to process a valid request).

    Success codes (2xx): 200 OK means the request succeeded and the server is returning data. 201 Created indicates a new resource was successfully created (typically used for POST requests). 204 No Content indicates success but there is no content to return (common for DELETE or update operations).

    Client error codes (4xx): 400 Bad Request means the client sent malformed data or missing required fields. 401 Unauthorized means authentication is required or failed, often related to cookie and JWT authentication. 403 Forbidden means the authenticated user lacks permission to access the resource. 404 Not Found means the requested resource does not exist. 409 Conflict indicates a conflict, such as trying to create a resource that already exists.

    Server error codes (5xx): 500 Internal Server Error is a catch-all for unexpected server failures. Other 5xx codes include 502 Bad Gateway and 503 Service Unavailable.

    Example

    csharp
    [ApiController]
    [Route("api/[controller]")]
    public class UsersController : ControllerBase
    {
        [HttpPost]
        public IActionResult CreateUser([FromBody] CreateUserDto dto)
        {
            if (dto == null || string.IsNullOrEmpty(dto.Name))
                return BadRequest("Name is required"); // 400
            
            if (UserExists(dto.Email))
                return Conflict("Email already registered"); // 409
            
            var user = new User { Name = dto.Name, Email = dto.Email };
            _context.Users.Add(user);
            _context.SaveChanges();
            
            return CreatedAtAction(nameof(GetUser), new { id = user.Id }, user); // 201
        }
        
        [HttpGet("{id}")]
        public IActionResult GetUser(int id)
        {
            var user = _context.Users.Find(id);
            if (user == null)
                return NotFound(); // 404
            
            return Ok(user); // 200
        }
        
        [HttpDelete("{id}")]
        public IActionResult DeleteUser(int id)
        {
            var user = _context.Users.Find(id);
            if (user == null)
                return NotFound(); // 404
            
            _context.Users.Remove(user);
            _context.SaveChanges();
            
            return NoContent(); // 204
        }
    }
    

    This example demonstrates a typical REST API controller that returns appropriate status codes for different scenarios: 201 when creating, 200 when retrieving, 204 when deleting, 400 for validation errors, 409 for conflicts, and 404 when resources don't exist.

    Key points to mention

    • Use correct status codes consistently—they communicate intent to API consumers and are often parsed by client libraries for retry logic.
    • Always return 2xx for success and 4xx for client errors; avoid returning 200 with an error message in the response body.
    • 401 and 403 are different: 401 means "you need to authenticate," while 403 means "you're authenticated but don't have permission."
    • Include meaningful error details in the response body (e.g., validation messages for 400 errors) to help clients debug issues.
    • Idempotent operations (GET, DELETE) should return 404 if the resource doesn't exist; POST may return 409 if the resource already exists depending on your API design.

    Common follow-up questions

    • What is the difference between 401 and 403? (401 = authentication failed, 403 = insufficient permissions)
    • When should you use 204 No Content vs. 200 OK? (204 for operations where you don't need to return data, 200 when returning a body)
    • How do you handle authorization errors in your API? (This ties into testing authorization rules in integration tests)

    Related interview questions

    .NETCsharpSecurity
  87. 87What is IActionResult?

    Beginner

    IActionResult is an interface in ASP.NET Core that represents the result of a controller action. It allows actions to return different HTTP responses—such as Ok, NotFound, BadRequest, or Redirect—without coupling the action to a specific response type. ActionResult<T> extends this pattern by combining a typed response body with standard HTTP status results.

    How it works

    IActionResult is a contract that says "I know how to convert myself into an HTTP response." When an action method returns an IActionResult, the ASP.NET Core framework calls the ExecuteAsync() method on that result object, which writes the appropriate HTTP status code, headers, and body to the response.

    Built-in implementations include OkResult (200), BadRequestResult (400), NotFoundResult (404), and CreatedAtActionResult (201). You can also create custom results by implementing the interface yourself.

    ActionResult<T> is a helper that combines typed data with status results. It has an implicit conversion to IActionResult, so you can return either a typed object (which becomes a 200 Ok response) or an IActionResult from the same action. This gives you flexibility while maintaining type safety for the happy path.

    Example

    csharp
    using Microsoft.AspNetCore.Mvc;
    
    [ApiController]
    [Route("api/[controller]")]
    public class UsersController : ControllerBase
    {
        [HttpGet("{id}")]
        public ActionResult<UserDto> GetUser(int id)
        {
            if (id <= 0)
                return BadRequest("User ID must be greater than 0.");
            
            var user = new UserDto { Id = id, Name = "Alice" };
            return Ok(user);
        }
        
        [HttpPost]
        public ActionResult<UserDto> CreateUser(CreateUserRequest request)
        {
            if (string.IsNullOrWhiteSpace(request.Name))
                return BadRequest("Name is required.");
            
            var user = new UserDto { Id = 1, Name = request.Name };
            return CreatedAtAction(nameof(GetUser), new { id = user.Id }, user);
        }
    }
    
    public class UserDto { public int Id { get; set; } public string Name { get; set; } }
    public class CreateUserRequest { public string Name { get; set; } }
    

    This example shows how ActionResult<T> lets the GetUser action return either a BadRequest (with explanation) or an Ok result with typed user data, and how CreateUser can return a CreatedAtAction result with a 201 status and location header.

    Key points to mention

    • IActionResult decouples the action logic from HTTP response details; the framework handles serialization and status codes.
    • ActionResult<T> provides type safety for the success case while still allowing you to return problem results (like BadRequest or NotFound).
    • Helper methods like Ok(), BadRequest(), NotFound(), and CreatedAtAction() are shortcuts that create the corresponding result objects.
    • You can return the typed object directly from an ActionResult<T> method, and it is implicitly wrapped in an Ok response.
    • For Minimal APIs, TypedResults provides similar functionality with better discoverability and OpenAPI support.

    Common follow-up questions

    • What's the difference between returning IActionResult and ActionResult<T>? IActionResult is the base interface with no type information; ActionResult<T> adds a typed body and makes it easier to document with Swagger/OpenAPI.
    • Can I create a custom result by implementing IActionResult? Yes, you implement ExecuteAsync(HttpContext) to write your custom logic directly to the response.
    • What happens if I just return an object without wrapping it in Ok()? In controller actions, the framework may auto-wrap it as a 200 response, but explicitly using Ok() or ActionResult<T> is clearer and more testable.

    Related interview questions

    .NETCsharp
  88. 88What is REST?

    Beginner

    REST (Representational State Transfer) is an architectural style for building web APIs that uses standard HTTP methods, resources identified by URIs, and stateless communication. It leverages HTTP semantics—GET to retrieve, POST to create, PUT to replace, PATCH to update, and DELETE to remove—making APIs predictable, scalable, and easy to integrate with.

    How it works

    REST treats everything as a resource identified by a unique URI (e.g., /api/products/42). Clients interact with these resources by sending HTTP requests with appropriate verbs, and servers respond with representations of those resources (typically JSON) and status codes that describe the outcome.

    The core principles are:

    • Client-Server Architecture: Separation of concerns between the client requesting data and the server managing it.
    • Statelessness: Each request contains all information needed; the server doesn't store client context between requests.
    • Standard HTTP Methods: Consistent use of GET (safe, idempotent), POST (creates), PUT (replaces), PATCH (partial update), and DELETE (removes).
    • Resource-Oriented: URIs represent nouns (resources), not verbs (actions). For example, use PUT /api/products/5 instead of GET /api/updateProduct?id=5.
    • Status Codes: Responses include meaningful HTTP status codes like 200 (OK), 201 (Created), 400 (Bad Request), 404 (Not Found), and 500 (Server Error) to communicate results clearly. See what are the common HTTP status codes for APIs for more detail.

    Example

    csharp
    // Simple REST API using minimal APIs in .NET 8
    var builder = WebApplication.CreateBuilder(args);
    var app = builder.Build();
    
    var products = new List<Product> 
    { 
        new(1, "Laptop", 999.99m), 
        new(2, "Mouse", 29.99m) 
    };
    
    // GET - Retrieve all products
    app.MapGet("/api/products", () => Results.Ok(products));
    
    // GET - Retrieve a single product
    app.MapGet("/api/products/{id}", (int id) => 
        products.FirstOrDefault(p => p.Id == id) is Product p 
            ? Results.Ok(p) 
            : Results.NotFound());
    
    // POST - Create a new product
    app.MapPost("/api/products", (Product newProduct) => 
    {
        products.Add(newProduct);
        return Results.Created($"/api/products/{newProduct.Id}", newProduct);
    });
    
    // PUT - Replace a product entirely
    app.MapPut("/api/products/{id}", (int id, Product updated) =>
    {
        var product = products.FirstOrDefault(p => p.Id == id);
        if (product is null) return Results.NotFound();
        product.Name = updated.Name;
        product.Price = updated.Price;
        return Results.Ok(product);
    });
    
    // DELETE - Remove a product
    app.MapDelete("/api/products/{id}", (int id) =>
    {
        var product = products.FirstOrDefault(p => p.Id == id);
        if (product is null) return Results.NotFound();
        products.Remove(product);
        return Results.NoContent();
    });
    
    app.Run();
    
    record Product(int Id, string Name, decimal Price);

    This example demonstrates all REST verbs: GET retrieves data, POST creates new products, PUT updates existing ones, and DELETE removes them—each returning appropriate status codes.

    Key points to mention

    • REST is not a protocol or standard; it's an architectural style that sits on top of HTTP.
    • Statelessness is critical—each request must be independent; the server should not rely on session data from previous requests.
    • Use nouns for URIs (e.g., /api/customers) and HTTP verbs for actions, not custom URL patterns like /api/getCustomer.
    • Always return meaningful status codes; a 200 OK with an error message in the body is misleading.
    • REST APIs should be idempotent where appropriate—GET, PUT, and DELETE should produce the same result if called multiple times with identical data.

    Common follow-up questions

    Related interview questions

    Further reading

    .NETCsharpArchitecture
  89. 89What is Swagger or OpenAPI?

    Beginner

    OpenAPI (formerly Swagger) is a standardized specification for describing REST APIs in a machine-readable format (JSON or YAML). Swagger UI is a tool that renders this specification as interactive documentation where developers can test endpoints directly in the browser. Clients, SDKs, and mock servers can be automatically generated from an OpenAPI document.

    How it works

    OpenAPI works by defining your entire API surface—endpoints, request/response schemas, authentication methods, and error codes—in a structured document. When you build an ASP.NET Core API, you can generate this document automatically using tools like Swashbuckle (which uses Swagger/OpenAPI under the hood).

    The workflow is: (1) you write API controllers with XML comments or attributes, (2) Swashbuckle scans your code at startup and generates an OpenAPI JSON/YAML file, (3) Swagger UI consumes that file and renders an interactive interface, and (4) clients can call your endpoints through the UI or generate typed client code from the specification.

    This approach ensures your documentation is always in sync with your actual code, eliminating the problem of outdated or manually-written API docs. It also enables contract-driven development where frontend teams can work from the OpenAPI spec even before backend implementation is complete.

    Example

    csharp
    using Microsoft.AspNetCore.Mvc;
    
    namespace MyApi.Controllers;
    
    /// <summary>
    /// Manages product operations.
    /// </summary>
    [ApiController]
    [Route("api/[controller]")]
    public class ProductsController : ControllerBase
    {
        /// <summary>
        /// Retrieves a product by ID.
        /// </summary>
        /// <param name="id">The product ID.</param>
        /// <returns>The product details.</returns>
        /// <response code="200">Product found.</response>
        /// <response code="404">Product not found.</response>
        [HttpGet("{id}")]
        [ProduceResponseType(typeof(ProductDto), StatusCodes.Status200OK)]
        [ProduceResponseType(StatusCodes.Status404NotFound)]
        public ActionResult<ProductDto> GetProduct(int id)
        {
            var product = new ProductDto { Id = id, Name = "Widget", Price = 19.99m };
            return Ok(product);
        }
    }
    
    public record ProductDto(int Id, string Name, decimal Price);
    

    This controller automatically generates OpenAPI documentation. Swashbuckle reads the XML comments and ProduceResponseType attributes to describe what the endpoint does, what it accepts, and what responses it returns. Swagger UI then displays this as an interactive page where you can click "Try it out" to test the endpoint.

    Key points to mention

    • Industry standard: OpenAPI is language-agnostic and widely adopted, making it easier for third parties to integrate with your API.
    • Self-documenting code: Documentation stays current with your implementation because it's generated directly from your code, not maintained separately.
    • Code generation: Clients, mock servers, and strongly-typed proxies can be generated from the OpenAPI spec, reducing manual coding.
    • Swashbuckle in .NET: ASP.NET Core has built-in integration via Swashbuckle NuGet package; it requires minimal setup (one-line registration and middleware).
    • Contract-first development: Teams can define the API contract in OpenAPI first, then implement backends and frontends in parallel.

    Common follow-up questions

    • How do you customize Swagger/OpenAPI documentation? Use XML comments, [Summary], [Description] attributes, and Swashbuckle filters to control what's exposed and how it appears.
    • Can you version APIs with OpenAPI? Yes; you can define multiple OpenAPI documents (one per version) and register them separately in Swashbuckle, allowing clients to see documentation for different API versions.
    • How does this relate to designing a good REST API? OpenAPI enforces clarity and consistency in API design; it encourages you to think through schemas, error responses, and endpoint behavior upfront.

    Related interview questions

    Further reading

    .NETCsharpTesting
  90. 90What is CORS?

    Beginner

    CORS (Cross-Origin Resource Sharing) is a browser security mechanism that allows web applications running on one origin (domain, protocol, or port) to make requests to APIs hosted on a different origin. By default, browsers block these cross-origin requests for security reasons, but CORS provides a standardized way to explicitly permit them by configuring allowed origins, HTTP methods, and headers on the server.

    How it works

    When a browser makes a cross-origin request, it first sends a preflight request (an OPTIONS request) to the server asking what origins, methods, and headers are allowed. The server responds with CORS headers indicating permissions. If the preflight passes, the browser allows the actual request (GET, POST, etc.) to proceed.

    For simple requests (GET, HEAD, POST with standard headers), the browser may skip the preflight and send the actual request directly, then check the response headers to determine if the cross-origin access is allowed.

    The key CORS response headers include:

    • Access-Control-Allow-Origin – specifies which origins can access the resource
    • Access-Control-Allow-Methods – specifies allowed HTTP methods (GET, POST, PUT, DELETE, etc.)
    • Access-Control-Allow-Headers – specifies which request headers are allowed
    • Access-Control-Allow-Credentials – indicates whether credentials (cookies, auth) can be included

    Example

    csharp
    // Program.cs (.NET 8+)
    var builder = WebApplication.CreateBuilder(args);
    
    // Define a CORS policy
    builder.Services.AddCors(options =>
    {
        options.AddPolicy("AllowFrontend", policy =>
        {
            policy.WithOrigins("https://example.com", "https://app.example.com")
                  .AllowAnyMethod()
                  .AllowAnyHeader()
                  .AllowCredentials();
        });
    });
    
    builder.Services.AddControllers();
    var app = builder.Build();
    
    // Apply the CORS policy globally
    app.UseCors("AllowFrontend");
    
    app.MapControllers();
    app.Run();
    

    This configuration allows requests from two specific origins, permits any HTTP method and header, and allows credentials to be sent with requests.

    Key points to mention

    • CORS is a browser-enforced security feature; it doesn't prevent server-to-server API calls or non-browser clients from accessing your API
    • You can define multiple CORS policies and apply them selectively to different endpoints or controllers using the [EnableCors] attribute, similar to how filters in ASP.NET Core work
    • In development, you can use AllowAnyOrigin(), but in production you should explicitly whitelist trusted origins for security
    • Avoid setting AllowAnyOrigin() with AllowCredentials() – this combination is rejected by browsers as a security violation
    • CORS must be configured before authentication middleware in the middleware pipeline to work correctly

    Common follow-up questions

    • What is the difference between a CORS preflight request and a simple request?
    • How do you apply CORS to specific controllers or endpoints rather than globally?
    • What security risks should you consider when configuring CORS?

    Related interview questions

    .NETCsharpaspnetcore
  91. 91What is Entity Framework Core?

    Beginner

    Entity Framework Core (EF Core) is the official Object-Relational Mapping (ORM) framework for .NET. It enables developers to work with databases using .NET objects instead of raw SQL, automatically mapping C# classes to database tables and translating LINQ queries into SQL commands. EF Core handles database operations, change tracking, migrations, and relationships declaratively.

    How it works

    Entity Framework Core acts as an abstraction layer between your application and the database. You define C# classes (called entities) that represent your data model. EF Core maintains a DbContext, which represents your database session and coordinates all database operations.

    When you query using LINQ, EF Core translates your C# expressions into SQL at runtime. For example, a LINQ Where() clause becomes a SQL WHERE statement. EF Core then executes the SQL against your database and materializes the results back into C# objects.

    Change tracking is a core feature: EF Core monitors which entities have been created, modified, or deleted during your DbContext's lifetime. When you call SaveChanges(), it generates the appropriate INSERT, UPDATE, or DELETE statements based on these changes.

    Migrations provide version control for your database schema. Instead of manually running SQL scripts, you define your model in code, and EF Core generates migration files that evolve your database schema over time.

    Example

    csharp
    // Define your entities
    public class Product
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public decimal Price { get; set; }
        public int CategoryId { get; set; }
        public Category Category { get; set; }
    }
    
    public class Category
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public List<Product> Products { get; set; } = new();
    }
    
    // Define your DbContext
    public class AppDbContext : DbContext
    {
        public DbSet<Product> Products { get; set; }
        public DbSet<Category> Categories { get; set; }
    
        protected override void OnConfiguring(DbContextOptionsBuilder options)
            => options.UseSqlServer("Server=.;Database=Shop;Trusted_Connection=true;");
    }
    
    // Usage
    using var context = new AppDbContext();
    
    // Query with LINQ (translates to SQL)
    var expensiveProducts = context.Products
        .Where(p => p.Price > 100)
        .Include(p => p.Category)
        .ToList();
    
    // Create and save
    var newProduct = new Product { Name = "Laptop", Price = 1500, CategoryId = 1 };
    context.Products.Add(newProduct);
    context.SaveChanges(); // Generates INSERT statement
    

    This example demonstrates querying products by price with their related category, then creating and persisting a new product—all using C# objects and LINQ instead of SQL.

    Key points to mention

    • LINQ to SQL translation: EF Core converts LINQ expressions into database-specific SQL queries at runtime.
    • Change tracking: EF Core automatically detects and tracks modifications to entities, generating appropriate SQL statements on SaveChanges().
    • Relationships: Navigation properties define one-to-many, many-to-many, and one-to-one relationships declaratively in code.
    • Migrations: Database schema evolution is managed through code-based migrations rather than manual SQL scripts.
    • Database agnostic: EF Core supports multiple database providers (SQL Server, PostgreSQL, SQLite, MySQL) with minimal code changes.

    Common follow-up questions

    Related interview questions

    Further reading

    .NETCsharpefcore
  92. 92What is a DbContext?

    Beginner

    DbContext is the core Entity Framework Core class that represents a session with the database. It provides an abstraction layer between your application and the database, exposing DbSet properties for each entity, tracking changes to objects in memory, and coordinating the saving of those changes back to the database through SaveChanges.

    How it works

    DbContext acts as a bridge between your domain objects and the database. When you create a DbContext instance, it opens a logical session that manages the lifecycle of your entities. The context maintains an internal change tracker that monitors every entity you load, create, modify, or delete. This is part of the Unit of Work pattern, where all changes are batched together and committed in a single transaction.

    Each DbSet<T> property represents a collection of entities of type T in the database. When you query through a DbSet, the context tracks the returned entities. As you modify these objects, the context detects those changes through its change tracking mechanism. When you call SaveChanges(), the context generates appropriate SQL INSERT, UPDATE, or DELETE statements and executes them within a transaction, ensuring data consistency.

    DbContext is typically configured with connection strings and model mappings in the OnConfiguring method or through dependency injection. Modern applications almost always use constructor-based dependency injection to register DbContext in the service container, making it easy to manage its lifetime and enable features like automatic connection pooling.

    Example

    csharp
    public class ApplicationDbContext : DbContext
    {
        public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
            : base(options) { }
    
        public DbSet<Product> Products { get; set; }
        public DbSet<Order> Orders { get; set; }
    
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Product>().HasKey(p => p.Id);
            modelBuilder.Entity<Order>().HasKey(o => o.Id);
        }
    }
    
    // Usage in a service
    public class ProductService
    {
        private readonly ApplicationDbContext _context;
    
        public ProductService(ApplicationDbContext context)
        {
            _context = context;
        }
    
        public async Task AddProductAsync(Product product)
        {
            _context.Products.Add(product);
            await _context.SaveChangesAsync();
        }
    }
    

    This example shows a custom DbContext defining two entities, configured via dependency injection, and then used in a service to add and persist a new product to the database.

    Key points to mention

    • DbContext maintains a session and tracks all entity changes in memory until SaveChanges is called
    • DbSet<T> properties expose queryable collections for LINQ operations against specific entity types
    • The context must be disposed properly (typically handled automatically with dependency injection scopes) to release database connections
    • DbContext is not thread-safe; use a new instance per request in web applications or employ synchronization for other scenarios
    • OnModelCreating allows fluent configuration of entity mappings, relationships, and database constraints

    Common follow-up questions

    • How do I configure DbContext with a connection string in dependency injection?
    • What is the difference between tracking and non-tracking queries?
    • How does SaveChanges differ from SaveChangesAsync, and when should I use each?

    Related interview questions

    .NETCsharpefcore
  93. 93What are EF Core migrations?

    Beginner

    EF Core migrations are a version control system for your database schema. They automatically track changes to your Entity Framework Core models and generate SQL scripts that transform your database from one schema state to another. Migrations let you evolve your database alongside your code without losing data.

    How it works

    When you modify a DbContext or entity class, EF Core doesn't automatically update the database. Instead, you use the migration system to capture those changes as discrete, reversible steps.

    The workflow is straightforward: after changing your model, you run dotnet ef migrations add MigrationName to create a migration file. This file contains an Up() method (applies the change) and a Down() method (reverts it). EF Core analyzes the difference between your current model and the database schema snapshot, then generates the SQL. Finally, you run dotnet ef database update to apply pending migrations to the database.

    Each migration is stored as a C# file in your Migrations folder alongside a snapshot file that tracks the complete schema state. This history forms an audit trail of all schema changes, making it easy to understand when and why changes occurred.

    Example

    csharp
    // Original model
    public class Product
    {
        public int Id { get; set; }
        public string Name { get; set; }
    }
    
    // After adding a property
    public class Product
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public decimal Price { get; set; }  // New property
    }
    
    // Terminal: dotnet ef migrations add AddPriceToProduct
    // This generates Up() with: migrationBuilder.AddColumn("Price", "Products", ...);
    // And Down() with: migrationBuilder.DropColumn("Price", "Products", ...);
    
    // Terminal: dotnet ef database update
    // Applies the migration to the database

    This example shows how a single property addition translates into a generated migration file with both forward and rollback logic.

    Key points to mention

    • Idempotency: Migrations track which ones have been applied via the __EFMigrationsHistory table, preventing duplicate execution.
    • Snapshot files: EF Core maintains a model snapshot to compare against when generating new migrations, ensuring accuracy.
    • Reversibility: The Down() method lets you roll back a migration with dotnet ef database update <PreviousMigration>.
    • Data loss risk: Some operations (like dropping columns) are destructive; you should review generated migrations before applying them to production, especially for zero-downtime deployments.
    • Customization: Generated migrations can be edited manually to include seed data or custom SQL logic.

    Common follow-up questions

    • How do you handle merge conflicts when multiple developers create migrations on different branches?
    • Can you safely rename a column without losing data, and how does EF Core handle this?
    • What's the difference between Update-Database and Script-Migration, and when would you use each?

    Related interview questions

    .NETCsharpefcore
  94. 94What is the difference between Add and Update in EF Core?

    Beginner

    Add marks an entity as new and schedules it for insertion into the database. Update marks an entity as modified and schedules all its properties for update in the database. The key difference is their intent: Add assumes the entity doesn't exist yet, while Update assumes it already exists and needs changes.

    How it works

    When you call DbContext.Add(), Entity Framework Core assigns the entity an EntityState of Added. This state tells EF Core to generate an INSERT statement when SaveChanges() is called. The entity is tracked by the context, and all property values are captured for the INSERT command.

    When you call DbContext.Update(), EF Core assigns an EntityState of Modified. This tells EF Core to generate an UPDATE statement. Importantly, Update marks all properties as changed, regardless of whether you actually modified them. This means every column in the UPDATE statement will be SET, even if the value hasn't changed.

    The distinction matters because Add expects a new entity (often with a default or zero primary key), while Update expects an entity that already exists in the database and needs synchronization. If you call Update on an entity that doesn't exist, the UPDATE statement will execute but affect zero rows—no error occurs, but the intended change doesn't happen.

    Example

    csharp
    var context = new BlogContext();
    
    // Using Add - for new entities
    var newBlog = new Blog { Title = "My New Blog" };
    context.Add(newBlog);
    await context.SaveChangesAsync();
    // Generates: INSERT INTO Blogs (Title) VALUES ('My New Blog')
    
    // Using Update - for existing entities
    var existingBlog = new Blog { Id = 1, Title = "Updated Title" };
    context.Update(existingBlog);
    await context.SaveChangesAsync();
    // Generates: UPDATE Blogs SET Title = 'Updated Title' WHERE Id = 1
    

    This example shows Add creating a new blog record and Update modifying an existing one identified by its Id.

    Aspect Add Update
    Entity State Added Modified
    SQL Generated INSERT UPDATE
    Property Tracking Only provided values included All properties marked as changed
    Use Case New entities not in database Existing entities requiring modification
    Primary Key Usually generated or default Must exist in database
    If Entity Doesn't Exist INSERT succeeds; new record created UPDATE executes but affects 0 rows
    What is the difference between Add and Update in EF Core diagram

    Key points to mention

    • EntityState matters: Add uses EntityState.Added, Update uses EntityState.Modified. You can check entry.State to verify the current state.
    • All properties updated: Update marks all properties as changed, so UPDATE statements include every column. This differs from attaching an entity and modifying only specific properties.
    • Tracking vs. detached entities: Both methods work on tracked or detached entities. Use Add for new detached entities and Update for existing detached entities that need full synchronization.
    • Performance consideration: If you only need to update specific properties, consider using DbContext.Attach() followed by property-level changes instead of Update for better performance.
    • Relationships: Add handles relationship setup for new entities; Update assumes relationships already exist in the database.

    Common follow-up questions

    • What's the difference between Update and Attach? How would you update only specific properties?
    • What happens if you call Add on an entity with an existing primary key value?
    • How does Update interact with EF Core migrations and schema changes?

    Related interview questions

    What is the difference between Add and Update in EF Core?
    .NETCsharpefcore
  95. 95What is AsNoTracking?

    Beginner

    AsNoTracking is a LINQ method that tells Entity Framework Core to retrieve entities without attaching them to the change tracker. This makes queries faster and more memory-efficient for read-only scenarios where you don't plan to modify the returned data.

    How it works

    By default, when Entity Framework Core executes a query, it attaches the returned entities to its internal change tracker. The change tracker monitors these entities for modifications, allowing EF Core to detect and persist changes when you call SaveChanges(). However, this monitoring consumes memory and CPU cycles.

    AsNoTracking disables this behavior for a specific query. The retrieved entities are materialized into objects but are never attached to the change tracker. This means:

    • EF Core doesn't allocate memory for change tracking snapshots
    • The query executes faster because there's no tracking overhead
    • You cannot modify and persist those entities using SaveChanges()
    • Multiple calls to retrieve the same entity by ID will create separate instances (since there's no identity map)

    This approach is ideal for read-only scenarios like displaying data in a UI, generating reports, or building search results where you have no intention of updating the database.

    Example

    csharp
    // WITH tracking (default behavior)
    var orders = _context.Orders
        .Where(o => o.CustomerId == 42)
        .ToList();  // Entities are tracked
    
    // WITHOUT tracking (read-only)
    var ordersReadOnly = _context.Orders
        .AsNoTracking()
        .Where(o => o.CustomerId == 42)
        .ToList();  // Entities are NOT tracked
    
    // Attempting to modify and save won't work
    ordersReadOnly.First().Amount = 999;
    _context.SaveChanges();  // Changes won't be persisted
    

    In the first query, the Order entities are tracked and any modifications you make will be detected and saved. In the second query with AsNoTracking(), the entities are pure data objects with no connection to the change tracker, so modifications are ignored.

    Key points to mention

    • AsNoTracking is a performance optimization for queries where you only need to read data, not modify it
    • It reduces memory usage and query execution time by eliminating change tracking overhead
    • Related to this concept is using projections with Select, which is even more efficient because it only retrieves the columns you need
    • You cannot call SaveChanges() on untracked entities to persist modifications—you must re-attach them or query with tracking
    • The opposite pattern is change tracking, which is necessary when you need to update entities

    Common follow-up questions

    • Can I attach an untracked entity back to the context? Yes, using _context.Attach(entity) or _context.Update(entity), but you'll need to manually mark properties as modified.
    • Should I always use AsNoTracking? No. Use it only for read-only queries. For update/delete operations, you need tracking to detect changes.
    • How does AsNoTracking compare to using DTO projections? Projections are often better because they fetch only needed columns and are inherently untracked, avoiding unnecessary data transfer.

    Related interview questions

    Further reading

    .NETCsharpPerformance
  96. 96What is the difference between IQueryable and IEnumerable?

    Beginner

    IQueryable translates LINQ expressions into provider-specific queries (usually SQL) executed on the server, while IEnumerable executes in-memory using LINQ-to-Objects. This means IQueryable is deferred, filtered at the source, and more efficient for large datasets; IEnumerable loads data first, then filters in your application.

    How it works

    IQueryable uses an expression tree—a representation of your query as data—which a provider (like Entity Framework Core) translates into SQL or another language before execution. When you call .Where() on an IQueryable, you're not executing the filter; you're building an abstract syntax tree. Only when you materialize the result (with .ToList(), .FirstOrDefault(), or iteration) does the provider send SQL to the database.

    IEnumerable uses delegates and LINQ-to-Objects, executing in-memory. Filters happen after data is loaded into your application. If you call .Where() on an IEnumerable of 1 million records, all 1 million are loaded first, then filtered in memory—potentially wasteful.

    The key insight: defer execution until necessary, and push filtering as close to the data source as possible. Calling .ToList() converts IQueryable to IEnumerable, severing the connection to the database provider.

    Example

    csharp
    using Microsoft.EntityFrameworkCore;
    
    var context = new AppDbContext();
    
    // IQueryable: builds SQL expression tree, filters on server
    IQueryable<User> queryable = context.Users
        .Where(u => u.Age > 30)
        .Where(u => u.City == "New York");
    
    // Nothing executed yet. Inspect queryable.ToString() to see the SQL expression.
    
    // IEnumerable: loads all users into memory, filters in-app
    IEnumerable<User> enumerable = context.Users
        .ToList()  // ← SQL executes here; all rows loaded
        .Where(u => u.Age > 30)
        .Where(u => u.City == "New York");
    
    // This inefficient pattern loads 1M rows, then filters in RAM.
    
    var results = queryable.ToList();  // ← SQL executes NOW with WHERE clauses; fewer rows returned

    The IQueryable version sends SELECT * FROM Users WHERE Age > 30 AND City = 'New York' to the database; the IEnumerable version fetches all users, then filters in your process.

    Key points to mention

    • Deferred execution: IQueryable delays translation until materialization; IEnumerable executes immediately upon filtering.
    • Data source: IQueryable targets external sources (databases); IEnumerable is in-memory only, using LINQ-to-Objects.
    • Performance: IQueryable is far more efficient for large datasets because filtering happens server-side; IEnumerable loads everything into RAM first.
    • Conversion: Calling .ToList(), .ToArray(), or .FirstOrDefault() on IQueryable executes the query and returns IEnumerable (or a concrete type). After that, no further filtering goes to the database.
    • Expressions: IQueryable accepts Expression<Func> predicates; IEnumerable accepts Func delegates. Expressions are code-as-data, enabling translation.

    Common follow-up questions

    • When would you intentionally use IEnumerable over IQueryable? When you need in-memory LINQ with custom logic that the database provider cannot translate, or when working with collections already loaded in memory.
    • What happens if you chain IQueryable and IEnumerable together? The moment you call .ToList(), you create an IEnumerable; all subsequent LINQ operations execute in-memory, defeating server-side query optimization.
    • Can you use .Select() on IQueryable to project before .ToList()? Yes, and you should—select only the columns you need server-side to reduce data transfer and memory usage.
    Aspect IQueryable IEnumerable
    Execution Model Deferred; translates to SQL/provider-specific query Deferred; executes in-memory with delegates
    Data Source External (database, OData, etc.) via provider In-memory collections
    When Query Runs At materialization (ToList, FirstOrDefault, iteration) At materialization, but filtering happens in-app
    Filtering Location Server-side; only filtered rows returned Client-side; all data loaded, then filtered in RAM
    Performance (1M rows) Excellent; WHERE clause sent to database Poor; all 1M rows loaded into memory
    Use Case EF Core queries, LINQ-to-SQL, large datasets In-memory lists, arrays, already-materialized data
    What is the difference between IQueryable and IEnumerable diagram

    Related interview questions

    What is the difference between IQueryable and IEnumerable?
    .NETCsharpefcorelinq
  97. 97What is eager loading in EF Core?

    Beginner

    Eager loading is a technique in EF Core where you explicitly load related data from the database in the same query as the parent entity, using the Include() method. This prevents the N+1 query problem by combining multiple tables into a single database round trip instead of executing separate queries for each related entity.

    How it works

    When you query an entity in EF Core, by default only that entity's columns are loaded from the database. Related entities are loaded lazily—meaning they're fetched only when you access them in code, triggering additional queries. Eager loading changes this behavior by telling EF Core to fetch related data upfront using Include() or ThenInclude().

    EF Core translates your Include() chains into SQL JOINs (for single relationships) or separate queries (depending on the navigation structure). The result is that all necessary data arrives in one or two round trips, eliminating the performance penalty of lazy loading.

    Eager loading is particularly useful when you know you'll need related data immediately. It's a deliberate, explicit choice—you declare your data dependencies upfront, making queries more predictable and performant in high-traffic scenarios.

    Example

    csharp
    // Entities
    public class Author
    {
        public int AuthorId { get; set; }
        public string Name { get; set; }
        public ICollection<Book> Books { get; set; }
    }
    
    public class Book
    {
        public int BookId { get; set; }
        public string Title { get; set; }
        public int AuthorId { get; set; }
        public Author Author { get; set; }
    }
    
    // Eager loading example
    using var context = new LibraryContext();
    
    // Load all authors with their books in a single query
    var authors = context.Authors
        .Include(a => a.Books)
        .ToList();
    
    // Access related data without extra queries
    foreach (var author in authors)
    {
        Console.WriteLine($"{author.Name} wrote {author.Books.Count} books");
    }
    

    This query generates a JOIN or a query with the related Books data included, so all books are available immediately without triggering additional database queries.

    Key points to mention

    • Use Include() to specify related data: Chain Include() for single-level relationships and ThenInclude() for nested relationships (e.g., .Include(a => a.Books).ThenInclude(b => b.Publisher)).
    • Improves performance for known dependencies: Eager loading reduces database round trips when you're certain you'll access related entities, avoiding the N+1 problem.
    • Explicit and declarative: Unlike lazy loading, eager loading makes your data requirements visible in code, improving maintainability and debugging.
    • Applies to DbContext.SaveChanges() queries: You call ToList(), FirstOrDefault(), or similar to materialize results; eager loading happens at query execution time.
    • Trade-off with memory: Eager loading can load more data than needed, so use it judiciously for critical queries.

    Common follow-up questions

    • What's the difference between eager loading and lazy loading? Eager loading fetches related data upfront with Include(); lazy loading fetches it on-demand when accessed. Eager loading prevents extra queries but loads more data; lazy loading is flexible but risks N+1 queries.
    • When should I use eager loading versus explicit loading or projection? Use eager loading when you need all related data. Use explicit loading (context.Entry().Collection().Load()) for conditional scenarios. Use projections (selecting only needed columns with Select()) for read-only scenarios to reduce memory footprint.
    • How does eager loading affect SQL performance? Eager loading generates JOINs, which can be large result sets. For complex graphs, Select() projections or multiple queries may be faster than a massive JOIN.

    Related interview questions

    .NETCsharpefcore
  98. 98What is logging in ASP.NET Core?

    Beginner

    Logging in ASP.NET Core is the built-in mechanism for recording application events and diagnostics using the ILogger<T> interface. It provides structured logging that routes messages to configurable providers (console, files, cloud services) with minimal setup. Unlike older logging frameworks, ASP.NET Core logging is lightweight, performant, and integrated by default.

    How it works

    ASP.NET Core uses a provider-based architecture where the ILoggerFactory creates logger instances for your classes. You inject ILogger<T> into any service via dependency injection, and it automatically uses providers registered in Program.cs. Message templates allow structured logging with named parameters (like "User {UserId} logged in"), which enables filtering, querying, and rich analysis in external log aggregators like Seq or Application Insights. Log levels (Trace, Debug, Information, Warning, Error, Critical) control verbosity per category or provider. Each log entry is enriched with timestamp, level, category name, and scope information automatically.

    Example

    csharp
    // Program.cs
    var builder = WebApplication.CreateBuilder(args);
    
    builder.Services.AddLogging(config =>
    {
        config.ClearProviders();
        config.AddConsole();
        config.AddDebug();
        config.SetMinimumLevel(LogLevel.Information);
    });
    
    builder.Services.AddScoped<UserService>();
    var app = builder.Build();
    app.Run();
    
    // UserService.cs
    public class UserService
    {
        private readonly ILogger<UserService> _logger;
    
        public UserService(ILogger<UserService> logger)
        {
            _logger = logger;
        }
    
        public void LoginUser(int userId, string email)
        {
            _logger.LogInformation("User {UserId} with email {Email} is logging in", userId, email);
            
            if (userId < 1)
            {
                _logger.LogWarning("Invalid user ID attempted: {UserId}", userId);
            }
        }
    }
    

    This code injects ILogger<UserService>, adds console and debug providers, and logs structured messages with named parameters—enabling external tools to query logs by UserId or Email without parsing strings.

    Key points to mention

    • Built-in and zero-config: No NuGet packages required; logging works out of the box in ASP.NET Core templates.
    • Use message templates, not string concatenation: Write _logger.LogInformation("User {Id}", id) instead of _logger.LogInformation($"User {id}") for structured data.
    • Multiple providers: Console, Debug, EventLog, EventSource, and third-party providers (Serilog, NLog, Seq) work interchangeably.
    • Log filtering: Control verbosity per category and log level in configuration; production typically uses Warning or higher.
    • Performance: ASP.NET Core logs only when the level is enabled, and uses efficient structured formats instead of string building.

    Common follow-up questions

    • How do you configure different log levels for different namespaces or providers?
    • What is the difference between LogDebug, LogInformation, and LogError, and when should you use each?
    • How do you add custom log providers or integrate Serilog for enhanced structured logging?

    Related interview questions

    Further reading

    .NETCsharpaspnetcore
  99. 99What is a Razor Page?

    Beginner

    A Razor Page is a page-focused programming model in ASP.NET Core where each page consists of a .cshtml view file paired with a C# code-behind class called a PageModel. It simplifies web application development by combining routing, rendering, and request handling into a single, cohesive unit per page, making it ideal for form-driven applications and smaller to medium-sized projects.

    How it works

    Razor Pages operates on a file-based routing convention rather than explicit controller actions. When you create a .cshtml file in the Pages folder, ASP.NET Core automatically maps it to a URL route based on its file path. For example, a file at Pages/Products/Index.cshtml is accessible at /Products/Index.

    Each Razor Page has an associated PageModel class that handles:

    • HTTP request handling: OnGet, OnPost, OnPut, OnDelete methods correspond to HTTP verbs
    • Data binding: Form data automatically binds to public properties via model binding
    • Business logic: Validation, database operations, and service calls
    • View data: Properties and methods accessible directly in the .cshtml markup

    This differs from MVC in ASP.NET Core, which separates controllers from views more explicitly. Razor Pages keeps the code and markup relationships tighter and more discoverable.

    Example

    csharp
    // Pages/Products/Create.cshtml.cs
    using Microsoft.AspNetCore.Mvc;
    using Microsoft.AspNetCore.Mvc.RazorPages;
    
    public class CreateModel : PageModel
    {
        private readonly IProductService _productService;
    
        public CreateModel(IProductService productService)
        {
            _productService = productService;
        }
    
        [BindProperty]
        public Product Product { get; set; } = new();
    
        public void OnGet() { }
    
        public async Task<IActionResult> OnPostAsync()
        {
            if (!ModelState.IsValid)
                return Page();
    
            await _productService.AddProductAsync(Product);
            return RedirectToPage("Index");
        }
    }
    

    The corresponding .cshtml file uses the PageModel properties directly via @Model, and form posts automatically invoke OnPostAsync() with data bound to the Product property.

    Key points to mention

    • Convention-based routing: File location determines URL without explicit route declarations
    • Built-in model binding: [BindProperty] automatically maps form data to public properties
    • Simpler for small-to-medium projects: Less boilerplate than full MVC, faster to build form-heavy applications
    • Handler methods: OnGet, OnPost, OnPut, OnDelete with optional async variants (OnGetAsync, OnPostAsync, etc.)
    • Dependency injection support: Constructor injection works seamlessly with PageModel classes

    Common follow-up questions

    • When would you choose Razor Pages over MVC? — When building form-centric applications, line-of-business apps, or small projects where file-per-page organization improves maintainability
    • How does routing work in Razor Pages? — It's convention-based; the folder structure under Pages/ maps directly to URLs, with optional @page directives for custom routes
    • Can you share code between Razor Pages? — Yes, via Partial Pages, base PageModel classes, services, and TagHelpers for reusable UI and logic

    Related interview questions

    .NETCsharpaspnetcore
  100. 100What is MVC in ASP.NET Core?

    Beginner

    MVC (Model-View-Controller) is an architectural pattern in ASP.NET Core that separates an application into three interconnected components: Models (data), Views (UI), and Controllers (request handlers). Controllers receive HTTP requests, process business logic, and return Views rendered with Razor—making it ideal for server-side web applications where the server generates HTML.

    How it works

    The MVC pattern divides responsibility to improve maintainability and testability. When a user makes a request:

    1. Controller receives the HTTP request and routes to an action method.
    2. Model represents the data—either retrieved from a database or accepted from the request. It contains both data and validation logic.
    3. View is a Razor template (.cshtml file) that renders HTML to the user, displaying the Model data dynamically.
    4. The controller returns the View with the Model, and Razor renders it server-side before sending HTML to the browser.

    This separation means your data layer doesn't know about UI rendering, UI doesn't contain business logic, and controllers orchestrate the flow. Unlike Razor Pages, which combine these concerns in a single file, MVC keeps them strictly separate.

    Example

    csharp
    // Model
    public class Product
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public decimal Price { get; set; }
    }
    
    // Controller
    public class ProductController : Controller
    {
        private readonly ApplicationDbContext _context;
        
        public ProductController(ApplicationDbContext context)
        {
            _context = context;
        }
        
        // Action method receives request, fetches model, returns view
        public async Task Details(int id)
        {
            var product = await _context.Products.FindAsync(id);
            if (product == null)
                return NotFound();
            return View(product); // Pass model to view
        }
    }
    
    // View (Details.cshtml)
    @model Product
    
    <h1>@Model.Name</h1>
    <p>Price: $@Model.Price</p>

    The controller fetches a Product from the database, passes it to the Details.cshtml view, which renders it as HTML with the product's name and price.

    Key points to mention

    • Separation of concerns: Each component has a single responsibility, making code easier to test and maintain.
    • Server-side rendering: HTML is generated on the server using Razor before being sent to the browser, unlike client-side frameworks.
    • Routing: ASP.NET Core maps URLs to controller actions automatically (e.g., /Product/Details/5 calls ProductController.Details(5)).
    • Model binding: ASP.NET Core automatically binds request data (query strings, form data) to action method parameters—reducing boilerplate code.
    • Dependency injection: Controllers receive dependencies via constructor injection, enabling testability and loose coupling.

    Common follow-up questions

    • What's the difference between MVC and Razor Pages? (Razor Pages combine Model and View logic in a single file with a code-behind, while MVC separates them completely.)
    • How does model validation fit into MVC? (Validation logic lives in the Model via data annotations or FluentValidation; controllers check ModelState before processing.)
    • Can I use MVC and Razor Pages in the same project? (Yes, ASP.NET Core supports both patterns simultaneously.)

    Related interview questions

    .NETCsharpaspnetcore
  101. 101What is Blazor?

    Beginner

    Blazor is a .NET framework for building interactive web applications using C# and Razor syntax instead of JavaScript. It runs either on the server (Blazor Server) or in the browser via WebAssembly (Blazor WebAssembly), and in .NET 8+ you can use Interactive Auto mode to combine both. This allows full-stack .NET development where your UI logic, business logic, and data access all use C#.

    How it works

    Blazor uses a component-based architecture where you build reusable UI components with C# and Razor markup. The framework handles rendering and interactivity through different hosting models:

    Blazor Server runs your component logic on the server. When a user interacts with the page, a WebSocket connection sends events to the server, which re-renders the component and sends UI updates back to the browser. This model is ideal for real-time applications and complex server-side logic, but requires a persistent connection.

    Blazor WebAssembly compiles your .NET code to WebAssembly, which runs directly in the browser. Your entire application downloads to the client, so it works offline and reduces server load, but initial load time is larger and server communication requires HTTP calls.

    Interactive Auto (introduced in .NET 8) intelligently switches between Server rendering (initial load) and WebAssembly (interactive updates) to optimize performance automatically.

    Blazor components are Razor files (.razor) containing markup, C# code, and CSS. They use familiar ASP.NET Core concepts like dependency injection, routing, forms, and data binding, making the transition from traditional ASP.NET smooth for .NET developers.

    Example

    csharp
    @page "/counter"
    @rendermode InteractiveAuto
    
    <PageTitle>Counter</PageTitle>
    
    <h1>Counter</h1>
    
    <p role="status">Current count: @count</p>
    
    <button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
    
    @code {
        private int count = 0;
    
        private void IncrementCount()
        {
            count++;
        }
    }

    This Blazor component renders a counter button that increments a variable. The @rendermode InteractiveAuto directive enables automatic mode switching, @onclick binds the button click to the C# method, and the markup updates when state changes.

    Key points to mention

    • Blazor eliminates the need to write JavaScript for interactive UIs; C# developers can build full-stack web apps in a single language.
    • Three hosting models exist: Server (persistent WebSocket connection), WebAssembly (runs in browser), and Interactive Auto (.NET 8+, combines both intelligently).
    • Components use Razor syntax with embedded C# code, similar to ASP.NET MVC Razor views, making it familiar to .NET developers.
    • Blazor supports dependency injection and scoped services, allowing clean architecture and testable code.
    • WebAssembly apps are larger on first load but offer offline capability and reduce server workload, while Server mode provides real-time updates with smaller payload sizes.

    Common follow-up questions

    • What is the difference between Blazor Server and Blazor WebAssembly? – Expect to discuss connection models, latency, offline support, and when to use each.
    • How do you call a .NET API from a Blazor WebAssembly app? – You'll use HttpClient to make HTTP requests to backend endpoints, similar to JavaScript SPAs.
    • Can Blazor replace ASP.NET MVC? – Blazor is for interactive UIs; traditional ASP.NET Core MVC is still better for traditional server-rendered pages or when you want simpler HTML without JavaScript interactivity.

    Related interview questions

    .NETCsharpaspnetcore
  102. 102What is a unit test?

    Beginner

    A unit test is an automated test that verifies a single, small piece of code—typically a method or function—in isolation from the rest of the application. Unit tests are the foundation of the testing pyramid and help catch bugs early, ensure code quality, and make refactoring safer. In .NET, popular frameworks include xUnit, NUnit, and MSTest.

    How it works

    Unit testing follows a simple pattern: Arrange, Act, Assert (AAA). First, you set up the conditions and inputs (Arrange). Then, you execute the code you're testing (Act). Finally, you verify that the output matches your expectations (Assert).

    A unit test isolates the code under test by removing external dependencies like databases, APIs, or file systems. This isolation is achieved through mocking and stubbing—replacing real dependencies with test doubles that behave in a controlled way. This ensures your test fails only when the code logic itself is wrong, not because an external service is unavailable.

    Each unit test should be small, focused, and independent. It should run quickly (milliseconds), require no special setup, and not depend on other tests or external systems. This allows you to run hundreds of tests in seconds and get immediate feedback during development.

    Example

    csharp
    // Production code
    public class Calculator
    {
        public int Add(int a, int b) => a + b;
        public int Subtract(int a, int b) => a - b;
    }
    
    // Unit test using xUnit
    public class CalculatorTests
    {
        [Fact]
        public void Add_WithTwoPositiveNumbers_ReturnsSum()
        {
            // Arrange
            var calculator = new Calculator();
            int a = 5, b = 3;
    
            // Act
            int result = calculator.Add(a, b);
    
            // Assert
            Assert.Equal(8, result);
        }
    
        [Theory]
        [InlineData(10, 5, 5)]
        [InlineData(0, 0, 0)]
        [InlineData(-5, 3, -2)]
        public void Subtract_WithVariousInputs_ReturnsCorrectDifference(int a, int b, int expected)
        {
            var calculator = new Calculator();
            int result = calculator.Subtract(a, b);
            Assert.Equal(expected, result);
        }
    }

    This example shows two unit tests: one that tests a specific case and another that uses [Theory] with multiple data sets to test the same logic with different inputs.

    Key points to mention

    • Isolation: Tests should verify one logical unit of behavior without external dependencies; mock or stub external services.
    • Speed: Unit tests must run in milliseconds, enabling frequent execution and rapid feedback during development.
    • Clarity: Test names should describe what is being tested and the expected outcome (e.g., Add_WithTwoPositiveNumbers_ReturnsSum).
    • Independence: Each test should be independent; tests should not rely on shared state or the order of execution.
    • Coverage: Aim for meaningful coverage of business logic and edge cases, not arbitrary percentage targets; quality over quantity.

    Common follow-up questions

    • How do you mock dependencies in unit tests? (Typically answered with Moq or NSubstitute libraries.)
    • What's the difference between unit tests, integration tests, and end-to-end tests? (Unit tests isolate a single method; integration tests verify multiple components together; E2E tests simulate real user workflows.)
    • How do you achieve good test coverage without over-testing? (Focus on critical paths, edge cases, and error scenarios rather than testing every line.)

    Related interview questions

    Further reading

    .NETCsharpTesting
  103. 103What is xUnit?

    Beginner

    xUnit is a free, open-source unit testing framework for .NET that provides a clean, simple API for writing and organizing tests. It uses attributes like [Fact] for single test cases and [Theory] with [InlineData] for parameterized tests. xUnit is designed to be developer-friendly and integrates seamlessly with Visual Studio, Visual Studio Code, and the dotnet CLI.

    How it works

    xUnit works by allowing you to mark test methods with attributes that tell the test runner how to execute them. When you run tests using the dotnet test command, xUnit discovers all methods decorated with [Fact] or [Theory], executes them in isolation, and reports pass/fail results. Each test is independent—xUnit creates a fresh instance of your test class for each test method, ensuring tests don't interfere with each other.

    The framework follows the AAA pattern (Arrange, Act, Assert): you set up your test data, execute the code being tested, and verify the results using assertion methods. xUnit provides clear assertion helpers like Assert.Equal(), Assert.True(), Assert.Throws(), and many others. You can also use third-party assertion libraries like FluentAssertions for more expressive assertions.

    xUnit also supports fixtures for shared setup/teardown logic, collections to run tests sequentially, and traits to organize and filter tests—all without requiring base classes or static setup methods.

    Example

    csharp
    using Xunit;
    
    public class CalculatorTests
    {
        [Fact]
        public void Add_WithTwoNumbers_ReturnsCorrectSum()
        {
            // Arrange
            var calculator = new Calculator();
            int num1 = 5;
            int num2 = 3;
    
            // Act
            int result = calculator.Add(num1, num2);
    
            // Assert
            Assert.Equal(8, result);
        }
    
        [Theory]
        [InlineData(2, 3, 5)]
        [InlineData(10, 5, 15)]
        [InlineData(-1, 1, 0)]
        public void Add_WithMultipleInputs_ReturnsCorrectSum(int a, int b, int expected)
        {
            var calculator = new Calculator();
            int result = calculator.Add(a, b);
            Assert.Equal(expected, result);
        }
    }
    
    public class Calculator
    {
        public int Add(int a, int b) => a + b;
    }

    The first test uses [Fact] to test a single scenario, while the second uses [Theory] with [InlineData] to run the same test logic with different input values, reducing code duplication for testing edge cases.

    Key points to mention

    • xUnit is the modern, recommended testing framework for .NET Core and .NET 5+ projects, widely adopted in the .NET community
    • [Fact] runs a single test; [Theory] runs the same test with multiple data sets using [InlineData], [MemberData], or [ClassData]
    • It follows the AAA pattern (Arrange, Act, Assert) and provides comprehensive assertion methods for validating behavior
    • xUnit uses class-per-fixture design, creating a new test class instance per test method, eliminating shared state issues
    • You can organize tests with [Trait] attributes and run them selectively using the dotnet CLI or IDE filters

    Common follow-up questions

    • What's the difference between [Fact] and [Theory] attributes?
    • How do you test for expected exceptions in xUnit? (Answer: Use Assert.Throws<T>() with a lambda expression)
    • How does xUnit compare to NUnit or MSTest? (Answer: xUnit is simpler, uses no base classes, and has better async support—see what is a unit test for context)

    Related interview questions

    Further reading

    .NETCsharpTesting
  104. 104What is mocking?

    Beginner

    Mocking is a unit testing technique that replaces real dependencies with fake objects to isolate the code under test. Mocks allow you to control external behavior, verify interactions, and test in isolation without relying on databases, APIs, or other services. Libraries like Moq and NSubstitute make it easy to create and configure mocks in .NET.

    How it works

    When you write a unit test, your code often depends on external services—a database, an HTTP client, a payment gateway, or a logging service. Testing against real dependencies is slow, brittle, and couples your tests to implementation details. Mocking solves this by creating a substitute object that implements the same interface as the real dependency.

    The mock intercepts method calls, returns predefined values, and records what methods were called and with what arguments. This lets you:

    • Control behavior—specify exactly what the dependency returns in each test scenario
    • Isolate the unit—test only your code logic without external side effects
    • Verify interactions—assert that your code called the dependency correctly
    • Test edge cases—easily simulate errors, timeouts, or unusual responses

    Mocking works best with dependency injection, where dependencies are passed in rather than created inside the class. This makes it trivial to swap the real dependency for a mock.

    Example

    csharp
    // Interface that can be mocked
    public interface IUserRepository
    {
        Task<User> GetUserByIdAsync(int id);
    }
    
    // Service that depends on the repository
    public class UserService
    {
        private readonly IUserRepository _repository;
    
        public UserService(IUserRepository repository)
        {
            _repository = repository;
        }
    
        public async Task<string> GetUserNameAsync(int id)
        {
            var user = await _repository.GetUserByIdAsync(id);
            return user?.Name ?? "Unknown";
        }
    }
    
    // Unit test with a mock using Moq
    [TestClass]
    public class UserServiceTests
    {
        [TestMethod]
        public async Task GetUserNameAsync_WhenUserExists_ReturnsUserName()
        {
            // Arrange
            var mockRepository = new Mock<IUserRepository>();
            mockRepository
                .Setup(r => r.GetUserByIdAsync(1))
                .ReturnsAsync(new User { Id = 1, Name = "Alice" });
    
            var service = new UserService(mockRepository.Object);
    
            // Act
            var result = await service.GetUserNameAsync(1);
    
            // Assert
            Assert.AreEqual("Alice", result);
            mockRepository.Verify(r => r.GetUserByIdAsync(1), Times.Once);
        }
    }
    

    The test creates a mock repository that returns a specific user when called, injects it into the service, and then verifies both the result and that the repository was called exactly once.

    Key points to mention

    • Mocks are for behavior verification—they let you check that your code called dependencies correctly, not just that results are correct
    • Use interfaces, not concrete classes—mocking requires an interface to implement; abstract base classes work too
    • Setup and verify are core operations—Setup configures return values, Verify asserts method calls occurred as expected
    • Different from stubs—stubs only return values; mocks also track interactions and allow verification
    • Improves test speed and reliability—no network calls, database queries, or file I/O means tests run fast and consistently

    Common follow-up questions

    • What's the difference between a mock and a stub? (Stubs provide canned responses; mocks verify behavior.)
    • When should you use mocks versus integration tests? (Mocks for unit tests and fast feedback; integration tests for real dependencies and end-to-end scenarios.)
    • How do you mock static methods or sealed classes? (Use libraries like Telerik JustMock or restructure to use interfaces instead.)

    Related interview questions

    Further reading

    .NETCsharpTesting
  105. 105What is JSON serialization in .NET?

    Beginner

    JSON serialization in .NET is the process of converting C# objects into JSON format (serialization) and converting JSON back into C# objects (deserialization). The built-in System.Text.Json namespace provides fast, efficient, and configurable APIs for this conversion, making it the standard choice for modern .NET applications.

    How it works

    JSON serialization works by inspecting the properties and fields of your C# objects and converting their values into JSON text representation. The JsonSerializer class uses reflection to read object metadata and then writes data to a JSON string or stream. When deserializing, it parses JSON text and reconstructs C# objects by mapping JSON properties back to corresponding class members.

    System.Text.Json is part of the Base Class Library (BCL) and was introduced in .NET Core 3.0 as a replacement for Newtonsoft.Json (Json.NET). It prioritizes performance by using stack-based parsing and avoids unnecessary allocations. You can control serialization behavior through JsonSerializerOptions, which allows you to customize naming conventions, date formats, null handling, and more. Additionally, attributes like [JsonPropertyName] and [JsonIgnore] give fine-grained control over individual properties.

    For even better performance, System.Text.Json source generation generates serialization code at compile time rather than relying on runtime reflection, eliminating reflection overhead and improving startup time in high-performance scenarios.

    Example

    csharp
    using System.Text.Json;
    using System.Text.Json.Serialization;
    
    public class Person
    {
        [JsonPropertyName("full_name")]
        public string Name { get; set; }
    
        [JsonIgnore]
        public int InternalId { get; set; }
    
        public int Age { get; set; }
    }
    
    // Serialization
    var person = new Person { Name = "Alice", Age = 30, InternalId = 123 };
    string json = JsonSerializer.Serialize(person);
    Console.WriteLine(json); // Output: {"full_name":"Alice","Age":30}
    
    // Deserialization with options
    var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
    var deserialized = JsonSerializer.Deserialize<Person>(json, options);
    Console.WriteLine(deserialized.Name); // Output: Alice

    This example shows serialization of a Person object to JSON (with renamed and ignored properties), then deserialization back to a C# object using case-insensitive property matching.

    Key points to mention

    • System.Text.Json is the modern, built-in serializer for .NET; it is faster and more lightweight than Newtonsoft.Json
    • Use JsonSerializer.Serialize() for serialization and JsonSerializer.Deserialize<T>() for deserialization
    • JsonSerializerOptions allows global configuration: PropertyNamingPolicy, WriteIndented, DefaultIgnoreCondition, etc.
    • Attributes like [JsonPropertyName], [JsonIgnore], and [JsonConverter] provide property-level control
    • Source generation mode eliminates runtime reflection, ideal for AOT compilation and performance-critical applications

    Common follow-up questions

    • What is the difference between System.Text.Json and Newtonsoft.Json? System.Text.Json is faster, built-in, and uses stack-based parsing; Newtonsoft.Json is more flexible and widely compatible with older code.
    • How do I handle polymorphic types during deserialization? Use [JsonDerivedType] attributes or implement a custom JsonConverter to handle type discrimination.
    • Can I use System.Text.Json with async streams? Yes, JsonSerializer.DeserializeAsyncEnumerable<T>() streams JSON arrays efficiently without loading everything into memory.

    Related interview questions

    Further reading

    .NETCsharp
  106. 106What is HttpClient?

    Beginner

    HttpClient is a .NET class that sends HTTP requests to web servers and receives responses. It provides methods like GetAsync, PostAsync, and PutAsync for making REST API calls. You should reuse a single HttpClient instance or use IHttpClientFactory to avoid socket exhaustion and connection pooling issues.

    How it works

    HttpClient sits on top of the HTTP protocol and provides a high-level abstraction for sending requests and handling responses. When you call a method like GetAsync, HttpClient constructs an HTTP request message, sends it over the network to a specified URI, waits for the server to respond, and returns an HttpResponseMessage containing the status code, headers, and body content.

    HttpClient maintains an underlying socket connection pool to reuse connections across requests. Creating a new HttpClient instance for each request exhausts sockets (especially under load), causing SocketException errors. This is why best practice dictates either reusing a static HttpClient or using IHttpClientFactory in dependency injection scenarios. HttpClient is thread-safe, so sharing one instance across multiple concurrent requests is safe and efficient.

    The class also handles common HTTP operations transparently—compression, cookies via CookieContainer, automatic redirects, and authentication headers can all be configured on the DefaultRequestHeaders property or per-request.

    Example

    csharp
    // Modern approach: inject IHttpClientFactory
    public class ApiService
    {
        private readonly IHttpClientFactory _httpClientFactory;
    
        public ApiService(IHttpClientFactory httpClientFactory)
        {
            _httpClientFactory = httpClientFactory;
        }
    
        public async Task<string> GetUserAsync(int userId)
        {
            var client = _httpClientFactory.CreateClient();
            var response = await client.GetAsync($"https://api.example.com/users/{userId}");
            response.EnsureSuccessStatusCode();
            return await response.Content.ReadAsStringAsync();
        }
    }
    
    // Register in Program.cs
    builder.Services.AddHttpClient();
    

    This example uses IHttpClientFactory to create an HttpClient, sends a GET request, checks the status code, and reads the response body as a string—all asynchronously.

    Key points to mention

    • Reuse or use IHttpClientFactory: Never create a new HttpClient per request; this exhausts sockets and degrades performance.
    • Thread-safe and asynchronous: HttpClient is designed for concurrent, async operations, making it ideal for high-throughput applications.
    • Configurable defaults: Set DefaultRequestHeaders for common headers (User-Agent, Authorization) to avoid repetition.
    • Timeout management: Set Timeout and individual request timeouts to prevent hanging requests and resource leaks.
    • Response validation: Always call EnsureSuccessStatusCode() or check StatusCode to handle HTTP errors gracefully.

    Common follow-up questions

    • Why not just create a new HttpClient each time? Creating new instances exhausts the socket pool, leading to SocketException: Too many open files or connection failures under load. Socket reuse dramatically improves scalability.
    • How do you handle timeouts and retries? Set the Timeout property, or use libraries like Polly for sophisticated retry policies with exponential backoff.
    • What's the difference between HttpClient and HttpClientFactory? HttpClient is the low-level class; IHttpClientFactory is a factory pattern that manages HttpClient lifetimes, handles socket pooling, and integrates with dependency injection for cleaner, more testable code.

    Related interview questions

    Further reading

    .NETCsharp
  107. 107What is the difference between Debug and Release builds?

    Beginner

    Debug builds include debugging symbols, disable optimizations, and enable runtime checks to help you find and fix bugs during development. Release builds are optimized for performance and size, remove debugging information, and are intended for production deployment.

    How it works

    When you compile a .NET project, the build configuration determines how the compiler and runtime treat your code. In Debug mode, the compiler preserves full symbol information (variable names, line numbers, stack traces) and applies minimal optimizations so code execution matches your source code line-by-line. This makes debugging easier but produces slower, larger binaries.

    Release mode strips debugging symbols, applies aggressive optimizations (inlining, dead code elimination, loop unrolling), and removes conditional compilation blocks marked for Debug only. The result is a faster, smaller executable suitable for end users.

    Both configurations are controlled via preprocessor directives and project properties. You can conditionally compile code using #if DEBUG blocks, and the runtime behavior differs too—Debug builds may include extra validation that Release builds skip entirely.

    Example

    csharp
    using System;
    using System.Diagnostics;
    
    class Program
    {
        static void Main()
        {
            #if DEBUG
            Console.WriteLine("Debug mode: Running with full logging enabled");
            LogDetailedMetrics();
            #endif
    
            int result = CalculateSum(1000000);
            Console.WriteLine($"Result: {result}");
    
            Debug.Assert(result > 0, "Result should be positive");
        }
    
        static int CalculateSum(int n)
        {
            int sum = 0;
            for (int i = 0; i < n; i++)
            {
                sum += i;
            }
            return sum;
        }
    
        static void LogDetailedMetrics()
        {
            var sw = Stopwatch.StartNew();
            // Expensive logging operations
            sw.Stop();
            Console.WriteLine($"Metrics logged in {sw.ElapsedMilliseconds}ms");
        }
    }
    

    This example demonstrates conditional compilation: the Debug logging and assertion only execute in Debug builds; in Release, they're stripped entirely, leaving a lean executable.

    Key points to mention

    • Symbols and debugging: Debug builds embed PDB (Program Database) files containing source-level debugging information; Release strips these for smaller, faster binaries.
    • Optimizations: Release enables compiler optimizations like inlining and tail-call optimization; Debug disables them to preserve step-through execution.
    • Preprocessor directives: Use #if DEBUG blocks to conditionally include expensive logging, assertions, or validation only during development.
    • Performance difference: Release builds can be 2–10× faster depending on the workload; Debug builds prioritize debuggability over speed.
    • Deployment: Always deploy Release builds to production; Debug builds waste resources and expose internal details.
    Aspect Debug Build Release Build
    Optimization Level Minimal (preserves line-by-line execution) Aggressive (inlining, dead-code elimination)
    Debug Symbols Included (full PDB file with variable names, line numbers) Stripped (no debugging information)
    Binary Size Larger (includes metadata and symbols) Smaller (optimized, no extra info)
    Execution Speed Slower (optimizations disabled) Faster (fully optimized)
    #if DEBUG Blocks Included (compiled in) Excluded (compiled out)
    Use Case Development and local testing Production and release deployment
    What is the difference between Debug and Release builds diagram

    Common follow-up questions

    • Can I debug a Release build? Yes, if you have the matching PDB file and enable "Debug release binaries" in your debugger, but stepping through optimized code can be confusing since instructions don't map 1:1 to source lines.
    • How do I set the build configuration in Visual Studio? Use the Solution Configuration dropdown in the toolbar, or go to Project Properties → Build and set the "Configuration" to Debug or Release.
    • Should I ever run Release locally? Yes—test Release builds locally before deployment to catch performance regressions or environment-specific issues.

    Related interview questions

    What is the difference between Debug and Release builds?
    .NETCsharpPerformanceDevOps
  108. 108What is .NET LTS vs STS?

    Beginner

    .NET LTS (Long Term Support) versions receive three years of support and are released every two years in even-numbered years, while .NET STS (Standard Term Support) versions receive 18 months of support and are released in odd-numbered years. LTS versions are intended for production workloads requiring stability and long-term compatibility, whereas STS versions offer new features more frequently for teams that can upgrade regularly.

    How it works

    Microsoft maintains two release cadences for .NET to balance innovation with stability. Every even year (2022, 2024, 2026), an LTS release ships with a guarantee of three years of mainstream support plus an additional year of extended support if needed. These versions become the backbone of enterprise applications because they receive critical security and bug fixes over an extended period without major breaking changes.

    In odd years (2023, 2025), Microsoft releases STS versions that receive 18 months of support. These versions introduce new language features, performance improvements, and library enhancements, but they have a shorter support window. Teams using STS must plan to upgrade within that timeframe or risk running unsupported software.

    The rationale is straightforward: organizations with large codebases, regulatory requirements, or conservative upgrade schedules need LTS for predictability. Teams working on greenfield projects or microservices that update frequently benefit from STS innovations. For example, .NET 8 (released November 2023) is LTS, while .NET 9 (released November 2024) is STS.

    Comparison table

    Aspect LTS (Long Term Support) STS (Standard Term Support)
    Release cycle Every 2 years (even years: 2022, 2024, 2026) Every year (odd years: 2023, 2025)
    Mainstream support duration 3 years 18 months
    Extended support 1 additional year available None
    Target audience Enterprise, regulated industries, stable workloads Fast-moving teams, microservices, new projects
    Feature velocity Conservative; focused on stability High; new features every 12 months
    Examples .NET 6, .NET 8 .NET 7, .NET 9
    What is .NET LTS vs STS diagram

    Example

    csharp
    // Checking runtime version at startup
    var runtimeVersion = System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription;
    Console.WriteLine($"Running on: {runtimeVersion}");
    
    // Output example: Running on: .NET 8.0.0
    // .NET 8 is LTS (supported until November 2026)
    
    var version = Environment.Version;
    bool isLtsVersion = version.Major is 6 or 8; // .NET 6 and 8 are LTS
    Console.WriteLine($"Using LTS: {isLtsVersion}");

    This code detects the running .NET version and identifies whether it is an LTS release; in production, you might log this during startup or enforce minimum version requirements.

    Key points to mention

    • Support timeline matters in production: Running unsupported versions exposes you to unpatched security vulnerabilities and lack of official bug fixes.
    • LTS is not "slow": LTS versions still receive performance improvements and new minor features; the difference is they avoid breaking changes to the runtime and framework APIs.
    • Plan upgrades accordingly: Teams using STS must budget time for annual or biennial upgrades; LTS teams have more flexibility but should still plan a transition path before support ends.
    • Container and CI/CD matter: With containerization, upgrading base images from one LTS to the next is simpler than it was with .NET Framework, making LTS transitions more manageable.
    • Hybrid approach is common: Large organizations often run LTS for core services and STS for experimental or microservice projects.

    Common follow-up questions

    • "How do I know when support ends for my current .NET version?" — Check the official dotnet.microsoft.com release calendar or use dotnet --info to see your current version and compare against the support matrix.
    • "Should we always use LTS?" — Not necessarily; it depends on your team's ability to update. If you have CI/CD automation and lightweight microservices, STS can work well. If you're managing legacy monoliths with compliance requirements, LTS is safer.
    • "Can we stay on an older LTS forever?" — Technically yes, but not recommended; security patches eventually stop, and interoperability with new libraries becomes problematic. Plan a refresh cycle before end-of-life arrives.

    Related interview questions

    Further reading

    What is .NET LTS vs STS?
    .NETCsharp
  109. 109What is a DTO?

    Beginner

    A Data Transfer Object (DTO) is a simple object used to transfer data between different layers of an application or over network boundaries like REST APIs. It decouples your API contract from your internal domain models, allowing you to expose only the data clients need without exposing implementation details or sensitive database fields.

    How it works

    DTOs act as intermediaries between your application's internal representation and external consumers. When a client makes an API request, your controller receives the request, maps incoming data to a DTO, processes it through your business logic layer, and then maps your domain model back to a response DTO before sending it to the client. This separation provides several benefits: it protects your database schema from direct exposure, allows you to include or exclude fields on a per-endpoint basis, enables versioning of API contracts independently from your domain model, and simplifies serialization by having flat, predictable structures.

    Think of a DTO as a "data envelope" specifically designed for transport. Your database entity might have computed properties, navigation collections, or sensitive fields that should never be exposed. A DTO contains only the fields you explicitly want to send, with exactly the structure your API clients expect. This becomes especially important as your application grows—changing your database schema doesn't automatically break your API if you manage the DTO mapping carefully.

    Example

    csharp
    // Domain model (internal)
    public class User
    {
        public int Id { get; set; }
        public string Email { get; set; }
        public string PasswordHash { get; set; }
        public DateTime CreatedAt { get; set; }
        public ICollection<Order> Orders { get; set; }
    }
    
    // DTO for API response
    public class UserDto
    {
        public int Id { get; set; }
        public string Email { get; set; }
        public DateTime CreatedAt { get; set; }
    }
    
    // Controller usage
    [HttpGet("{id}")]
    public async Task<ActionResult<UserDto>> GetUser(int id)
    {
        var user = await _context.Users.FindAsync(id);
        if (user == null) return NotFound();
        
        var userDto = new UserDto
        {
            Id = user.Id,
            Email = user.Email,
            CreatedAt = user.CreatedAt
        };
        
        return Ok(userDto);
    }
    

    This example shows how the DTO excludes sensitive fields like PasswordHash and navigation properties like Orders, exposing only what the client needs and protecting your internal domain model.

    Key points to mention

    • Separation of concerns: Your API contract remains independent of your database schema, allowing each to evolve separately without breaking clients.
    • Security: DTOs prevent accidental exposure of sensitive fields like password hashes, internal timestamps, or admin flags that should never leave the server.
    • Performance: By excluding unnecessary fields, you reduce payload size and improve serialization performance, especially when dealing with large object graphs.
    • Mapping: Libraries like AutoMapper simplify DTO-to-domain model conversion, reducing boilerplate code and keeping mappings centralized and testable.
    • API versioning: Different API versions can use different DTOs, allowing you to support multiple client versions without maintaining multiple domain models.

    Common follow-up questions

    • Should I use DTOs for every entity, or only for API responses? (Answer: Use them for public API boundaries; internal layer-to-layer transfers are less critical.)
    • How do you handle DTO mapping at scale? (Answer: Use AutoMapper or similar libraries with profiles; define mappings in one place to avoid duplication.)
    • Can a DTO contain business logic? (Answer: No; DTOs should be simple data containers with only properties. Business logic belongs in services or domain models. When securing sensitive data in transit, ensure DTOs and their contents are transmitted securely.)

    Related interview questions

    Further reading

    .NETCsharpefcore
  110. 110What is the difference between authentication and authorization?

    Beginner

    Authentication verifies who the user is by validating their credentials (username, password, token, etc.), while authorization determines what that authenticated user is allowed to do based on their roles, permissions, or policies. In short: authentication answers "Who are you?" and authorization answers "What can you do?"

    How it works

    Authentication is the first step in any secure system. When a user logs in, the application verifies their identity against stored credentials (typically a hashed password in a database). Once authenticated, the user receives proof of identity—usually a session token or JWT. This proof is included in subsequent requests so the server knows who is making the request.

    Authorization happens after authentication. Once the server confirms who the user is, it checks what that user is permitted to access or modify. This is typically done through roles (Admin, User, Guest), claims (permissions attached to a user), or policies (custom business rules). For example, a user might be authenticated but not authorized to delete other users' accounts.

    In ASP.NET Core, authentication middleware runs early in the request pipeline order, extracting the user's identity from cookies, headers, or tokens. Authorization middleware runs later, using that identity to enforce access control. You can also use resource-based authorization for more granular control over specific objects or data.

    Example

    csharp
    [ApiController]
    [Route("api/[controller]")]
    public class AdminController : ControllerBase
    {
        [HttpDelete("{id}")]
        [Authorize(Roles = "Admin")]  // Authorization: only Admin role
        public IActionResult DeleteUser(int id)
        {
            // Authentication already verified in [Authorize]
            // This code only runs if user is authenticated AND in Admin role
            return Ok($"User {id} deleted");
        }
    }
    
    // In Program.cs:
    builder.Services.AddAuthentication("Bearer")
        .AddJwtBearer("Bearer", options => options.Authority = "https://auth-server");
    builder.Services.AddAuthorization();
    builder.Services.AddControllers();
    

    The [Authorize(Roles = "Admin")] attribute first confirms the user is authenticated (authentication), then verifies they belong to the Admin role (authorization). Without both checks passing, the request is rejected.

    Key points to mention

    • Sequential process: Authentication must succeed before authorization is even evaluated.
    • Different concerns: Authentication is about identity verification; authorization is about access control based on identity.
    • Flexible authorization: You can use roles, claims, policies, or custom handlers to implement authorization logic.
    • Stateless is common: Modern APIs use tokens (like JWT authentication) to carry authentication info without server-side sessions.
    • Multiple layers: Authorization can be applied at the action level, controller level, or globally via policies.

    Common follow-up questions

    • How do you implement custom authorization policies in ASP.NET Core?
    • What's the difference between role-based and claims-based authorization?
    • How do you handle authorization for protected API endpoints?
    Aspect Authentication Authorization
    Purpose Verifies the user's identity Determines user access rights
    Question it answers "Who are you?" "What can you do?"
    Timing Happens first, before authorization Happens after authentication succeeds
    Common methods Username/password, tokens (JWT, OAuth), certificates, MFA Roles, claims, policies, resource-based rules
    Failure result 401 Unauthorized (identity not verified) 403 Forbidden (authenticated but not allowed)
    ASP.NET Core attribute [Authorize] (checks if authenticated) [Authorize(Roles = "...")] or policies
    What is the difference between authentication and authorization diagram

    Related interview questions

    What is the difference between authentication and authorization?
    .NETCsharpSecurity
  111. 111How does the ASP.NET Core middleware pipeline work internally?

    Experienced

    The ASP.NET Core middleware pipeline is a chain of RequestDelegate functions composed at startup, where each middleware receives the HttpContext and a reference to the next middleware in the chain. When a request arrives, it flows through each middleware in registration order; each middleware can inspect, modify, or short-circuit the request, then call the next delegate or return early. On the response path, middleware executes in reverse order, allowing each to wrap the response before it goes back to the client.

    How does the ASP.NET Core middleware pipeline work internally diagram

    How it works

    At application startup, IApplicationBuilder compiles all registered middlewares into a single nested delegate chain. Each middleware is wrapped as a RequestDelegate—a function with signature Func<HttpContext, Task>. During registration (in Program.cs or Startup.cs), the builder stores each middleware and creates a composite function where the "next" pointer of one middleware points to the next registered middleware.

    When an HTTP request arrives, it enters the outermost middleware. That middleware can examine the HttpContext, perform setup (logging, authentication checks, etc.), then decide to call await next(httpContext) to pass control to the next middleware. If a middleware does not call next(), it short-circuits the pipeline and returns a response immediately. After the next middleware completes, control returns to the caller, allowing each middleware to perform cleanup or modify the response on the way back.

    This design—often called the Russian doll or onion model—ensures that middleware can both pre-process requests and post-process responses in a predictable, composable way. The order of app.Use*() calls determines execution order for the request phase; the response phase reverses that order.

    Example

    csharp
    var builder = WebApplication.CreateBuilder(args);
    var app = builder.Build();
    
    // Middleware 1: Authentication
    app.Use(async (context, next) =>
    {
        Console.WriteLine("→ Authentication: checking token...");
        context.Items["User"] = "Alice";
        await next(context);
        Console.WriteLine("← Authentication: response ready");
    });
    
    // Middleware 2: Logging
    app.Use(async (context, next) =>
    {
        Console.WriteLine("→ Logging: request for " + context.Request.Path);
        await next(context);
        Console.WriteLine("← Logging: response status " + context.Response.StatusCode);
    });
    
    // Endpoint
    app.MapGet("/", () => "Hello!");
    
    app.Run();

    This example shows two middlewares wrapping the request/response cycle: Authentication runs first on the way in, then Logging, then the endpoint handler; on the way back, Logging completes before Authentication, demonstrating the nested execution model.

    Key points to mention

    • RequestDelegate composition: The pipeline is built at startup as a chain of delegates, not executed dynamically per request.
    • Order matters: Registration order determines request processing order; response processing reverses that order.
    • Short-circuiting: Middleware can skip calling next() to return a response early, preventing further middlewares from running.
    • HttpContext carries state: The HttpContext.Items dictionary allows middlewares to share data along the pipeline.
    • Scoped and request-level dependencies: Each request creates a scope, so middleware can inject and use DI-resolved services specific to that request.

    Common follow-up questions

    • How do you write custom middleware as a class versus inline, and what are the pros/cons?
    • What happens if you register middleware after calling app.MapControllers() or other terminal middleware?
    • How do exception-handling middleware (like app.UseExceptionHandler()) fit into the pipeline, and why does their position matter?

    Main steps

    1. Registration: During app startup, each app.Use*() call adds a middleware to a list in IApplicationBuilder.
    2. Compilation: Before the app listens for requests, the builder composes all middlewares into a single nested RequestDelegate chain, with each middleware's next pointer referencing the subsequent middleware.
    3. Request arrival: An HTTP request enters the outermost (first-registered) middleware, which receives the HttpContext.
    4. Request phase: Each middleware executes its pre-logic (authentication, logging setup, etc.), then calls await next(context) to hand control to the next middleware in the chain.
    5. Endpoint execution: The final middleware or route handler processes the request and generates a response.
    6. Response phase: Control returns up the chain in reverse order; each middleware executes post-logic (logging completion, response header modification, etc.) as the response propagates back to the client.

    Related interview questions

    Further reading

    How does the ASP.NET Core middleware pipeline work internally?
    .NETCsharpaspnetcore
  112. 112How do you write custom middleware?

    Experienced

    Custom middleware in ASP.NET Core is written by creating a class with a constructor accepting RequestDelegate and an InvokeAsync(HttpContext) method, or by implementing IMiddleware for better dependency injection support. Register it in the pipeline using app.UseMiddleware<T>() at the appropriate point to intercept and process HTTP requests and responses.

    How it works

    ASP.NET Core middleware forms a chain where each component can inspect, modify, or short-circuit the request before passing it to the next middleware, and then inspect or modify the response on the way back. When you write custom middleware, you're inserting a handler into this pipeline.

    The convention-based approach requires a public class with a constructor taking RequestDelegate next (the next middleware in the chain) and a public InvokeAsync(HttpContext context) method. Inside InvokeAsync, you perform your logic, then call await next(context) to pass control forward. This pattern is simple but ties middleware to the request pipeline.

    The IMiddleware interface approach is cleaner for dependency injection scenarios. Implement InvokeAsync(HttpContext context, RequestDelegate next) directly on a class registered in the DI container. This allows you to inject dependencies into the middleware constructor, making it testable and loosely coupled. Use app.UseMiddleware<T>() for convention-based or app.Use(async (context, next) => {...}) for inline middleware.

    Example

    csharp
    // Convention-based custom middleware
    public class RequestTimingMiddleware
    {
        private readonly RequestDelegate _next;
        private readonly ILogger<RequestTimingMiddleware> _logger;
    
        public RequestTimingMiddleware(RequestDelegate next, ILogger<RequestTimingMiddleware> logger)
        {
            _next = next;
            _logger = logger;
        }
    
        public async Task InvokeAsync(HttpContext context)
        {
            var stopwatch = System.Diagnostics.Stopwatch.StartNew();
            
            // Before the request
            context.Items["RequestStartTime"] = DateTime.UtcNow;
            
            try
            {
                await _next(context);
            }
            finally
            {
                stopwatch.Stop();
                _logger.LogInformation($"Request to {context.Request.Path} took {stopwatch.ElapsedMilliseconds}ms");
            }
        }
    }
    
    // Registration in Program.cs
    var builder = WebApplicationBuilder.CreateBuilder(args);
    var app = builder.Build();
    
    app.UseMiddleware<RequestTimingMiddleware>();
    app.MapGet("/", () => "Hello").WithName("GetRoot").WithOpenApi();
    
    app.Run();
    

    This middleware logs the execution time of each request by wrapping the await next(context) call in a stopwatch, demonstrating how middleware can execute code before and after the rest of the pipeline.

    Key points to mention

    • Order matters: middleware registered first executes first on the way in, last on the way out (like nested function calls).
    • Use IMiddleware with DI for cleaner, testable code; use convention-based for simple, lightweight middleware.
    • Always call await next(context) unless you intentionally short-circuit (e.g., for authentication failures or global exception handling).
    • Middleware can read/modify request headers, response status, and store data in context.Items for downstream handlers.
    • Async all the way: use async Task InvokeAsync to avoid blocking the request pipeline.

    Common follow-up questions

    • What's the difference between middleware and filters? Middleware operates on all requests at the pipeline level; filters are MVC/Minimal API constructs that apply to specific controllers or endpoints.
    • How do you write middleware that runs only for specific routes? Check context.Request.Path inside InvokeAsync, or use app.UseWhen(predicate, ...) to conditionally branch the pipeline.
    • Can middleware access DI services? Yes—inject them into the constructor for convention-based or via method parameters. With IMiddleware, inject into the constructor and use in InvokeAsync.

    Related interview questions

    Further reading

    .NETCsharpaspnetcore
  113. 113What is the difference between Use, Run and Map in middleware?

    Experienced

    Use adds general-purpose middleware that can pass control to the next middleware via next(). Run adds terminal middleware that short-circuits the pipeline and never calls next(). Map branches the pipeline conditionally based on a request path prefix, allowing different middleware chains for different routes.

    How it works

    In ASP.NET Core's middleware pipeline, each component processes the request and either passes it forward or terminates it. Use() is the standard method for adding middleware—it receives a next delegate that represents the remainder of the pipeline. The middleware must decide whether to call next to continue processing or skip it to end execution early.

    Run() is a terminal middleware extension that never calls next. It always ends the pipeline and produces a final response. Multiple Run() calls can exist, but only the first one reached will execute—subsequent ones never run because the pipeline has already terminated. This makes Run() useful for catch-all handlers or default responses at the end of your middleware chain.

    Map() is a branching mechanism that evaluates a path predicate and directs matching requests to a separate middleware pipeline. If the condition matches (typically a path prefix like /admin), the mapped middleware executes. If it doesn't match, the request continues to the next middleware in the main pipeline. Importantly, mapped pipelines are independent; middleware registered inside a Map() block doesn't affect requests outside that branch.

    Example

    csharp
    var builder = WebApplication.CreateBuilder(args);
    var app = builder.Build();
    
    // General middleware - can call next or not
    app.Use(async (context, next) =>
    {
        context.Response.Headers.Add("X-Custom-Header", "From Use");
        await next();
    });
    
    // Branch for /admin path
    app.Map("/admin", admin =>
    {
        admin.Use(async (context, next) =>
        {
            context.Response.Headers.Add("X-Admin", "true");
            await next();
        });
    
        // Terminal middleware in admin branch
        admin.Run(async context =>
        {
            await context.Response.WriteAsJsonAsync(new { message = "Admin area" });
        });
    });
    
    // Terminal middleware - pipeline ends here if reached
    app.Run(async context =>
    {
        await context.Response.WriteAsJsonAsync(new { message = "Main response" });
    });
    
    app.Run();
    

    This example shows a general middleware adding a header, a branched route for /admin with its own chain, and a terminal Run() for all other requests. Requests to /admin never reach the final Run().

    Key points to mention

    • Use() is composable and non-terminal—middleware must explicitly call next to continue the pipeline or choose to short-circuit.
    • Run() is a convenience extension that creates terminal middleware; it's semantically clearer than Use() when you know the pipeline ends there.
    • Map() creates a conditional branch that doesn't interfere with the main pipeline—non-matching requests skip the mapped block entirely.
    • Order matters: middleware registered earlier always executes before later middleware, and Map() conditions are evaluated in registration order.
    • MapWhen() is a related method that branches on a custom predicate instead of just a path prefix, offering more flexible branching logic.

    Comparison table

    Method Purpose Calls next? Terminates Pipeline? Common Use Case
    Use() General-purpose middleware Optional (your choice) No (unless you skip next) Logging, authentication, request modification
    Run() Terminal middleware Never Yes (always) Default response, catch-all handler
    Map() Path-based branching Depends on mapped middleware No (main pipeline continues if no match) Route-specific logic (/api, /admin)
    What is the difference between Use, Run and Map in middleware diagram

    Common follow-up questions

    • What happens if I call next() in a Run() middleware? You can't—Run() is an extension that doesn't accept a next parameter. If you need conditional behavior, use Use() instead.
    • Can I nest Map() calls? Yes, you can map within a map to create hierarchical branching (e.g., Map("/api").Map("/v1")), though this can get complex.
    • How does MapWhen() differ from Map()? MapWhen() accepts a custom predicate function instead of a path prefix, allowing branching on headers, query parameters, or custom logic.

    Related interview questions

    Further reading

    What is the difference between Use, Run and Map in middleware?
    .NETCsharpaspnetcore
  114. 114What happens if you inject a scoped service into a singleton?

    Experienced

    When you inject a scoped service into a singleton, the scoped service becomes a "captive dependency" that lives for the entire lifetime of the application. This violates the intended scope isolation and causes shared state across requests, disposed resources, and unpredictable behavior. ASP.NET Core's scope validation in Development throws an exception to catch this mistake; in Production, you won't detect it until bugs surface.

    How it works

    Dependency injection containers manage service lifetimes strictly: singleton instances exist once for the application, transient instances are created per request, and scoped instances live for the duration of a web request (or logical scope).

    When a singleton depends on a scoped service, the container must inject that scoped service instance into the singleton at the time the singleton is constructed. Since the singleton is created only once (typically on first use), the scoped service instance is also created only once and reused forever—captured by the singleton's reference. This is the "captive dependency" problem.

    The consequences are severe: if the scoped service holds request-specific state (like a DbContext), that state persists across all requests. Multiple concurrent requests end up sharing the same instance, causing race conditions, connection pool exhaustion, and DbContext disposal errors. This is particularly dangerous because it often works in single-threaded testing but fails catastrophically under production load.

    ASP.NET Core's scope validation (enabled by default in Development) detects this anti-pattern and throws an InvalidOperationException with a message like "Cannot consume scoped service from singleton." In Production, validation is disabled for performance, so the bug silently corrupts your application.

    Example

    csharp
    // Scoped service (request-specific)
    public interface IRequestHandler
    {
        Guid RequestId { get; }
    }
    
    public class RequestHandler : IRequestHandler
    {
        public Guid RequestId { get; } = Guid.NewGuid();
    }
    
    // Singleton that incorrectly depends on scoped service
    public class BadSingletonService
    {
        private readonly IRequestHandler _handler;
    
        public BadSingletonService(IRequestHandler handler)
        {
            _handler = handler; // Captured scoped instance
        }
    
        public void ProcessRequest() => Console.WriteLine(_handler.RequestId);
    }
    
    // Registration
    var services = new ServiceCollection();
    services.AddScoped<IRequestHandler, RequestHandler>();
    services.AddSingleton<BadSingletonService>(); // ❌ Throws in Development
    
    var provider = services.BuildServiceProvider(validateScopes: true);
    

    This code throws an exception during build in Development because the container detects that a singleton is consuming a scoped dependency. The RequestHandler instance created for the singleton would be reused across all requests, violating scope isolation.

    Key points to mention

    • Captive dependency: A scoped or transient service captured by a longer-lived service, causing it to live longer than intended.
    • Scope validation: Enable validateScopes: true when building the service provider in Development to catch this pattern early.
    • IServiceScopeFactory: Use this to create child scopes within singletons and resolve scoped services safely, as shown in how to resolve scoped services in a background service.
    • Silent failures in Production: Validation is disabled for performance, so these bugs often go undetected until they cause mysterious concurrency or resource exhaustion issues.
    • Related to lifetime rules: Understand ASP.NET Core service lifetimes to design dependency graphs correctly.

    Common follow-up questions

    • How do you safely use a scoped service from a singleton? Inject IServiceScopeFactory and create a child scope when needed: using var scope = factory.CreateScope(); var handler = scope.ServiceProvider.GetRequiredService<IRequestHandler>();
    • Does this apply to transient services too? Yes—a transient service captured by a singleton becomes a captive dependency. However, transient-in-scoped is safe because each request gets fresh instances.
    • Why doesn't ASP.NET Core prevent this at compile time? Because the container is dynamic and lifetime violations are discovered at runtime during configuration validation.

    Related interview questions

    Further reading

    .NETCsharpdependency-injection
  115. 115How do you resolve scoped services in a background service?

    Experienced

    Inject IServiceScopeFactory into your background service, then create a new scope per unit of work using CreateAsyncScope() or CreateScope(). Resolve your scoped services (like DbContext) from scope.ServiceProvider, and dispose the scope when finished to ensure cleanup. This pattern prevents scoped service lifetime violations and ensures each background task operates in isolation.

    How it works

    Background services (like BackgroundService or hosted services) are singletons by nature—they live for the application's entire lifetime. Scoped services, by contrast, are meant to live for a single request or logical unit of work. Directly injecting scoped services into a singleton background service creates a captive dependency problem, where the scoped service is promoted to singleton lifetime, defeating its purpose.

    The solution is to use IServiceScopeFactory, which is a singleton that can create new scopes on demand. Each time your background service performs a discrete unit of work (processing a queue item, running a scheduled task, etc.), you create a fresh scope, resolve your scoped dependencies from that scope's ServiceProvider, execute the work, and dispose the scope. This ensures proper lifetime management and allows scoped services like DbContext to be properly cleaned up after each logical operation.

    Example

    csharp
    using Microsoft.Extensions.DependencyInjection;
    using Microsoft.Extensions.Hosting;
    
    public class DataProcessingService : BackgroundService
    {
        private readonly IServiceScopeFactory _scopeFactory;
        private readonly ILogger<DataProcessingService> _logger;
    
        public DataProcessingService(
            IServiceScopeFactory scopeFactory,
            ILogger<DataProcessingService> logger)
        {
            _scopeFactory = scopeFactory;
            _logger = logger;
        }
    
        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
        {
            while (!stoppingToken.IsCancellationRequested)
            {
                try
                {
                    // Create a new scope for this unit of work
                    using (var scope = _scopeFactory.CreateAsyncScope())
                    {
                        var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
                        var repository = scope.ServiceProvider.GetRequiredService<IOrderRepository>();
    
                        var pendingOrders = await dbContext.Orders
                            .Where(o => o.Status == OrderStatus.Pending)
                            .ToListAsync(stoppingToken);
    
                        foreach (var order in pendingOrders)
                        {
                            await repository.ProcessOrderAsync(order);
                        }
    
                        await dbContext.SaveChangesAsync(stoppingToken);
                        _logger.LogInformation("Processed {Count} orders", pendingOrders.Count);
                    }
    
                    await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
                }
                catch (Exception ex)
                {
                    _logger.LogError(ex, "Error in background service");
                    await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
                }
            }
        }
    }
    
    // Registration in Program.cs
    services.AddScoped<AppDbContext>();
    services.AddScoped<IOrderRepository, OrderRepository>();
    services.AddHostedService<DataProcessingService>();

    This example creates a new scope each time the background service processes orders, ensuring the DbContext and repository are fresh and properly disposed after each iteration.

    Key points to mention

    • Background services are singletons and cannot directly inject scoped dependencies without creating a captive dependency.
    • Use IServiceScopeFactory (a singleton) to create scopes on demand within your background work loops.
    • Always use using (var scope = _scopeFactory.CreateAsyncScope()) or using (var scope = _scopeFactory.CreateScope()) to ensure the scope is disposed and scoped services are cleaned up.
    • Resolve DbContext and other scoped services fresh from scope.ServiceProvider for each logical unit of work.
    • Wrap the scoped work in try-catch to handle exceptions gracefully without leaving broken scopes in memory.

    Common follow-up questions

    • Can I use CreateScope() instead of CreateAsyncScope()? Yes, both return an IServiceScope. CreateAsyncScope() is the async variant. Use CreateAsyncScope() if you're already in an async context to avoid unnecessary thread switches.
    • What happens if I forget to dispose the scope? Scoped services won't be cleaned up, potentially causing resource leaks (e.g., DbContext connections staying open). Always use using statements.
    • Should I create one scope for the entire background service lifetime or per iteration? Create a new scope per logical unit of work (per queue item, per scheduled interval, etc.) to ensure isolation and proper resource cleanup.

    Related interview questions

    Further reading

    .NETCsharpdependency-injection
  116. 116What are keyed services in .NET 8?

    Experienced

    Keyed services in .NET 8 allow you to register multiple implementations of the same interface under distinct keys, then inject a specific implementation at runtime using the [FromKeyedServices] attribute. This feature eliminates the need for factory patterns or Dictionary<TKey, TValue> workarounds, providing a cleaner, type-safe way to manage polymorphic dependencies.

    How it works

    Keyed services extend the dependency injection (DI) container to associate each registration with a key. When multiple implementations exist for an interface, you assign a unique key (typically a string or object) to each one during registration. At injection time, you decorate the parameter with [FromKeyedServices("keyName")] to specify which implementation the container should resolve. This approach is particularly useful when you need to select different implementations based on configuration, feature flags, or runtime conditions without creating explicit factories or service locators.

    The DI container maintains a mapping of interface → key → implementation internally. When resolving, it looks up the key to find the correct type, then constructs and returns that specific instance. This works seamlessly with dependency injection scopes (singleton, transient, scoped), maintaining all the benefits of DI while adding runtime selection capability.

    Example

    csharp
    // Define interface
    public interface IPaymentProcessor
    {
        void ProcessPayment(decimal amount);
    }
    
    // Multiple implementations
    public class StripeProcessor : IPaymentProcessor
    {
        public void ProcessPayment(decimal amount) => Console.WriteLine($"Processing ${amount} via Stripe");
    }
    
    public class PayPalProcessor : IPaymentProcessor
    {
        public void ProcessPayment(decimal amount) => Console.WriteLine($"Processing ${amount} via PayPal");
    }
    
    // Register keyed services
    var builder = WebApplication.CreateBuilder(args);
    builder.Services
        .AddKeyedSingleton("stripe")
        .AddKeyedSingleton("paypal");
    
    var app = builder.Build();
    
    // Inject specific implementation
    app.MapPost("/pay", (
        [FromKeyedServices("stripe")] IPaymentProcessor processor,
        decimal amount) =>
    {
        processor.ProcessPayment(amount);
        return "Payment processed";
    });
    
    app.Run();

    This example registers two payment processors under keys "stripe" and "paypal". The endpoint injects the Stripe processor by requesting the "stripe" key, while other endpoints could request "paypal" independently.

    Key points to mention

    • Registration methods: Use AddKeyedSingleton<TInterface, TImplementation>(key), AddKeyedScoped, or AddKeyedTransient to register with a key.
    • Injection attribute: The [FromKeyedServices] attribute works in controllers, minimal APIs, and constructor injection to specify which keyed service to resolve.
    • Replaces factories: Eliminates boilerplate factory classes or service locators like IServiceProvider.GetKeyedService<T>(key).
    • Type-safe and discoverable: Compile-time safety with clear dependencies; IDE tools can track which keys are registered and used.
    • Works with dependency graphs: A keyed service can itself have dependencies that the container resolves automatically.

    Common follow-up questions

    • How do you handle keyed services in background services or hosted services?
    • Can you combine keyed services with factory patterns for more complex scenarios?
    • What happens if you request a key that was never registered—does it throw an exception or return null?

    Related interview questions

    .NETCsharpdependency-injection
  117. 117What is the difference between IOptions, IOptionsSnapshot and IOptionsMonitor?

    Experienced

    IOptions, IOptionsSnapshot, and IOptionsMonitor are three ways to inject configuration into ASP.NET Core services, each with different lifetimes and behaviors. IOptions is a singleton that reads configuration once at startup; IOptionsSnapshot is scoped and recomputes per request; IOptionsMonitor is a singleton that watches for configuration changes and notifies subscribers in real time.

    How it works

    These interfaces provide access to strongly-typed configuration objects bound from your appsettings.json or other configuration sources. The key difference lies in their service lifetimes and when they read configuration values.

    IOptions is registered as a singleton and caches the configuration object for the application's lifetime. It reads configuration once during dependency injection setup. This is the most performant but cannot reflect runtime changes.

    IOptionsSnapshot is scoped to each request and reads the current configuration at the start of each scope. It allows you to see configuration changes between requests without restarting the application, but the values remain constant within a single request.

    IOptionsMonitor is a singleton that actively monitors configuration changes. It holds a reference to the latest configuration value and provides an OnChange event for subscribers to react to configuration updates immediately. This is useful for settings that change frequently and require immediate application without restarting.

    Example

    csharp
    public class AppSettings
    {
        public string ApiKey { get; set; }
        public int MaxRetries { get; set; }
    }
    
    public class ConfigurationService
    {
        private readonly IOptions<AppSettings> _options;
        private readonly IOptionsSnapshot<AppSettings> _snapshot;
        private readonly IOptionsMonitor<AppSettings> _monitor;
    
        public ConfigurationService(
            IOptions<AppSettings> options,
            IOptionsSnapshot<AppSettings> snapshot,
            IOptionsMonitor<AppSettings> monitor)
        {
            _options = options;
            _snapshot = snapshot;
            _monitor = monitor;
    
            // Subscribe to configuration changes
            monitor.OnChange((newSettings, name) =>
            {
                Console.WriteLine($"Configuration changed: MaxRetries = {newSettings.MaxRetries}");
            });
        }
    
        public void DemonstrateUsage()
        {
            // IOptions: reads once at startup, cached
            var apiKey1 = _options.Value.ApiKey;
    
            // IOptionsSnapshot: reads current configuration
            var retries = _snapshot.Value.MaxRetries;
    
            // IOptionsMonitor: always reflects latest value
            var currentKey = _monitor.CurrentValue.ApiKey;
        }
    }
    
    // Startup configuration
    builder.Services.Configure<AppSettings>(
        builder.Configuration.GetSection("Settings"));
    

    This example demonstrates injecting all three interfaces into a service. IOptions caches the value once, IOptionsSnapshot reads fresh values per request, and IOptionsMonitor actively watches for changes and triggers callbacks when configuration updates.

    Comparison table

    Interface Lifetime Reads Configuration Reflects Changes Best Use Case
    IOptions<T> Singleton Once at startup No (cached) Static settings that never change
    IOptionsSnapshot<T> Scoped Per request Between requests only Settings that change rarely, per-request consistency required
    IOptionsMonitor<T> Singleton On demand (latest) Yes (immediate) Dynamic settings requiring real-time updates and notifications
    What is the difference between IOptions, IOptionsSnapshot and IOptionsMonitor diagram

    Key points to mention

    • Performance vs. flexibility: IOptions is fastest because it caches; IOptionsMonitor is more flexible but slightly slower due to change detection.
    • Request consistency: IOptionsSnapshot guarantees the same configuration value throughout a single request, avoiding inconsistencies if settings change mid-request.
    • OnChange events: Only IOptionsMonitor provides the OnChange callback, allowing services to react immediately to configuration updates without restarting.
    • Named options: All three support named configuration instances using IOptionsMonitor.Get(name) for multiple configuration sets.
    • File watchers: IOptionsSnapshot and IOptionsMonitor require file watchers enabled in your configuration provider to detect changes at runtime.

    Common follow-up questions

    • How do you reload configuration without restarting the application, and what constraints apply?
    • Why does IOptionsSnapshot use the scoped lifetime instead of transient?
    • Can you use IOptionsMonitor with database-backed configuration, and what are the performance implications?

    Related interview questions

    Further reading

    What is the difference between IOptions, IOptionsSnapshot and IOptionsMonitor?
    .NETCsharpdependency-injection
  118. 118How do you validate options at startup?

    Experienced

    Direct answer: Options validation at startup is accomplished using the Options pattern with ValidateDataAnnotations() and ValidateOnStart(), or by implementing IValidateOptions<T>. This ensures configuration errors are caught during application initialization rather than at runtime when a feature first accesses the options, preventing silent failures in production.

    How it works

    The Options pattern in .NET provides a mechanism to validate configuration before the application runs. By default, options are validated lazily—only when first accessed. This can mask configuration problems that don't surface until a specific code path executes. Startup validation changes this behavior by triggering validation during the host build phase, causing the application to fail immediately if the configuration is invalid.

    There are two primary approaches. The first uses fluent configuration with ValidateDataAnnotations(), which validates properties decorated with data annotation attributes (like [Required], [Range]). The second is ValidateOnStart(), which registers a hosted service that validates options during startup. For complex custom validation logic, you implement IValidateOptions<T> directly, giving you full control over validation rules and error messages.

    Example

    csharp
    // AppSettings class with validation attributes
    public class DatabaseOptions
    {
        [Required]
        public string ConnectionString { get; set; }
    
        [Range(1, 100)]
        public int MaxConnections { get; set; } = 10;
    
        [EmailAddress]
        public string AdminEmail { get; set; }
    }
    
    // Custom validator implementing IValidateOptions
    public class DatabaseOptionsValidator : IValidateOptions<DatabaseOptions>
    {
        public ValidateOptionsResult Validate(string name, DatabaseOptions options)
        {
            if (string.IsNullOrEmpty(options.ConnectionString))
                return ValidateOptionsResult.Fail("ConnectionString is required");
    
            if (!options.ConnectionString.Contains("Server="))
                return ValidateOptionsResult.Fail("ConnectionString must contain Server= parameter");
    
            if (options.MaxConnections < 1 || options.MaxConnections > 100)
                return ValidateOptionsResult.Fail("MaxConnections must be between 1 and 100");
    
            return ValidateOptionsResult.Success;
        }
    }
    
    // Startup configuration
    var builder = WebApplication.CreateBuilder(args);
    
    // Approach 1: Data annotations + ValidateOnStart
    builder.Services
        .AddOptions<DatabaseOptions>()
        .Bind(builder.Configuration.GetSection("Database"))
        .ValidateDataAnnotations()
        .ValidateOnStart();
    
    // Approach 2: Custom validator
    builder.Services.AddSingleton<IValidateOptions<DatabaseOptions>, 
        DatabaseOptionsValidator>();
    builder.Services
        .AddOptions<DatabaseOptions>()
        .Bind(builder.Configuration.GetSection("Database"))
        .ValidateOnStart();
    
    var app = builder.Build();
    // If validation fails here, the app crashes with clear error messages
    

    This example demonstrates both data annotation validation and custom validation logic. The ValidateOnStart() method ensures both approaches run during application startup, causing the app to fail fast with descriptive errors if the database configuration is invalid.

    Key points to mention

    • Fail-fast principle: Validation at startup catches configuration errors immediately, preventing silent failures that might only appear hours into production operation.
    • ValidateDataAnnotations(): Automatically validates properties with standard .NET data annotations; simple and declarative for common validation scenarios.
    • IValidateOptions<T>: Provides full control for complex validation logic involving multiple properties, external dependencies, or business rules that can't be expressed with attributes.
    • ValidateOnStart(): Registers a hosted service that forces options validation during host build; without it, validation happens only on first access.
    • Error clarity: Startup validation errors include details about which option failed and why, making troubleshooting faster than runtime errors buried in logs.

    Common follow-up questions

    • What happens if validation fails? The application throws an OptionsValidationException and exits immediately during builder.Build(), before any middleware or services run. This is intentional—bad configuration should prevent startup.
    • Can you validate options after dependency injection is available? Yes, implement IValidateOptions<T> and inject dependencies through its constructor; you can call external services, databases, or APIs as part of validation logic.
    • How does this differ from configuration validation in configuration providers? Configuration providers validate raw config sources; options validation validates the strongly-typed, bound result, catching type mismatches and business rule violations after binding.

    Related interview questions

    .NETCsharp
  119. 119How does configuration precedence work in ASP.NET Core?

    Experienced

    ASP.NET Core uses a layered configuration system where configuration providers are registered in a specific order, and later providers override earlier ones. This means command-line arguments take highest priority, followed by environment variables, then file-based sources like appsettings.json. Understanding this precedence is critical for managing settings across development, staging, and production environments.

    How does configuration precedence work in ASP.NET Core diagram

    How it works

    ASP.NET Core's configuration system builds a composite configuration by chaining multiple providers together. Each provider reads configuration from a different source—files, environment variables, command-line arguments, or even Azure Key Vault. When you request a configuration value, the system traverses the providers in reverse order of registration: the last registered provider is checked first, and if a value is found, it's returned immediately. This "last-one-wins" approach means you can layer configurations, with sensitive or environment-specific values overriding defaults.

    The default configuration pipeline established by CreateDefaultBuilder() or AddJsonFile() follows this order:

    1. Base configuration: Load appsettings.json first as the baseline configuration.
    2. Environment-specific overrides: Load appsettings.{Environment}.json (e.g., appsettings.Development.json) to override base settings for specific environments.
    3. User secrets injection: In Development only, load user secrets from the secrets store to avoid committing sensitive data.
    4. Environment variables: Read system or application environment variables to allow container/cloud deployments to inject configuration.
    5. Command-line arguments: Parse arguments passed directly to the application, giving operators final control at runtime.

    Example

    csharp
    // Program.cs - .NET 8 minimal hosting
    var builder = WebApplication.CreateBuilder(args);
    
    // Default builder already registers: appsettings.json, 
    // appsettings.{Environment}.json, user secrets, env vars, command-line args
    var config = builder.Configuration;
    
    // Accessing a value - checks all providers in precedence order
    string connectionString = config["ConnectionStrings:DefaultConnection"];
    // If env var "ConnectionStrings__DefaultConnection" exists, it wins
    // Otherwise falls back to appsettings.json value
    
    builder.Services.AddSingleton(config);
    builder.Services.Configure<AppSettings>(config.GetSection("AppSettings"));
    
    var app = builder.Build();
    app.Run();
    
    // Real-world scenario: Running in Docker
    // docker run -e ConnectionStrings__DefaultConnection="Server=prod-db;..." myapp
    // This environment variable overrides the appsettings.json connection string
    

    This example shows how environment variables override file-based configuration—a common pattern when deploying containers or to cloud platforms where secrets are injected as environment variables rather than stored in files.

    Key points to mention

    • Last provider wins: Configuration providers are checked in reverse registration order. The most recently added provider takes precedence, allowing environment-specific or runtime overrides.
    • Environment-specific files: appsettings.{Environment}.json files are automatically loaded based on the ASPNETCORE_ENVIRONMENT variable, making multi-environment management seamless.
    • User secrets for Development: User secrets are only loaded in Development to prevent accidental commits of sensitive data while keeping them available locally.
    • Double underscore for nested values: Environment variables use __ (double underscore) to represent nested configuration keys (e.g., ConnectionStrings__DefaultConnection maps to ConnectionStrings:DefaultConnection).
    • Immutability after build: Once builder.Build() is called, the configuration is frozen. Dynamic reloading requires special handling with IOptionsMonitor<T> or file watchers.

    Common follow-up questions

    • How do you reload configuration at runtime without restarting? Use IOptionsMonitor<T> instead of IOptions<T>, which polls the configuration source for changes. For appsettings.json, ensure reloadOnChange: true is set in AddJsonFile().
    • Can you customize the configuration precedence order? Yes. After CreateDefaultBuilder(), you can call builder.Configuration.AddJsonFile(), AddEnvironmentVariables(), or custom providers in any order to override the defaults.
    • What happens if a configuration key doesn't exist? The system returns null. Use config["key"] ?? "defaultValue" or binding to a class with default properties to handle missing values gracefully.

    Related interview questions

    Further reading

    How does configuration precedence work in ASP.NET Core?
    .NETCsharpaspnetcore
  120. 120How do you store secrets safely in .NET apps?

    Experienced

    Use user secrets during development and cloud secret stores (Azure Key Vault, AWS Secrets Manager) in production, loaded via configuration providers. Never commit secrets to appsettings.json or version control, and always rotate secrets regularly.

    How it works

    .NET provides a layered secret management approach. During development, the user secrets manager stores sensitive data outside your project directory—typically in %APPDATA%\Microsoft\UserSecrets on Windows or ~/.microsoft/usersecrets on Linux/macOS. This prevents accidental commits while allowing local configuration.

    In production, secrets are stored in dedicated services like Azure Key Vault or AWS Secrets Manager, which provide encryption at rest, audit logging, access control, and rotation capabilities. .NET's configuration system loads these via providers that fetch and cache secrets in memory, integrating seamlessly with IConfiguration.

    The flow is: application requests configuration → configuration provider checks sources in order (environment variables, Key Vault, appsettings) → secret is returned and used. This follows the configuration precedence model, where environment-specific sources override defaults.

    Example

    csharp
    // Program.cs (.NET 8)
    var builder = WebApplication.CreateBuilder(args);
    
    // Development: user secrets
    if (builder.Environment.IsDevelopment())
    {
        builder.Configuration.AddUserSecrets<Program>();
    }
    
    // Production: Azure Key Vault
    var keyVaultUrl = builder.Configuration["KeyVault:Url"];
    if (!string.IsNullOrEmpty(keyVaultUrl))
    {
        var credential = new DefaultAzureCredential();
        builder.Configuration.AddAzureKeyVault(
            new Uri(keyVaultUrl),
            credential);
    }
    
    var dbPassword = builder.Configuration["Database:Password"];
    // Use dbPassword safely—never log or expose it
    builder.Services.AddSingleton(new DbConfig { Password = dbPassword });
    
    var app = builder.Build();
    app.Run();

    This example loads user secrets in development and Azure Key Vault in production, ensuring secrets are never in source code and are centrally managed.

    Key points to mention

    • User Secrets Manager: Initialize with dotnet user-secrets init, set values with dotnet user-secrets set "Key" "value"—stored outside the project directory.
    • Cloud Providers: Azure Key Vault, AWS Secrets Manager, HashiCorp Vault offer encryption, audit trails, rotation, and access policies; integrate via AddAzureKeyVault() or similar.
    • Never in appsettings: Even in appsettings.Production.json, secrets should not appear; use environment-based configuration providers instead.
    • Environment Variables: Use for simple deployments; configuration system reads ASPNETCORE_ prefixed variables automatically.
    • Encryption & Access Control: Cloud stores encrypt secrets at rest and in transit; restrict access via IAM/RBAC and enable audit logging for compliance.

    Common follow-up questions

    • How do you rotate secrets without downtime? — Most cloud providers support versioning; update the secret, set an expiration on the old version, and applications automatically fetch the new one on next configuration refresh or restart.
    • What if Key Vault is unavailable? — Implement circuit breaker patterns, cache secrets in memory with TTL, or fall back to environment variables; consider the security/availability trade-off.
    • How do you handle local development without Azure access? — Use user secrets locally; in CI/CD, inject secrets as environment variables or use a mock secret store for testing.

    Related interview questions

    Further reading

    .NETCsharpSecurity
  121. 121What is the difference between a filter and middleware?

    Experienced

    Middleware runs globally for every HTTP request in the ASP.NET Core pipeline and has no knowledge of MVC concepts, while filters are MVC-specific components that execute within the action invocation pipeline and have full access to action context, model state, and action results. Middleware is infrastructure-level, whereas filters are application-logic-level.

    How it works

    Middleware operates at the lowest level of the request pipeline. It forms a chain where each middleware component can inspect the request, modify it, pass it to the next component, and then inspect the response on the way back. Middleware runs regardless of whether the request reaches an MVC controller or not—it applies to static files, API endpoints, health checks, and everything in between.

    Filters, by contrast, are part of the MVC pipeline specifically. They execute only when a request reaches an MVC controller action. Filters have access to rich MVC-specific information like ActionContext, ModelStateDictionary, ActionResult, and the controller instance itself. This makes filters ideal for cross-cutting concerns that need awareness of MVC semantics, such as model validation, authorization based on user roles, or result transformation.

    The request pipeline order shows middleware wrapping everything, with filters nested inside the MVC layer. If you need to intercept requests before they reach routing or handle non-MVC traffic, use middleware. If you need to intercept at the action level with MVC context, use filters.

    Example

    csharp
    // Middleware: runs for ALL requests
    public class LoggingMiddleware
    {
        private readonly RequestDelegate _next;
    
        public LoggingMiddleware(RequestDelegate next)
        {
            _next = next;
        }
    
        public async Task InvokeAsync(HttpContext context)
        {
            Console.WriteLine($"[Middleware] Request: {context.Request.Path}");
            await _next(context);
            Console.WriteLine($"[Middleware] Response: {context.Response.StatusCode}");
        }
    }
    
    // Filter: runs only for MVC action invocations
    public class ValidateModelAttribute : ActionFilterAttribute
    {
        public override void OnActionExecuting(ActionExecutingContext context)
        {
            if (!context.ModelState.IsValid)
            {
                Console.WriteLine($"[Filter] Model validation failed for action {context.ActionDescriptor.DisplayName}");
                context.Result = new BadRequestObjectResult(context.ModelState);
            }
        }
    }
    
    // Usage in Program.cs
    app.UseMiddleware();
    
    // Usage on controller action
    [HttpPost]
    [ValidateModel]
    public IActionResult Create([FromBody] UserDto dto) => Ok();
    

    The middleware logs all HTTP requests and responses globally, while the filter validates model state only when an MVC action is about to execute, with access to the actual action context.

    Aspect Middleware Filters
    Scope Global; runs for every request MVC-specific; runs only for controller actions
    Access to Context Only HttpContext ActionContext, ModelState, route data, action descriptor
    Execution Timing Before routing, before MVC layer During action invocation pipeline
    Use Cases Logging, authentication, CORS, compression, static files Authorization, model validation, result transformation, exception handling
    Configuration app.UseMiddleware<T>() in Program.cs Attribute on controller/action or global filter
    Performance Lower overhead for infrastructure concerns Only invoked when MVC action matches
    What is the difference between a filter and middleware diagram

    Key points to mention

    • Middleware has no knowledge of MVC concepts; it sees only raw HTTP requests and responses.
    • Filters are MVC-aware and can access model state, action parameters, and action results—use them for business logic validation.
    • Middleware executes before routing; filters execute after routing and model binding are complete.
    • Use middleware for cross-cutting infrastructure concerns; use filters for action-level concerns.
    • Both can be chained, and both support async operations, but they serve different layers of the application.

    Common follow-up questions

    • What is middleware and how does it form a pipeline?
    • Can a filter access the request body, and should it? What are the performance implications?
    • When would you use a global filter versus a middleware component for authentication?

    Related interview questions

    Further reading

    What is the difference between a filter and middleware?
    .NETCsharpaspnetcore
  122. 122What are the types of filters in ASP.NET Core?

    Experienced

    ASP.NET Core supports five main filter types that execute at different stages of the request pipeline: Authorization, Resource, Action, Exception, and Result filters. Each filter type serves a distinct purpose and runs in a predictable order. Additionally, Minimal APIs introduce Endpoint filters for similar cross-cutting concerns without controllers.

    How it works

    Filters in ASP.NET Core form a pipeline that intercepts request processing. The execution order is fixed and predictable:

    1. Authorization Filters – Run first; determine if the user is authorized to access the resource. Execution stops here if authorization fails.
    2. Resource Filters – Run after authorization; useful for short-circuiting expensive operations or caching responses before model binding.
    3. Action Filters – Run before and after action method execution; modify parameters before execution or the result after execution.
    4. Exception Filters – Run only if an unhandled exception occurs; handle and transform exceptions into responses.
    5. Result Filters – Run after the action returns a result; modify the response or response format.

    For Minimal APIs, endpoint filters provide similar functionality with a different API surface. Filters can be applied globally, per-controller, or per-action/endpoint. They implement interfaces like IAuthorizationFilter, IActionFilter, or IExceptionFilter.

    Example

    csharp
    // Action Filter example – logs execution time
    public class ExecutionTimeFilter : IActionFilter
    {
        private Stopwatch _stopwatch;
    
        public void OnActionExecuting(ActionExecutingContext context)
        {
            _stopwatch = Stopwatch.StartNew();
        }
    
        public void OnActionExecuted(ActionExecutedContext context)
        {
            _stopwatch.Stop();
            Console.WriteLine($"Action executed in {_stopwatch.ElapsedMilliseconds}ms");
        }
    }
    
    // Exception Filter example – handles specific exceptions
    public class ApiExceptionFilter : IExceptionFilter
    {
        public void OnException(ExceptionContext context)
        {
            if (context.Exception is ArgumentNullException)
            {
                context.Result = new BadRequestObjectResult(
                    new { error = "Invalid argument provided" });
                context.ExceptionHandled = true;
            }
        }
    }
    
    // Register globally in Program.cs
    builder.Services.AddControllers(options =>
    {
        options.Filters.Add<ExecutionTimeFilter>();
        options.Filters.Add<ApiExceptionFilter>();
    });

    This example shows an action filter that measures execution time and an exception filter that converts ArgumentNullException into a friendly 400 Bad Request response.

    Key points to mention

    • Filters execute in a strict, predictable order: Authorization → Resource → Action → Exception → Result.
    • Filters can be applied at three scopes: globally (all requests), per-controller, or per-action—more specific scopes override broader ones.
    • Authorization and Resource filters run before model binding; Action and Result filters run after.
    • Short-circuiting is possible in early filters (Authorization, Resource) by setting a result; subsequent filters won't execute.
    • Minimal APIs use endpoint filters (registered via AddEndpointFilter()) rather than attribute-based filters, providing a functional approach.

    Common follow-up questions

    • What's the difference between an Action Filter and a Resource Filter? – Resource filters run before model binding and can short-circuit the pipeline earlier; use them for caching or early validation. Action filters run after model binding and are ideal for logging or modifying action parameters.
    • Can you use async filters? – Yes; implement IAsyncActionFilter, IAsyncAuthorizationFilter, etc., which provide async methods like OnActionExecutionAsync().
    • How do you control filter execution order when multiple filters are registered? – Use the Order property; lower values run first. Scopes (action-level, controller-level, global) also matter.

    Related interview questions

    Further reading

    .NETCsharpaspnetcore
  123. 123How do you implement global exception handling?

    Experienced

    Global exception handling in .NET is implemented using the UseExceptionHandler middleware combined with IExceptionHandler implementations (in .NET 8+), or by creating custom middleware that catches all unhandled exceptions, logs them, and returns standardized ProblemDetails responses to clients. This approach ensures consistent error responses, hides sensitive internal details, and centralizes logging without requiring try-catch blocks throughout your application.

    How it works

    Global exception handling works by intercepting all unhandled exceptions at the middleware level before they reach the client. In .NET 8+, the recommended approach uses the built-in UseExceptionHandler middleware paired with IExceptionHandler implementations. When an exception occurs, the middleware catches it, logs the error, and converts it into a standardized ProblemDetails response (RFC 7807 format). Custom exception handlers can map specific exception types to appropriate HTTP status codes and error messages. Older approaches involve writing custom middleware that wraps the request pipeline. The key benefit is that all exceptions follow one path, making it easier to enforce security (no stack traces to clients), consistency (uniform error format), and observability (centralized logging).

    Example

    csharp
    // Program.cs - .NET 8+ approach
    var builder = WebApplication.CreateBuilder(args);
    
    builder.Services.AddExceptionHandler<GlobalExceptionHandler>()
        .AddProblemDetails();
    
    var app = builder.Build();
    app.UseExceptionHandler();
    app.MapControllers();
    app.Run();
    
    // GlobalExceptionHandler.cs
    public class GlobalExceptionHandler : IExceptionHandler
    {
        private readonly ILogger<GlobalExceptionHandler> _logger;
    
        public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
        {
            _logger = logger;
        }
    
        public async ValueTask<bool> TryHandleAsync(
            HttpContext httpContext,
            Exception exception,
            CancellationToken cancellationToken)
        {
            _logger.LogError(exception, "An unhandled exception occurred");
    
            var response = new ProblemDetails
            {
                Status = StatusCodes.Status500InternalServerError,
                Title = "An error occurred",
                Detail = exception.Message
            };
    
            if (exception is ArgumentException argEx)
            {
                response.Status = StatusCodes.Status400BadRequest;
                response.Title = "Validation error";
            }
            else if (exception is KeyNotFoundException)
            {
                response.Status = StatusCodes.Status404NotFound;
                response.Title = "Resource not found";
            }
    
            httpContext.Response.StatusCode = response.Status.Value;
            await httpContext.Response.WriteAsJsonAsync(response, cancellationToken);
    
            return true;
        }
    }

    This example registers a custom exception handler that catches all exceptions, maps specific exception types to appropriate HTTP status codes, logs the error, and returns a standardized ProblemDetails response to the client.

    Key points to mention

    • Middleware ordering matters – UseExceptionHandler must be placed early in the pipeline (typically after routing but before other middleware) to catch exceptions from subsequent middleware. See more about request pipeline order.
    • Use ProblemDetails (RFC 7807) – standardizes error responses across APIs with fields like status, title, detail, and extensions for structured error information.
    • Never expose internal details to clients – log full stack traces server-side but return generic messages to clients in production to avoid leaking sensitive information.
    • Custom exception handlers enable type-specific mapping – different exception types can map to different HTTP status codes and error messages for better API semantics.
    • IExceptionHandler is testable – can be unit tested independently by passing a mock HttpContext and verifying ProblemDetails responses.

    Common follow-up questions

    • How do you differentiate error responses by exception type, and should you log all exceptions the same way? (Answer: Map known exceptions to specific status codes; log errors as errors, validation failures as warnings.)
    • What's the difference between exception handling middleware and custom middleware? (Answer: UseExceptionHandler is purpose-built for exceptions; custom middleware is more flexible but requires manual exception handling.)
    • How do you handle async exceptions in middleware, and can they always be caught? (Answer: Async exceptions in tasks may not be caught by middleware; use try-catch in async endpoints and ensure the handler uses async/await properly.)

    Related interview questions

    Further reading

    .NETCsharpaspnetcore
  124. 124What is ProblemDetails?

    Experienced

    ProblemDetails is the RFC 9457 standard format for representing HTTP API errors in a consistent, machine-readable way. In ASP.NET Core, it defines a structured response with fields like type, title, status, detail, and instance, plus custom extensions. The AddProblemDetails() middleware automatically converts exceptions into standardized error responses across your entire application.

    How it works

    ProblemDetails provides a unified contract for error responses that clients can reliably parse and handle. When you enable it in your middleware pipeline via AddProblemDetails() and UseProblemDetails(), any unhandled exception or explicit problem response is serialized into the RFC 9457 format. This replaces ad-hoc error responses with a predictable structure.

    The standard fields are:

    • type – URI identifying the problem category (e.g., https://api.example.com/errors/validation-failed)
    • title – Short, human-readable summary (e.g., "Validation Failed")
    • status – HTTP status code (400, 404, 500, etc.)
    • detail – Detailed explanation of the specific occurrence
    • instance – URI uniquely identifying this problem instance
    • Custom extensions – Additional fields specific to your domain

    You can customize behavior by registering a ProblemDetailsService that maps specific exceptions to appropriate HTTP responses and enriches the problem details with domain-specific data.

    Example

    csharp
    // Program.cs
    var builder = WebApplication.CreateBuilder(args);
    builder.Services.AddProblemDetails(options =>
    {
        options.CustomizeProblemDetails = context =>
        {
            context.ProblemDetails.Instance = $"{context.HttpContext.Request.Method} {context.HttpContext.Request.Path}";
            
            if (context.Exception is ArgumentException argEx)
            {
                context.ProblemDetails.Title = "Invalid Argument";
                context.ProblemDetails.Status = StatusCodes.Status400BadRequest;
                context.ProblemDetails.Detail = argEx.Message;
                context.HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
            }
        };
    });
    
    var app = builder.Build();
    app.UseProblemDetails();
    app.MapGet("/users/{id}", (int id) =>
    {
        if (id <= 0) throw new ArgumentException("User ID must be positive");
        return Results.Ok(new { id, name = "John" });
    });
    app.Run();
    
    // Response for GET /users/-1:
    // HTTP 400
    // {
    //   "type": "https://tools.ietf.org/html/rfc9457",
    //   "title": "Invalid Argument",
    //   "status": 400,
    //   "detail": "User ID must be positive",
    //   "instance": "GET /users/-1"
    // }

    This example shows how AddProblemDetails captures an ArgumentException, maps it to a 400 status, and returns a standardized error response that clients can consistently parse.

    Key points to mention

    • ProblemDetails is an RFC 9457 standard, ensuring interoperability across tools and platforms; it replaces ad-hoc JSON error formats.
    • Use CustomizeProblemDetails to map domain exceptions to appropriate HTTP status codes and enrich responses with business logic details.
    • Works seamlessly with controllers and minimal APIs, capturing both handled and unhandled exceptions.
    • You can extend the standard schema with custom properties (e.g., errors, traceId) by setting them on context.ProblemDetails.Extensions.
    • Improves REST API design by providing a predictable error contract, reducing client-side error-handling complexity.

    Common follow-up questions

    • How do you return a ProblemDetails response explicitly in an endpoint? – Use Results.Problem() or Results.BadRequest(new ProblemDetails { ... }) to explicitly return a problem response without throwing an exception.
    • Can you customize ProblemDetails per exception type? – Yes, check context.Exception's type in CustomizeProblemDetails and conditionally set title, status, and detail fields based on the exception.
    • What HTTP status codes should map to specific error scenarios? – Use 400 for validation errors, 404 for not found, 409 for conflicts, 500 for server errors, and 422 for unprocessable entities.

    Related interview questions

    Further reading

    .NETCsharp
  125. 125How do you version APIs in ASP.NET Core?

    Experienced

    API versioning in ASP.NET Core is implemented using the Asp.Versioning NuGet package, which supports multiple versioning strategies including URL segment, query string, header, and media type versioning. You configure versioning in the DI container, apply it to controllers or actions, and optionally report supported and deprecated versions in response headers to guide clients toward current API versions.

    How it works

    The Asp.Versioning package (formerly Asp.Versioning.Mvc.ApiExplorer) integrates with ASP.NET Core's routing and controller selection to map incoming requests to the correct API version. When you register versioning in the service container, you define how versions are communicated—typically via URL path (e.g., /api/v1/products), query string (?api-version=1.0), HTTP headers (api-version: 1.0), or Accept header media types (application/vnd.company.v1+json).

    Each controller or action method is decorated with [ApiVersion] attributes specifying which versions it supports. The middleware then matches the incoming request's version to the appropriate handler. You can also mark versions as deprecated using [ApiVersion(..., Deprecated = true)], which adds deprecation warnings to response headers, prompting clients to upgrade.

    For backward compatibility, you often maintain multiple controller versions side-by-side (ProductsV1Controller, ProductsV2Controller) or use branching logic within a single controller. This approach ensures old clients continue working while new clients benefit from improved endpoints.

    Example

    csharp
    // Program.cs
    var builder = WebApplication.CreateBuilder(args);
    
    builder.Services.AddApiVersioning(options =>
    {
        options.DefaultApiVersion = new ApiVersion(1, 0);
        options.AssumeDefaultVersionWhenUnspecified = true;
        options.ReportApiVersions = true;
        options.ApiVersionReader = ApiVersionReader.Combine(
            new UrlSegmentApiVersionReader(),
            new QueryStringApiVersionReader("api-version"),
            new HeaderApiVersionReader("api-version")
        );
    })
    .AddApiExplorer(options =>
    {
        options.GroupNameFormat = "'v'VVV";
        options.SubstituteApiVersionInUrl = true;
    });
    
    builder.Services.AddControllers();
    var app = builder.Build();
    app.MapControllers();
    app.Run();
    
    // Controllers
    [ApiController]
    [Route("api/v{version:apiVersion}/[controller]")]
    public class ProductsController : ControllerBase
    {
        [HttpGet]
        [ApiVersion("1.0")]
        public IActionResult GetV1()
            => Ok(new { message = "Products v1 - basic data" });
    
        [HttpGet]
        [ApiVersion("2.0")]
        public IActionResult GetV2()
            => Ok(new { message = "Products v2 - enhanced data", enhanced = true });
    
        [HttpPost]
        [ApiVersion("1.0", Deprecated = true)]
        public IActionResult CreateV1([FromBody] CreateProductRequest req)
            => Ok("Use v2 for better validation");
    
        [HttpPost]
        [ApiVersion("2.0")]
        public IActionResult CreateV2([FromBody] CreateProductRequest req)
            => CreatedAtAction(nameof(GetV2), new { id = 1 }, req);
    }
    

    This configuration accepts versions via URL segment (/api/v1/...), query string, or header, applies version constraints to controller actions, marks v1 POST as deprecated, and includes version info in response headers.

    Key points to mention

    • Multiple versioning strategies: Choose the approach that fits your client base—URL segment is most discoverable, headers are REST-pure, media types are content-negotiation aligned.
    • Deprecation signaling: Use response headers (api-supported-versions, api-deprecated-versions) to inform clients when to migrate, reducing support burden.
    • Backward compatibility: Maintain multiple controller versions or use conditional logic to ensure existing clients aren't broken by new releases.
    • Swagger/OpenAPI integration: The package automatically generates separate Swagger specs per version, helping API consumers understand available endpoints.
    • Default versioning: Set a sensible default (usually latest stable) so unversioned requests don't fail unexpectedly.

    Common follow-up questions

    • How do you handle data model changes between versions? Create separate DTO classes (ProductV1Dto, ProductV2Dto) and map domain models to the appropriate version in each action, or use polymorphic deserialization to accept both old and new shapes.
    • What strategy minimizes code duplication across versions? Extract common business logic into shared services, use inheritance or composition for version-specific behavior, and avoid copying entire controllers unnecessarily.
    • How do you deprecate an old API version? Set Deprecated = true, document the sunset date, return a 410 Gone or warning headers for final requests, and provide clear migration guidance in API docs and logs.

    Related interview questions

    .NETCsharpaspnetcore
  126. 126How do endpoint filters work in Minimal APIs?

    Experienced

    Endpoint filters in Minimal APIs are middleware-like components that wrap the endpoint handler, executing custom logic before and after the handler runs. They enable cross-cutting concerns such as validation, logging, authorization, and request/response transformation without modifying the handler itself. Filters are added per-endpoint using AddEndpointFilter() and form a pipeline where each filter can short-circuit execution or pass control to the next filter.

    How it works

    Endpoint filters implement the IEndpointFilter interface and receive an EndpointFilterInvocationContext containing the HTTP context, arguments, and a delegate to invoke the next filter or handler. The filter can inspect or modify the request before calling context.Proceed(), and then inspect or modify the response afterward. Multiple filters chain together in the order they were added, creating a pipeline similar to middleware but scoped to a single endpoint.

    When a request arrives, the first filter executes its pre-handler logic. It then calls context.Proceed(), which invokes the next filter in the chain. Once all filters have executed and the actual handler runs, control unwinds back through the filters, allowing post-handler logic to execute. If any filter returns early without calling Proceed(), the handler is short-circuited and subsequent filters don't run.

    Filters differ from traditional ASP.NET Core filters in that they are specific to Minimal APIs and operate at the endpoint handler level rather than globally or at the controller action level.

    Example

    csharp
    // Custom endpoint filter for validation and logging
    public class ValidationAndLoggingFilter : IEndpointFilter
    {
        private readonly ILogger<ValidationAndLoggingFilter> _logger;
    
        public ValidationAndLoggingFilter(ILogger<ValidationAndLoggingFilter> logger)
        {
            _logger = logger;
        }
    
        public async ValueTask<object?> InvokeAsync(
            EndpointFilterInvocationContext context,
            EndpointFilterDelegate next)
        {
            var httpContext = context.HttpContext;
            _logger.LogInformation("Request received: {Method} {Path}", 
                httpContext.Request.Method, httpContext.Request.Path);
    
            // Pre-handler logic: validate request
            if (!httpContext.Request.Headers.ContainsKey("X-Api-Key"))
            {
                return Results.BadRequest("Missing X-Api-Key header");
            }
    
            // Call next filter or handler
            var result = await next(context);
    
            // Post-handler logic: log response
            _logger.LogInformation("Response sent: {StatusCode}", httpContext.Response.StatusCode);
    
            return result;
        }
    }
    
    // Register and use the filter
    var builder = WebApplication.CreateBuilder();
    var app = builder.Build();
    
    app.MapPost("/users", CreateUser)
        .AddEndpointFilter<ValidationAndLoggingFilter>()
        .WithName("CreateUser");
    
    async Task<IResult> CreateUser(CreateUserRequest req)
    {
        return Results.Ok(new { id = 1, name = req.Name });
    }
    
    record CreateUserRequest(string Name);
    

    This filter logs incoming requests, validates the presence of an API key header (short-circuiting if missing), calls the handler, and then logs the outgoing response status code.

    Key points to mention

    • Filters implement IEndpointFilter and receive EndpointFilterInvocationContext with HTTP context and handler arguments.
    • Multiple filters chain in order; each calls context.Proceed() to pass control to the next filter or handler.
    • Returning early from a filter (without calling Proceed()) short-circuits the pipeline and prevents the handler from running.
    • Filters are endpoint-specific (added via AddEndpointFilter()) rather than global, making them ideal for fine-grained control.
    • Filters can access and modify request/response data, making them useful for validation, authorization, transformation, and logging.

    Common follow-up questions

    • Can filters access endpoint metadata or route parameters? Yes, the EndpointFilterInvocationContext provides access to HttpContext, route parameters, and the endpoint definition itself.
    • How do endpoint filters compare to middleware? Filters are endpoint-scoped and have access to endpoint-specific data, whereas middleware is global; filters are better for per-endpoint concerns.
    • Can I use dependency injection in filters? Yes, filters can be registered in the DI container and receive dependencies via constructor injection, similar to services.

    Related interview questions

    Further reading

    .NETCsharpaspnetcore
  127. 127How do you organize a large Minimal API project?

    Experienced

    Organize a large Minimal API project by grouping related endpoints using MapGroup, isolating each feature into dedicated extension methods or module classes, and applying filters, authentication, and metadata at the group level to reduce duplication. This structure mirrors controller-based organization while maintaining the simplicity and performance benefits of Minimal APIs.

    How it works

    Minimal API organization relies on grouping endpoints by feature domain (Users, Orders, Products) rather than scattering them throughout Program.cs. MapGroup allows you to define a route prefix and apply shared middleware, authentication policies, and metadata to multiple endpoints at once. Each feature group is typically housed in its own extension method on WebApplication or in a dedicated module class, making the application structure immediately clear to new team members.

    The pattern follows these layers: Program.cs orchestrates feature registration, extension methods or modules organize endpoint logic, and shared concerns (auth, logging, validation) are applied at the group level rather than per-endpoint. This reduces boilerplate, centralizes cross-cutting concerns, and scales cleanly as the API grows from dozens to hundreds of endpoints.

    Example

    csharp
    // Program.cs
    var builder = WebApplicationBuilder.CreateBuilder(args);
    builder.Services.AddScoped<IUserService>(sp => new UserService());
    builder.Services.AddScoped<IOrderService>(sp => new OrderService());
    
    var app = builder.Build();
    
    // Register feature endpoints
    app.MapUserEndpoints();
    app.MapOrderEndpoints();
    
    app.Run();
    
    // Users/UserEndpoints.cs
    public static class UserEndpoints
    {
        public static void MapUserEndpoints(this WebApplication app)
        {
            var group = app.MapGroup("/api/users")
                .WithName("Users")
                .WithOpenApi()
                .RequireAuthorization("ApiKey");
    
            group.MapGet("", GetUsers)
                .WithSummary("List all users")
                .WithDescription("Retrieves a paginated list of users");
    
            group.MapGet("{id}", GetUserById)
                .WithName("GetUserById")
                .WithOpenApi();
    
            group.MapPost("", CreateUser)
                .RequireAuthorization("AdminOnly");
    
            group.MapPut("{id}", UpdateUser);
            group.MapDelete("{id}", DeleteUser);
        }
    
        private static async Task<IResult> GetUsers(IUserService service)
        {
            var users = await service.GetAllAsync();
            return Results.Ok(users);
        }
    
        private static async Task<IResult> GetUserById(int id, IUserService service)
        {
            var user = await service.GetByIdAsync(id);
            return user is null ? Results.NotFound() : Results.Ok(user);
        }
    
        private static async Task<IResult> CreateUser(CreateUserDto dto, IUserService service)
        {
            var user = await service.CreateAsync(dto);
            return Results.Created($"/api/users/{user.Id}", user);
        }
    
        private static async Task<IResult> UpdateUser(int id, UpdateUserDto dto, IUserService service)
        {
            var success = await service.UpdateAsync(id, dto);
            return success ? Results.NoContent() : Results.NotFound();
        }
    
        private static async Task<IResult> DeleteUser(int id, IUserService service)
        {
            var success = await service.DeleteAsync(id);
            return success ? Results.NoContent() : Results.NotFound();
        }
    }

    This example demonstrates grouping all user-related endpoints under a single /api/users prefix, applying authorization and OpenAPI metadata once for the entire group, and handling create, read, update, and delete operations with clean separation of concerns.

    Key points to mention

    • MapGroup for shared configuration: Centralize RequireAuthorization, WithOpenApi, WithName, and middleware application to eliminate per-endpoint duplication.
    • Feature-based extension methods: Each domain (Users, Orders, Products) gets its own extension method on WebApplication, making Program.cs readable and modular even with 50+ endpoints.
    • Dependency injection inside handlers: Pass service dependencies directly into handler methods; ASP.NET Core resolves them automatically, keeping handler signatures clean.
    • Namespace and file organization: Use folder structure like Features/Users/, Features/Orders/ to mirror API routes and make navigation intuitive.
    • Reusable metadata and policies: Apply WithOpenApi(), RequireAuthorization(), and validation filters at group level; individual endpoints override only when necessary.

    Common follow-up questions

    • How do you handle shared validation and error handling across groups? Use endpoint filters or middleware registered at the group level or globally in Program.cs to standardize validation logic and error responses.
    • When should you use a module class instead of an extension method? For large features with internal helpers and significant complexity, a dedicated module class with static methods is more maintainable; for simple feature groups, extension methods keep code concise.
    • How do you test Minimal API endpoints organized this way? Use WebApplicationFactory in xUnit or NUnit to spin up the full application, then send HTTP requests to test endpoints without coupling to individual handler methods.

    Related interview questions

    Further reading

    .NETCsharpaspnetcore
  128. 128What are TypedResults in Minimal APIs?

    Experienced

    TypedResults in Minimal APIs are a helper class that returns strongly typed HTTP responses (like Ok<T>, NotFound, or BadRequest<T>) instead of generic IResult. They enable compile-time type safety, improve testability, and allow ASP.NET Core to generate accurate OpenAPI documentation automatically without additional metadata attributes.

    How it works

    TypedResults is a static helper class in the Microsoft.AspNetCore.Http namespace that provides factory methods for creating strongly typed response objects. Each method returns a concrete type that implements IResult and includes generic type parameters for the response body.

    When you use TypedResults.Ok<Product>(product), the compiler knows the exact return type. This allows:

    • Compile-time type checking: The endpoint's return type can be declared as the specific result type, not just IResult.
    • OpenAPI introspection: ASP.NET Core automatically infers response schemas, status codes, and media types from the return type without needing [Produces] or [ProduceResponseType] attributes.
    • Unit test clarity: Tests can assert the exact response type and inspect properties directly without casting.

    The key difference from Results helper is that Results returns untyped IResult, while TypedResults returns strongly typed responses that preserve generic information at runtime.

    Example

    csharp
    using Microsoft.AspNetCore.Http.HttpResults;
    
    var builder = WebApplication.CreateBuilder();
    var app = builder.Build();
    
    app.MapGet("/products/{id}", GetProductById);
    
    async Task<Results<Ok<Product>, NotFound>> GetProductById(int id)
    {
        var product = await db.Products.FindAsync(id);
        if (product == null)
            return TypedResults.NotFound();
        return TypedResults.Ok(product);
    }
    
    // Unit test example
    [Fact]
    public async Task GetProduct_WithValidId_ReturnsOkWithProduct()
    {
        var result = await GetProductById(1);
        
        // Direct type assertion, no casting needed
        var okResult = Assert.IsType<Ok<Product>>(result);
        Assert.NotNull(okResult.Value);
        Assert.Equal(1, okResult.Value.Id);
    }

    The endpoint declares its return type as Results<Ok<Product>, NotFound>, making both possible outcomes explicit, and OpenAPI automatically documents both a 200 response with a Product schema and a 404 response.

    Key points to mention

    • TypedResults preserves generic type information that Results discards, enabling accurate schema generation in OpenAPI documentation.
    • Return types can use the Results<T1, T2, ...> union syntax to declare all possible outcomes, improving code clarity and API contracts.
    • No need for [Produces], [ProduceResponseType], or response type metadata attributes when using TypedResults—ASP.NET Core infers them automatically.
    • TypedResults works seamlessly with Minimal APIs and is the recommended approach for modern ASP.NET Core applications.
    • Unit tests can directly assert and inspect the response type without casting to IResult, improving test readability and maintainability.

    Common follow-up questions

    • What's the difference between Results and TypedResults? — Results returns untyped IResult, losing generic type info; TypedResults returns strongly typed responses that preserve the response body type for OpenAPI and testing.
    • How does TypedResults improve OpenAPI generation? — ASP.NET Core reflects on the generic types to automatically populate response schemas, status codes, and media types without manual metadata attributes.
    • Can I mix Results and TypedResults in the same endpoint? — Yes, but it's cleaner and more consistent to use TypedResults exclusively for strongly typed contracts.

    Related interview questions

    Further reading

    .NETCsharpaspnetcore
  129. 129How does JWT authentication work in ASP.NET Core?

    Experienced

    JWT authentication in ASP.NET Core works by having the JwtBearer middleware validate a Bearer token from the Authorization header, verify its signature and claims, and then construct a ClaimsPrincipal that the authorization system uses to enforce access policies. The server signs tokens at login, and clients send them with every request; the server never stores sessions. This stateless approach scales well for distributed systems and APIs.

    How does JWT authentication work in ASP.NET Core diagram

    How it works

    JWT (JSON Web Token) authentication is a token-based, stateless mechanism. When a user logs in, the server creates a token containing claims (identity information) and signs it with a secret key. The client stores this token and includes it in the Authorization header as Bearer <token> for subsequent requests.

    On each request, the JwtBearer authentication handler intercepts the token, validates its signature using the same secret key (or public key in asymmetric scenarios), checks expiration, issuer, and audience claims, and reconstructs a ClaimsPrincipal. This principal is then attached to the HttpContext and used by authorization policies to control access to endpoints.

    The key advantage is statelessly validating identity without server-side session storage. The token itself is the proof of authentication, so it works seamlessly across multiple servers and microservices. However, tokens cannot be revoked instantly—only new tokens issued after a revocation event will reflect the change.

    Example

    csharp
    // Program.cs – Configure JWT authentication
    var builder = WebApplication.CreateBuilder(args);
    
    builder.Services
        .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
        .AddJwtBearer(options =>
        {
            options.TokenValidationParameters = new TokenValidationParameters
            {
                ValidateIssuerSigningKey = true,
                IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("your-secret-key-min-32-chars-long")),
                ValidateIssuer = true,
                ValidIssuer = "https://myapp.com",
                ValidateAudience = true,
                ValidAudience = "myapp-api",
                ValidateLifetime = true,
                ClockSkew = TimeSpan.Zero
            };
        });
    
    builder.Services.AddAuthorization();
    var app = builder.Build();
    
    app.UseAuthentication();
    app.UseAuthorization();
    
    // Login endpoint – issue token
    app.MapPost("/login", (ITokenService tokenService) =>
    {
        var token = tokenService.GenerateToken("user123", "admin");
        return Results.Ok(new { accessToken = token });
    });
    
    // Protected endpoint
    app.MapGet("/protected", (HttpContext ctx) =>
        Results.Ok($"Hello {ctx.User.FindFirst(ClaimTypes.NameIdentifier)?.Value}"))
        .RequireAuthorization();
    
    app.Run();
    

    This configures JWT validation with issuer, audience, and lifetime checks, creates a login endpoint that issues tokens, and protects an endpoint with .RequireAuthorization()—middleware validates the Bearer token and populates the ClaimsPrincipal before the handler executes.

    Key points to mention

    • Stateless validation: The server does not store tokens or sessions; validation is purely cryptographic.
    • Token structure: JWTs consist of header (algorithm), payload (claims), and signature; only the signature proves authenticity.
    • Claim validation: Issuer, audience, expiration, and custom claims are checked; misconfiguration allows token hijacking.
    • No instant revocation: Tokens remain valid until expiration; implement token blacklists or short lifetimes and refresh tokens for revocation.
    • Asymmetric keys optional: Use symmetric keys (HS256) for simple scenarios or asymmetric (RS256) when multiple servers validate tokens signed by a central authority.

    Common follow-up questions

    Main steps

    1. Token issuance: Server creates a JWT containing claims (subject, roles, expiration) and signs it with a secret or private key.
    2. Client storage: Client receives the token and stores it (typically in memory or localStorage for SPAs).
    3. Token transmission: Client includes the token in the Authorization header as Bearer <token> with each request.
    4. Middleware interception: JwtBearer middleware extracts and parses the token from the Authorization header.
    5. Signature verification: Middleware validates the token's signature using the configured key.
    6. Claims validation: Issuer, audience, expiration, and custom claims are checked against configured parameters.
    7. Principal construction: A valid token is converted into a ClaimsPrincipal and attached to HttpContext.User.
    8. Authorization enforcement: Authorization policies and [Authorize] attributes use the ClaimsPrincipal to grant or deny access.

    Related interview questions

    Further reading

    How does JWT authentication work in ASP.NET Core?
    .NETCsharpaspnetcoreSecurity
  130. 130How do refresh tokens work?

    Experienced

    Refresh tokens are long-lived credentials that allow a client to obtain a new short-lived access token without requiring the user to re-authenticate. When an access token expires, the client sends the refresh token to the authorization server to receive a fresh access token and optionally a new refresh token. Implementing proper refresh token rotation and revocation strategies is critical for security.

    How it works

    The refresh token flow operates in distinct phases. First, during initial authentication, the authorization server issues both an access token (valid for minutes) and a refresh token (valid for days or weeks). The access token is used to authenticate API requests, while the refresh token is stored securely on the client—typically in an HTTP-only cookie or encrypted local storage.

    When the access token expires, instead of forcing re-authentication, the client sends the refresh token to the token endpoint. The server validates the refresh token, checks it hasn't been revoked, and issues a new access token. Modern implementations employ refresh token rotation: each refresh returns a fresh refresh token and invalidates the old one. This limits the window of exposure if a token is compromised.

    The server maintains a token store (database or cache) tracking issued refresh tokens, their expiration, associated user IDs, and revocation status. When a refresh token is used, the server marks it as consumed and issues a new one. If the same refresh token is used twice, it indicates token theft, and the server can invalidate the entire token family and force re-authentication.

    Secure storage is paramount: refresh tokens must never be exposed to JavaScript (use HTTP-only cookies), encrypted in transit (HTTPS only), and rotated regularly. Access tokens should be short-lived (5-15 minutes) to minimize damage if stolen, while refresh tokens live longer (days to weeks) but remain protected.

    Example

    csharp
    public class TokenService
    {
        private readonly ITokenRepository _tokenRepo;
        private readonly IConfiguration _config;
    
        public async Task<TokenResponse> RefreshAccessTokenAsync(string refreshToken)
        {
            var storedToken = await _tokenRepo.GetRefreshTokenAsync(refreshToken);
            
            if (storedToken == null || storedToken.IsRevoked || storedToken.ExpiryDate < DateTime.UtcNow)
                throw new UnauthorizedAccessException("Invalid or expired refresh token");
    
            var userId = storedToken.UserId;
            var newAccessToken = GenerateAccessToken(userId);
            
            // Rotate: invalidate old token and issue new one
            storedToken.IsRevoked = true;
            var newRefreshToken = GenerateRefreshToken();
            
            await _tokenRepo.UpdateAsync(storedToken);
            await _tokenRepo.AddRefreshTokenAsync(new RefreshTokenEntity
            {
                Token = newRefreshToken,
                UserId = userId,
                ExpiryDate = DateTime.UtcNow.AddDays(7),
                CreatedAt = DateTime.UtcNow
            });
    
            return new TokenResponse
            {
                AccessToken = newAccessToken,
                RefreshToken = newRefreshToken,
                ExpiresIn = 900 // 15 minutes
            };
        }
    
        private string GenerateAccessToken(string userId) =>
            new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken(
                issuer: _config["Jwt:Issuer"],
                audience: _config["Jwt:Audience"],
                claims: new[] { new Claim(ClaimTypes.NameIdentifier, userId) },
                expires: DateTime.UtcNow.AddMinutes(15),
                signingCredentials: new SigningCredentials(
                    new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["Jwt:Key"])),
                    SecurityAlgorithms.HmacSha256)
            ));
    
        private string GenerateRefreshToken() =>
            Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
    }
    

    This code demonstrates refresh token rotation: the old token is revoked, a new access token is generated with a short expiry, and a new refresh token is issued, all in a single atomic operation.

    Key points to mention

    • Token lifetime asymmetry: Access tokens are short-lived (minutes); refresh tokens are long-lived (days/weeks) and stored securely.
    • Rotation strategy: Each refresh invalidates the old token and issues a new one, reducing exposure window and detecting token theft via reuse detection.
    • Storage security: Refresh tokens must be stored in HTTP-only cookies or encrypted at rest; never expose them to client-side JavaScript.
    • Revocation and blacklisting: Maintain a database or cache of valid refresh tokens; revoke on logout, password change, or suspicious reuse patterns.
    • Family tracking: If the same refresh token is used twice, revoke all tokens in that family and force re-authentication, indicating compromise.

    Common follow-up questions

    • What happens if a refresh token expires before it's used? The client must re-authenticate; there is no grace period.
    • How do you detect token theft in a refresh token rotation scheme? If the same (now-revoked) refresh token is used again, it indicates the original token was stolen.
    • Can you revoke all active refresh tokens for a user? Yes—maintain a "revocation epoch" timestamp in the user record; any token issued before that epoch is considered revoked.

    Related interview questions

    Further reading

    .NETCsharpSecurity
  131. 131What is policy-based authorization?

    Experienced

    Policy-based authorization is a declarative, reusable approach to defining and enforcing authorization rules in ASP.NET Core. Instead of scattering role or permission checks throughout your code, you define named policies that combine requirements (roles, claims, custom logic) in one place, then apply them via attributes or middleware. This separates authorization concerns from business logic and makes policies easy to test, update, and reuse across endpoints.

    How it works

    Policy-based authorization operates through ASP.NET Core's authorization service. You register policies in the dependency injection container during startup using AddAuthorization. Each policy can contain one or more requirements—such as role membership, claim values, or custom logic evaluated by a handler. When a request hits an endpoint decorated with [Authorize(Policy = "...")] or RequireAuthorization(policy), the framework invokes the authorization service. The service evaluates all requirements in that policy and succeeds only if every requirement passes. If any requirement fails, the request is forbidden.

    Custom requirements inherit from IAuthorizationRequirement and are paired with handlers that inherit from AuthorizationHandler<TRequirement>. Handlers examine the AuthorizationHandlerContext (which includes the authenticated user's claims and principal) and call context.Succeed(requirement) if the condition is met.

    Example

    csharp
    // Register policies in Program.cs
    builder.Services.AddAuthorization(options =>
    {
        options.AddPolicy("AdminOnly", policy =>
            policy.RequireRole("Administrator"));
    
        options.AddPolicy("PremiumUser", policy =>
            policy.RequireClaim("subscription", "premium"));
    
        options.AddPolicy("MinimumAge18", policy =>
            policy.AddRequirements(new MinimumAgeRequirement(18)));
    });
    
    // Custom requirement
    public class MinimumAgeRequirement : IAuthorizationRequirement
    {
        public int MinimumAge { get; }
        public MinimumAgeRequirement(int minimumAge) => MinimumAge = minimumAge;
    }
    
    // Custom handler
    public class MinimumAgeHandler : AuthorizationHandler<MinimumAgeRequirement>
    {
        protected override Task HandleRequirementAsync(
            AuthorizationHandlerContext context, MinimumAgeRequirement requirement)
        {
            var ageClaim = context.User.FindFirst("age");
            if (ageClaim != null && int.TryParse(ageClaim.Value, out var age))
            {
                if (age >= requirement.MinimumAge)
                    context.Succeed(requirement);
            }
            return Task.CompletedTask;
        }
    }
    
    // Apply policy to controller or action
    [Authorize(Policy = "AdminOnly")]
    [HttpDelete("users/{id}")]
    public async Task<IActionResult> DeleteUser(int id) => Ok();
    
    [Authorize(Policy = "PremiumUser")]
    [HttpGet("premium-content")]
    public IActionResult GetPremiumContent() => Ok();
    

    This example defines three policies: one requiring the Administrator role, one requiring a "premium" subscription claim, and one using a custom handler to validate a user's age claim. Endpoints apply the appropriate policy via the [Authorize] attribute.

    Key points to mention

    • Centralized configuration: Define all policies once in Program.cs, making authorization logic consistent and easier to maintain.
    • Composable requirements: A single policy can combine multiple requirements (roles, claims, custom logic). All must succeed for authorization to pass.
    • Custom handlers: Use AuthorizationHandler<TRequirement> to implement complex, domain-specific authorization logic without cluttering controllers.
    • Reusability: Apply the same policy to multiple endpoints or even different applications, reducing duplication.
    • Testing-friendly: Mock the authorization service or use WithWebHostBuilder in integration tests to verify policy behavior independently.

    Common follow-up questions

    • How does policy-based authorization differ from role-based authorization? Role-based uses fixed role names; policies are more flexible and can combine multiple requirement types. Policies are similar to resource-based authorization but applied at the route level rather than the resource instance level.
    • Can I combine multiple policies on one endpoint? No, [Authorize(Policy = "...")] accepts one policy. Combine multiple requirements into a single policy or use multiple handlers on shared requirements.
    • How do I test custom authorization handlers? See testing authorization rules in integration tests for patterns using WithWebHostBuilder to inject mock handlers.

    Related interview questions

    Further reading

    .NETCsharpSecurity
  132. 132How do you implement resource-based authorization?

    Experienced

    Resource-based authorization checks permissions based on specific resource ownership or properties by injecting IAuthorizationService and calling AuthorizeAsync(user, resource, requirement) inside action methods, paired with a custom authorization handler that evaluates the resource state.

    How it works

    Resource-based authorization differs from policy-based authorization because the decision depends on the actual resource instance, not just the user's claims or roles. Instead of decorating actions with [Authorize(Policy = "...")], you inject IAuthorizationService and call it at runtime after retrieving the resource from your database or service layer.

    The flow is: retrieve the resource → create an authorization requirement → pass the user, resource, and requirement to AuthorizeAsync → your custom handler receives both and evaluates ownership or permissions. This is powerful because you can check properties like "is this user the owner of this document?" or "does this user have edit permission on this project?"

    You define a custom requirement (inheriting from IAuthorizationRequirement) and a handler (inheriting from AuthorizationHandler<TRequirement, TResource>). The handler's HandleRequirementAsync method receives the resource and decides whether the requirement is satisfied.

    Example

    csharp
    // Define the requirement
    public class EditDocumentRequirement : IAuthorizationRequirement { }
    
    // Define the handler
    public class EditDocumentHandler : AuthorizationHandler<EditDocumentRequirement, Document>
    {
        protected override Task HandleRequirementAsync(
            AuthorizationHandlerContext context,
            EditDocumentRequirement requirement,
            Document resource)
        {
            if (resource.OwnerId == context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value)
            {
                context.Succeed(requirement);
            }
            return Task.CompletedTask;
        }
    }
    
    // Register in Program.cs
    services.AddAuthorizationBuilder()
        .AddPolicy("EditDocument", policy =>
            policy.Requirements.Add(new EditDocumentRequirement()));
    services.AddSingleton<IAuthorizationHandler, EditDocumentHandler>();
    
    // Use in controller
    [ApiController]
    [Route("api/documents")]
    public class DocumentsController(IAuthorizationService authorizationService) : ControllerBase
    {
        [HttpPut("{id}")]
        public async Task<IActionResult> UpdateDocument(int id, UpdateDocumentDto dto)
        {
            var document = await _db.Documents.FindAsync(id);
            if (document == null) return NotFound();
    
            var result = await authorizationService.AuthorizeAsync(
                User, document, new EditDocumentRequirement());
    
            if (!result.Succeeded) return Forbid();
    
            document.Title = dto.Title;
            await _db.SaveChangesAsync();
            return Ok();
        }
    }

    This example checks if the current user owns the document before allowing updates, with the handler evaluating ownership by comparing the document's OwnerId to the user's identity claim.

    Key points to mention

    • IAuthorizationService.AuthorizeAsync(user, resource, requirement) is called inside your action after loading the resource, enabling dynamic, context-aware decisions.
    • Custom handlers inherit from AuthorizationHandler<TRequirement, TResource> and access both the user context and the specific resource in HandleRequirementAsync.
    • Resource-based authorization complements authentication and authorization by checking ownership or object-level permissions rather than just roles or claims.
    • You can combine multiple handlers and requirements; if any handler calls context.Fail(), the overall result is forbidden.
    • This pattern scales well for multi-tenant systems, collaborative platforms, or any scenario where permissions vary per resource instance.

    Common follow-up questions

    • How do you test this? — Use integration tests with test users and resources to verify handlers correctly approve or deny access.
    • Can you use filters instead? — Yes, you could implement a custom authorization filter, but injecting the service is clearer and keeps authorization logic in your handlers.
    • What if the resource has hierarchical permissions? — Pass the resource or its parent to the handler; the handler can check ownership at any level in your permission hierarchy.

    Related interview questions

    Further reading

    .NETCsharpSecurity
  133. 133What is the difference between cookie and JWT authentication?

    Experienced

    Cookie-based authentication stores session state on the server and uses HTTP-only cookies for browser clients, requiring explicit CSRF protection and server memory. JWT (JSON Web Token) authentication is stateless, encoding claims directly in a signed token that the client includes in request headers, making it ideal for APIs and distributed systems. The choice depends on your architecture: cookies for traditional server-rendered apps, JWT for APIs and mobile clients.

    How it works

    Cookie-based authentication follows a traditional session model. When a user logs in, the server validates credentials, creates a session record in memory or a data store (Redis, SQL Server), and sends a session ID back as an HTTP-only cookie. The browser automatically includes this cookie on every request to the same domain. The server looks up the session, verifies it's valid and not expired, and processes the request. On logout, the session is destroyed. Because cookies are automatically sent, CSRF attacks are possible—an attacker can trick your browser into making unwanted requests—so you must implement CSRF protection tokens.

    JWT authentication is stateless. When a user logs in, the server creates a signed token containing user claims (ID, roles, permissions) and an expiration time, then returns it to the client. The client stores the token (localStorage, sessionStorage, or a secure cookie) and includes it in the Authorization: Bearer <token> header on every request. The server validates the signature and expiration without looking up any session. Because the token is explicitly passed by the client and not automatically included, CSRF is not a concern; XSS (Cross-Site Scripting) is the primary threat if the token is stored in JavaScript-accessible storage.

    Example

    csharp
    // Cookie-based authentication (ASP.NET Core)
    app.MapPost("/login-cookie", async (LoginRequest req, IAuthService auth, 
        HttpContext ctx) =>
    {
        var user = await auth.ValidateCredentialsAsync(req.Email, req.Password);
        if (user == null) return Results.Unauthorized();
        
        await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
            new ClaimsPrincipal(new ClaimsIdentity(new[]
            {
                new Claim(ClaimTypes.NameIdentifier, user.Id),
                new Claim(ClaimTypes.Email, user.Email)
            }, "cookie")));
        
        return Results.Ok();
    });
    
    // JWT authentication (ASP.NET Core)
    app.MapPost("/login-jwt", async (LoginRequest req, IAuthService auth, 
        ITokenService tokenService) =>
    {
        var user = await auth.ValidateCredentialsAsync(req.Email, req.Password);
        if (user == null) return Results.Unauthorized();
        
        var token = tokenService.GenerateToken(new[]
        {
            new Claim(ClaimTypes.NameIdentifier, user.Id),
            new Claim(ClaimTypes.Email, user.Email)
        });
        
        return Results.Ok(new { token });
    });

    The cookie example uses SignInAsync to create a secure, HTTP-only cookie that the browser manages automatically. The JWT example generates a signed token the client must store and include explicitly on subsequent requests.

    Comparison table

    Aspect Cookie-based JWT
    State Stateful (server stores sessions) Stateless (token is self-contained)
    Storage HTTP-only cookie (automatic on browser) Client (localStorage, sessionStorage, or cookie)
    Transmission Automatic with every same-domain request Manual in Authorization header
    CSRF risk High; requires CSRF token protection Low; client explicitly sends token
    Best for Browser-based apps (MVC, Blazor Server) APIs, SPAs, mobile apps, microservices
    Scalability Requires shared session store (Redis, DB) No server-side session storage needed
    What is the difference between cookie and JWT authentication diagram

    Key points to mention

    • Cookies are automatic and transparent to the client but tie authentication to the browser and same-origin policy; JWTs require explicit client management but work across domains and with mobile apps.
    • Cookie sessions must be validated against server state every request; JWT tokens are validated cryptographically and do not require a database lookup, improving performance at scale.
    • Cookies mitigate XSS (HTTP-only flag) but require CSRF protection; JWTs mitigate CSRF but are vulnerable to XSS if stored in JavaScript. Use secure HTTP-only cookies for JWTs when possible.
    • JWT authentication in ASP.NET Core is configured with AddJwtBearer middleware; cookie authentication uses AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).
    • Token expiration and refresh strategies are critical for JWT; with cookies, session expiration is server-controlled.

    Common follow-up questions

    • How do you handle token refresh with JWT? Issue a short-lived access token and a longer-lived refresh token; when the access token expires, the client uses the refresh token to get a new access token without re-entering credentials.
    • Can you use both cookies and JWT together? Yes; store the JWT in an HTTP-only cookie so the browser sends it automatically, gaining JWT's stateless benefits with cookie's automatic transmission and XSS protection.
    • Why is revocation harder with JWT than cookies? JWTs are valid until expiration; to revoke early, you must maintain a blacklist or use a token version in your user record, defeating some stateless benefits.

    Related interview questions

    Further reading

    What is the difference between cookie and JWT authentication?
    .NETCsharpSecurity
  134. 134How do you protect APIs against CSRF?

    Experienced

    Direct Answer: CSRF protection depends on your authentication mechanism. For cookie-authenticated endpoints, use antiforgery tokens (like ASP.NET Core's built-in AntiforgeryToken) or enforce SameSite cookie attributes. Bearer token APIs are inherently safe from classic CSRF because browsers cannot automatically attach custom authorization headers—attackers must trick users into submitting forms with valid tokens, which is much harder.

    How it works

    Cross-Site Request Forgery (CSRF) exploits the browser's automatic cookie inclusion behavior. When a user visits a malicious site while authenticated to your API, the browser automatically sends stored cookies with any request to your domain, allowing the attacker to perform unwanted actions on the user's behalf.

    Cookie-based authentication: The classic solution is the double-submit cookie pattern or synchronizer token pattern. A unique token is generated per session and embedded in forms or response headers. The server validates that the token in the request matches the token stored in the cookie. Since JavaScript on malicious domains cannot read tokens stored in httpOnly cookies, attackers cannot forge valid requests.

    Bearer token authentication: APIs using JWT or other bearer tokens in the Authorization header are protected by the browser's Same-Origin Policy for custom headers. JavaScript from malicious origins cannot set the Authorization header due to CORS restrictions, making automatic token inclusion impossible. This is why bearer tokens are inherently CSRF-safe compared to cookies.

    SameSite cookies: A modern browser feature that restricts cookie inclusion in cross-site requests. Strict mode prevents all cross-site submissions; Lax allows safe GET requests but blocks POST requests from external sites.

    Example

    csharp
    // Startup configuration
    services.AddAntiforgery(options =>
    {
        options.HeaderName = "X-CSRF-TOKEN";
        options.FormFieldName = "AntiforgeryToken";
        options.SuppressXFrameOptionsHeader = false;
    });
    
    services.ConfigureApplicationCookie(options =>
    {
        options.Cookie.SameSite = SameSiteMode.Strict;
        options.Cookie.HttpOnly = true;
        options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
    });
    
    // Controller action
    [HttpPost]
    [ValidateAntiforgeryToken]
    public IActionResult ChangeEmail([FromForm] string newEmail)
    {
        // Token is automatically validated by the attribute
        _userService.UpdateEmail(User.Identity.Name, newEmail);
        return Ok();
    }
    
    // In the view, include the token
    <form method="post" asp-action="ChangeEmail">
        @Html.AntiForgeryToken()
        <input type="email" name="newEmail" />
        <button type="submit">Update</button>
    </form>

    The ValidateAntiforgeryToken attribute checks that the submitted token matches the token in the user's cookie, preventing CSRF attacks while allowing legitimate form submissions.

    Key points to mention

    • Cookie authentication requires antiforgery tokens — always use [ValidateAntiforgeryToken] on state-changing endpoints (POST, PUT, DELETE)
    • Bearer tokens are CSRF-safe by default — the Same-Origin Policy prevents cross-site header injection
    • SameSite=Strict/Lax mitigates CSRF — use this in addition to tokens for defense-in-depth on cookie-based APIs
    • httpOnly and Secure flags are essential — prevent JavaScript access and ensure encrypted transport
    • SPAs and single-page apps — verify tokens are stored securely (not localStorage for sensitive values) and sent in every state-changing request

    Common follow-up questions

    • Why is SameSite not enough? — SameSite relies on browser support and doesn't protect against same-site requests; combining it with antiforgery tokens provides stronger defense.
    • Do I need CSRF protection for public endpoints? — No, only endpoints that modify state and require authentication. Public read-only endpoints are safe from CSRF.
    • How do I handle CSRF tokens in SPAs with multiple tabs? — Use a single token per session stored in httpOnly cookies and include it in request headers; refreshing a tab will resync the token if needed.

    Related interview questions

    Further reading

    .NETCsharpSecurity
  135. 135How do you implement rate limiting in .NET 8?

    Experienced

    Rate limiting in .NET 8 is implemented using the built-in System.Threading.RateLimiting namespace, which provides middleware and APIs for controlling request flow. You define a rate limiter policy (fixed window, sliding window, token bucket, or concurrency), partition by user/IP/custom key, and return HTTP 429 (Too Many Requests) when limits are exceeded. The middleware integrates seamlessly into the Minimal APIs or controller pipeline.

    How it works

    .NET 8 introduced a standardized rate limiting system that operates at multiple levels. At the core, you create a RateLimiter instance with a specific algorithm and partition strategy. The middleware intercepts requests, checks if the consumer (identified by user ID, IP address, or custom key) has capacity remaining, and either allows the request through or rejects it with a 429 status code.

    The main algorithms are: Fixed Window (allow N requests per fixed time period), Sliding Window (overlapping windows for smoother throttling), Token Bucket (tokens accumulate over time; each request consumes tokens), and Concurrency (limit simultaneous requests). You can partition by any key—common choices are IP address, authenticated user ID, or custom identifiers—enabling per-consumer limits rather than global limits.

    When a request is rejected, the middleware optionally sets retry-after headers and logs telemetry, helping clients implement intelligent backoff strategies. For distributed scenarios, consider how to design a rate limiter for a distributed system using Redis or external services.

    Example

    csharp
    var builder = WebApplication.CreateBuilder(args);
    
    // Add rate limiting service
    builder.Services.AddRateLimiter(options =>
    {
        options.AddFixedWindowLimiter("fixed", config =>
        {
            config.PermitLimit = 10;
            config.Window = TimeSpan.FromMinutes(1);
            config.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
            config.QueueLimit = 2;
        });
        
        options.AddTokenBucketLimiter("token", config =>
        {
            config.TokenLimit = 100;
            config.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
            config.ReplenishmentPeriod = TimeSpan.FromSeconds(10);
            config.TokensPerPeriod = 20;
            config.QueueLimit = 5;
        });
        
        options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    });
    
    var app = builder.Build();
    app.UseRateLimiter();
    
    app.MapGet("/api/data", () => "Success")
        .RequireRateLimiting("fixed");
    
    app.MapPost("/api/upload", () => "Uploaded")
        .RequireRateLimiting("token");
    
    app.Run();
    

    This example defines two policies: a fixed window allowing 10 requests per minute, and a token bucket replenishing 20 tokens every 10 seconds with a max of 100 tokens. Endpoints apply policies using RequireRateLimiting(), and requests exceeding limits receive a 429 response.

    Key points to mention

    • Use AddRateLimiter() in the service container and UseRateLimiter() middleware to enable the feature globally or per-endpoint.
    • Partition strategies allow per-user or per-IP limits; use custom partition keys for more granular control (e.g., API key, tenant ID).
    • Queuing options (QueueLimit, QueueProcessingOrder) determine whether excess requests wait or are rejected immediately.
    • For distributed systems, in-memory rate limiting works only on a single instance; consider Redis-backed solutions for multi-node deployments.
    • Combine with resilience patterns like exponential backoff and circuit breakers to handle 429 responses gracefully.

    Common follow-up questions

    • How do you partition by user or IP address? — Override the partition key by implementing a custom partitioner or using the built-in extension methods like PartitionByAuthenticatedUser() and PartitionByIpAddress() on your limiter configuration.
    • How do you handle rate limiting in a distributed system? — Use a shared backing store (Redis, MemoryCache with sync, or custom RateLimiterStatistics) so all instances respect the same limits; .NET 8's built-in implementation is in-memory only.
    • What should clients do when they receive a 429 response? — Implement exponential backoff with jitter, respect the Retry-After header, and consider circuit breakers to avoid hammering the endpoint.

    Related interview questions

    Further reading

    .NETCsharpaspnetcoreasync
  136. 136What is output caching in ASP.NET Core?

    Experienced

    Output caching in ASP.NET Core is a server-side response caching mechanism that stores complete HTTP responses based on configurable policies, tags, and eviction rules. Unlike HTTP response caching which relies on Cache-Control headers sent to clients or intermediaries, output caching gives you fine-grained control over what gets cached, for how long, and how it gets invalidated—all on the server.

    How it works

    Output caching middleware intercepts HTTP responses before they're sent to the client. When a request matches a configured cache policy, the middleware checks if a cached response exists for that request. If it does, it returns the cached response immediately without executing the endpoint logic. If not, the endpoint executes normally, and the middleware stores the response according to the policy rules.

    Policies are defined declaratively using the OutputCache attribute or fluent builder methods. Each policy specifies cache duration, which request properties to consider (query strings, headers, cookies), and optional cache tags for grouped invalidation. When you tag cached responses, you can invalidate entire groups at once—for example, invalidating all "product" cache tags when product data changes.

    The cache itself is an in-memory store by default, though you can implement custom providers for distributed scenarios. Key features include conditional caching (cache only under certain conditions), Vary headers support (cache different responses for different Accept headers or other conditions), and automatic cache busting through tag-based invalidation.

    Example

    csharp
    // Program.cs
    var builder = WebApplication.CreateBuilder(args);
    builder.Services.AddOutputCache(options =>
    {
        options.AddPolicy("ProductList", policy =>
            policy
                .Expire(TimeSpan.FromMinutes(5))
                .WithTag("products")
                .VaryByQuery("category"));
        
        options.AddPolicy("UserProfile", policy =>
            policy
                .Expire(TimeSpan.FromMinutes(10))
                .WithTag("user-profile")
                .VaryByCookie("userId"));
    });
    
    var app = builder.Build();
    app.UseOutputCache();
    
    // Endpoint
    app.MapGet("/products", (string category) => GetProducts(category))
        .CacheOutput("ProductList")
        .WithName("GetProducts")
        .WithOpenApi();
    
    // Invalidate by tag
    app.MapPost("/products/invalidate", async (IOutputCacheStore cache) =>
    {
        await cache.EvictByTagAsync("products", default);
        return Results.Ok();
    });

    This example demonstrates a cached product list endpoint that varies by query string category, a profile endpoint that varies by user cookie, and an invalidation endpoint that clears all cached responses tagged as "products".

    Key points to mention

    • Server-side only: Output caching stores responses on the server, unlike HTTP caching headers that may be cached by clients or CDNs.
    • Policy-based control: Define policies declaratively to specify duration, cache keys (vary by), conditions, and tags—no HTTP header configuration needed.
    • Tag-based invalidation: Group cached responses by tags and invalidate multiple entries at once, ideal for cache coherency when upstream data changes.
    • Request matching: Vary cache by query strings, headers, cookies, or custom logic to ensure different requests get appropriate cached or fresh responses.
    • Extensible storage: Default in-memory store is suitable for single-server scenarios; implement IOutputCacheStore for distributed caching (Redis, etc.).

    Common follow-up questions

    • How does output caching differ from response caching? Response caching uses HTTP headers (Cache-Control, ETag) and relies on browser or intermediary caches; output caching is purely server-side with fine-grained policies and tag-based invalidation.
    • Can I use output caching with distributed systems? Yes, by implementing a custom IOutputCacheStore backed by Redis or another distributed cache, though setup is more involved than the default in-memory provider.
    • How do I cache conditional responses? Use the WithCacheableStatus() method to cache specific HTTP status codes, or add custom conditions via policy builders to cache only when business rules are met.

    Related interview questions

    Further reading

    .NETCsharpaspnetcore
  137. 137What is the difference between IMemoryCache and IDistributedCache?

    Experienced

    IMemoryCache stores objects in-process memory within a single application instance and offers the fastest access, but data is not shared across multiple server instances. IDistributedCache stores serialized data in an external store (Redis, SQL Server, or other backends) and is accessible across all instances in a distributed system, but with higher latency and serialization overhead.

    How it works

    IMemoryCache is an in-process cache that lives in your application's memory. When you store an object, it remains in RAM for the lifetime of that application instance. Retrieval is extremely fast because there's no network round-trip or deserialization. However, if your application restarts or if you're running multiple instances behind a load balancer, each instance has its own separate cache. This means cached data on Server A is invisible to Server B.

    IDistributedCache abstracts over a remote cache store. The most common implementation uses Redis, but it can also use SQL Server, NCache, or custom implementations. When you store data, it's serialized (usually to JSON or binary) and sent over the network to the external store. All application instances connect to the same backend, so they share the same cached data. This makes it ideal for scaling horizontally, but introduces network latency and serialization costs.

    Aspect IMemoryCache IDistributedCache
    Storage Location In-process memory (RAM) External remote store (Redis, SQL, etc.)
    Performance Fastest; no network or serialization Slower; includes network I/O and deserialization
    Data Sharing Not shared; each instance has its own cache Shared across all application instances
    Scalability Poor for multi-instance deployments Excellent for distributed/scaled systems
    Memory Usage Consumes application process memory Offloaded to external service
    Persistence Lost on application restart Can survive application restarts (depends on backend)
    What is the difference between IMemoryCache and IDistributedCache diagram

    Example

    csharp
    // IMemoryCache - single instance only
    public class UserService
    {
        private readonly IMemoryCache _memoryCache;
    
        public UserService(IMemoryCache memoryCache)
        {
            _memoryCache = memoryCache;
        }
    
        public async Task<User> GetUserAsync(int userId)
        {
            string key = $"user_{userId}";
            
            if (_memoryCache.TryGetValue(key, out User cachedUser))
                return cachedUser;
    
            var user = await FetchUserFromDb(userId);
            _memoryCache.Set(key, user, TimeSpan.FromHours(1));
            return user;
        }
    }
    
    // IDistributedCache - shared across instances
    public class DistributedUserService
    {
        private readonly IDistributedCache _distributedCache;
    
        public DistributedUserService(IDistributedCache distributedCache)
        {
            _distributedCache = distributedCache;
        }
    
        public async Task<User> GetUserAsync(int userId)
        {
            string key = $"user_{userId}";
            var json = await _distributedCache.GetStringAsync(key);
            
            if (json != null)
                return JsonSerializer.Deserialize<User>(json);
    
            var user = await FetchUserFromDb(userId);
            await _distributedCache.SetStringAsync(key, 
                JsonSerializer.Serialize(user), 
                new DistributedCacheEntryOptions 
                { 
                    AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1) 
                });
            return user;
        }
    }
    

    The first example caches in-process and is only visible to that server instance. The second serializes to a distributed cache backend that all instances can access.

    Key points to mention

    • Use IMemoryCache for single-server applications or for very fast, instance-local caching of non-critical data (like calculated UI state).
    • Use IDistributedCache in multi-instance deployments or when cache data must survive application restarts.
    • IDistributedCache requires external infrastructure (Redis, SQL Server) and adds network latency; don't over-engineer single-instance apps.
    • IMemoryCache can cause cache consistency issues in load-balanced scenarios if different instances have stale or different cached values.
    • You can layer both: use IDistributedCache for the authoritative store and IMemoryCache as a local L1 cache in front of it.

    Common follow-up questions

    • How would you implement cache invalidation across multiple instances? (Answer: use a distributed cache event or pub/sub pattern with Redis; IMemoryCache has no cross-instance invalidation mechanism.)
    • When would you combine both IMemoryCache and IDistributedCache? (Answer: two-tier caching—IDistributedCache as the authoritative shared store, IMemoryCache as a local L1 cache for ultra-fast access to frequently requested items.)
    • What happens to IMemoryCache during a deployment or restart? (Answer: it's completely lost; use IDistributedCache if you need cache to survive application lifecycle events.)

    Related interview questions

    Further reading

    What is the difference between IMemoryCache and IDistributedCache?
    .NETCsharpPerformance
  138. 138What is HybridCache in .NET 9?

    Experienced

    HybridCache is a new caching abstraction introduced in .NET 9 that intelligently combines in-memory and distributed caching layers into a single, unified API. It automatically handles cache stampede protection by ensuring that when multiple concurrent requests miss the cache for the same key, the data factory function executes only once, with other requests waiting for that single result.

    How it works

    HybridCache operates as a two-tier caching system. First, it checks the in-memory cache (L1), which is extremely fast. If the data isn't found, it checks the distributed cache (L2, such as Redis), which is shared across multiple instances. If both miss, the factory function runs once, and the result is cached in both layers simultaneously.

    The stampede protection works through coordinated locking. When multiple threads or processes request the same missing key concurrently, HybridCache uses a distributed lock mechanism to ensure only one factory execution occurs. Other concurrent requests block briefly and receive the result from that single execution, rather than each spawning their own database query or computation.

    HybridCache integrates seamlessly with IDistributedCache implementations and provides sensible defaults while allowing fine-grained control over timeouts, serialization, and tag-based invalidation. It's designed to reduce latency and database load in modern distributed applications where you need both speed (in-process) and consistency (distributed).

    Example

    csharp
    
    public class ProductService
    {
        private readonly HybridCache _cache;
        private readonly IProductRepository _repo;
    
        public ProductService(HybridCache cache, IProductRepository repo)
        {
            _cache = cache;
            _repo = repo;
        }
    
        public async Task<Product> GetProductAsync(int id, CancellationToken ct = default)
        {
            // HybridCache checks L1 (in-memory), then L2 (distributed).
            // If both miss, runs factory once, caches in both layers.
            return await _cache.GetOrCreateAsync(
                $"product-{id}",
                async cancel => await _repo.GetProductByIdAsync(id, cancel),
                options: new HybridCacheEntryOptions 
                { 
                    LocalCacheExpiration = TimeSpan.FromMinutes(5),
                    DistributedCacheExpiration = TimeSpan.FromHours(1)
                },
                token: ct
            );
        }
    }
    

    This code retrieves a product from the database only once even if ten concurrent requests arrive for the same missing product ID, with all receiving the result from a single query execution.

    Key points to mention

    • Stampede protection: Prevents the cache stampede problem by serializing factory execution for concurrent misses on the same key.
    • Dual-layer caching: Combines fast in-memory (L1) with distributed (L2) storage, optimizing both latency and consistency across instances.
    • Unified API: Single abstraction replaces the need to manually manage both IMemoryCache and IDistributedCache.
    • Flexible expiration: Allows different TTLs for in-memory versus distributed cache, adapting to your consistency and performance needs.
    • Tag-based invalidation: Supports tagging entries for efficient bulk invalidation (useful for related data dependencies).

    Common follow-up questions

    • How does HybridCache compare to using IMemoryCache and IDistributedCache separately? — HybridCache eliminates boilerplate, automatically handles stampede protection, and provides coordinated expiration across both layers, whereas manual approaches require explicit locking and synchronization logic.
    • What happens if the distributed cache is unavailable? — HybridCache gracefully falls back to in-memory cache; it doesn't fail the request. However, you lose cross-instance consistency until the distributed cache recovers.
    • Can I use HybridCache without a distributed cache? — Yes; it works with only an in-memory layer, though you lose multi-instance coherence. The factory logic remains protected against stampedes within a single process.

    Related interview questions

    Further reading

    .NETCsharpasyncPerformance
  139. 139How do you avoid cache stampedes?

    Experienced

    A cache stampede occurs when a cached entry expires and many concurrent requests simultaneously try to rebuild it, overwhelming the database or backend service. You prevent this by using locking (single-flight pattern), probabilistic early refresh with jitter, or distributed cache solutions like HybridCache in .NET 9, ensuring only one request rebuilds the cache while others wait for the result.

    How it works

    A cache stampede happens because cache expiration is a hard boundary: when a key expires, the next request sees a cache miss and starts a potentially expensive operation. If many requests arrive simultaneously at that moment, they all see the miss and all rebuild the cache independently—multiplying database load.

    Locking and single-flight patterns ensure only one goroutine (or thread) rebuilds; others block or queue on a lock and reuse the result. Probabilistic early refresh uses jitter to stagger expiration across instances, so not all requests refresh at once. HybridCache provides distributed coordination automatically. The key principle is: one cache miss should trigger one rebuild, not N rebuilds.

    Example

    csharp
    // Single-flight pattern with SemaphoreSlim
    public class CacheService
    {
        private readonly IDistributedCache _cache;
        private readonly SemaphoreSlim _lock = new(1, 1);
    
        public async Task<string> GetDataAsync(string key)
        {
            var cached = await _cache.GetStringAsync(key);
            if (cached != null)
                return cached;
    
            // Only one thread rebuilds; others wait for the result
            await _lock.WaitAsync();
            try
            {
                cached = await _cache.GetStringAsync(key);
                if (cached != null)
                    return cached;
    
                var data = await FetchFromDatabaseAsync();
                await _cache.SetStringAsync(key, data, 
                    new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) });
                return data;
            }
            finally
            {
                _lock.Release();
            }
        }
    
        private async Task<string> FetchFromDatabaseAsync() => "data from db";
    }
    

    This locks during cache misses so concurrent requests queue; only the first rebuilds, and the rest reuse its result. In distributed scenarios, use a distributed lock (Redis SETNX, database mutex) instead of SemaphoreSlim.

    Key points to mention

    • Root cause: Hard cache expiration + concurrent miss = N simultaneous rebuild attempts.
    • Locking/single-flight: Serialize cache rebuilds so one request computes, others wait and reuse.
    • Probabilistic refresh: Add jitter to TTL or refresh before expiration so not all instances refresh simultaneously.
    • Distributed lock: In multi-server deployments, use Redis, database, or Consul to coordinate—SemaphoreSlim only works per process.
    • HybridCache: .NET 9+ handles this automatically with built-in coordination and layered caching.

    Common follow-up questions

    • How do you handle distributed cache stampedes? Use a distributed lock (Redis SET with NX and EX, or SETNX) to elect one server to rebuild, or use HybridCache's built-in L1/L2 coordination.
    • What is probabilistic early refresh and how does it help? Refresh cache slightly before expiration with randomized jitter so not all servers refresh at the exact same time, spreading load.
    • When would you use HybridCache over manual locking? Always prefer HybridCache for new projects—it combines local + distributed caching with automatic stampede prevention, reducing boilerplate.

    Related interview questions

    Further reading

    .NETCsharpasyncPerformance
  140. 140How does IHttpClientFactory help?

    Experienced

    IHttpClientFactory is a factory pattern implementation that manages and pools HttpMessageHandler instances to prevent socket exhaustion and DNS caching issues, while providing a clean abstraction for dependency injection and resilience policy integration. It replaces the anti-pattern of creating new HttpClient instances per request, ensuring efficient resource use and proper lifetime management across your application.

    How it works

    When you register IHttpClientFactory via AddHttpClient(), it creates a pool of reusable HttpMessageHandler objects. Each handler manages a connection pool to remote endpoints, reducing the overhead of establishing new TCP connections and DNS lookups. The factory creates new HttpClient instances on demand, but these are cheap wrappers around the pooled handlers—the expensive part (sockets, DNS, connection state) is shared.

    The factory supports three patterns: default clients, named clients, and typed clients. Named clients allow you to configure multiple HttpClient instances with different base addresses or timeout policies. Typed clients go further by wrapping IHttpClientFactory in strongly-typed classes injected directly into your services, encapsulating API logic and promoting testability.

    IHttpClientFactory integrates seamlessly with resilience libraries like Polly, allowing you to attach retry policies, circuit breakers, and timeout handlers at registration time. This ensures every request using that client factory benefits from consistent resilience behavior without scattered try-catch logic.

    Example

    csharp
    // Startup/Program.cs
    services.AddHttpClient<GitHubApiClient>()
        .ConfigureHttpClient(client =>
        {
            client.BaseAddress = new Uri("https://api.github.com");
            client.DefaultRequestHeaders.Add("User-Agent", "MyApp");
        })
        .AddPolicyHandler(GetRetryPolicy());
    
    // GitHubApiClient.cs
    public class GitHubApiClient
    {
        private readonly HttpClient _httpClient;
    
        public GitHubApiClient(HttpClient httpClient)
        {
            _httpClient = httpClient;
        }
    
        public async Task<UserData> GetUserAsync(string username)
        {
            var response = await _httpClient.GetAsync($"/users/{username}");
            response.EnsureSuccessStatusCode();
            return await response.Content.ReadAsAsync<UserData>();
        }
    }
    
    // In your controller/service
    public class MyService
    {
        private readonly GitHubApiClient _client;
        public MyService(GitHubApiClient client) => _client = client;
        
        public async Task DoWork() => await _client.GetUserAsync("octocat");
    }
    
    static IAsyncPolicy<HttpResponseMessage> GetRetryPolicy() =>
        HttpPolicyExtensions
            .HandleTransientHttpError()
            .WaitAndRetryAsync(3, retryAttempt =>
                TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));
    

    This example registers a typed client for GitHub API calls with automatic retry logic and a shared base address, so every injected instance reuses the same connection pool and resilience behavior.

    Key points to mention

    • Prevents socket exhaustion: Pooled handlers maintain persistent connections rather than opening a new socket per request, avoiding "too many open files" errors under load.
    • Solves DNS staleness: By keeping connections alive, stale DNS cache in HttpClientHandler is refreshed periodically rather than persisting indefinitely.
    • Three usage patterns: default factory, named clients (multiple configs), and typed clients (strongly-typed wrappers with injected logic).
    • Polly integration: Decorates clients with resilience policies (retry, circuit break, timeout) at registration, applied transparently to all requests.
    • Lifetime control: Factory manages handler lifetime; you inject cheap HttpClient instances without worrying about disposal.

    Common follow-up questions

    • "Should HttpClient ever be disposed?" – No; the factory handles disposal. If you inject HttpClient directly (via the factory), treat it as long-lived and reusable.
    • "How do you test a typed client?" – Mock HttpClientFactory or inject a mock HttpClient in unit tests; use HttpClientFactory.CreateClient() in integration tests.
    • "What's the difference between named and typed clients?" – Named clients are retrieved by string key; typed clients are specific classes injected directly, offering better type safety and encapsulation.

    Related interview questions

    .NETCsharpPerformance
  141. 141How do you add resilience to HttpClient calls?

    Experienced

    Add resilience to HttpClient calls using Microsoft.Extensions.Http.Resilience with AddStandardResilienceHandler, which provides out-of-the-box retry policies, timeouts, circuit breakers, and rate limiting—all built on Polly. This is the modern, recommended approach in .NET 8+ that eliminates manual Polly configuration for typical scenarios.

    How it works

    Microsoft.Extensions.Http.Resilience is a NuGet package that wraps Polly policies and integrates them directly into the HttpClient pipeline via message handlers. When you add AddStandardResilienceHandler() to your HttpClientBuilder, it automatically applies a preconfigured set of policies:

    • Retry policy: Retries transient failures (5xx, 408 timeouts) using exponential backoff with jitter.
    • Timeout policy: Enforces request and total timeouts to prevent hanging requests.
    • Circuit breaker: Opens after consecutive failures to fast-fail and protect downstream services.
    • Rate limiter: Throttles outbound requests to respect API limits and backpressure.

    These policies compose into a pipeline where each request flows through retry → timeout → circuit breaker → rate limiter logic. You can also customize the handler's configuration to adjust thresholds, retry counts, and timeout durations to match your service's SLAs.

    Example

    csharp
    using Microsoft.Extensions.DependencyInjection;
    using Microsoft.Extensions.Http.Resilience;
    
    var services = new ServiceCollection();
    
    // Add HttpClient with standard resilience
    services.AddHttpClient<MyApiClient>()
        .AddStandardResilienceHandler(options =>
        {
            options.Retry.MaxRetryAttempts = 3;
            options.CircuitBreaker.FailureThreshold = 0.5; // Open after 50% failure rate
            options.TotalRequestTimeout = TimeSpan.FromSeconds(30);
            options.AttemptTimeout = TimeSpan.FromSeconds(10);
        });
    
    var provider = services.BuildServiceProvider();
    var client = provider.GetRequiredService<MyApiClient>();
    
    public class MyApiClient
    {
        private readonly HttpClient _httpClient;
    
        public MyApiClient(HttpClient httpClient)
        {
            _httpClient = httpClient;
        }
    
        public async Task<string> GetDataAsync(string id)
        {
            var response = await _httpClient.GetAsync($"https://api.example.com/data/{id}");
            response.EnsureSuccessStatusCode();
            return await response.Content.ReadAsStringAsync();
        }
    }
    

    This code configures an HttpClient with standard resilience policies—retry attempts, timeout limits, and circuit breaker thresholds are applied transparently to all requests made through _httpClient.

    Key points to mention

    • AddStandardResilienceHandler is the modern, recommended approach over manually composing Polly policies for typical scenarios.
    • Policies are applied in order: retry → timeout → circuit breaker → rate limiter; transient failures like 5xx and 408 are retried automatically.
    • Configuration is optional; the handler provides sensible defaults that work for most services without customization.
    • The circuit breaker prevents cascading failures by failing fast once a threshold of failures is reached, protecting upstream services.
    • When designing resilient service-to-service communication, combine this with proper error handling, fallbacks, and monitoring.

    Common follow-up questions

    • What happens when the circuit breaker opens? — Once open, the circuit breaker immediately rejects requests without calling the endpoint for a configured timeout period (usually 30 seconds), allowing the downstream service to recover. After that period, it transitions to half-open and tests one request; if successful, it closes again.
    • How does this differ from manually using Polly? — AddStandardResilienceHandler provides preconfigured, composable policies out-of-the-box, reducing boilerplate. Manual Polly gives finer control but requires more code and understanding of policy composition.
    • Can I implement custom resilience logic? — Yes; you can add custom handlers or policies by chaining .AddTransientHttpErrorPolicy() or .AddPolicyHandler() after AddStandardResilienceHandler, or use rate limiting in .NET 8 for more advanced scenarios.

    Related interview questions

    Further reading

    .NETCsharp
  142. 142How do you implement health checks?

    Experienced

    Health checks in ASP.NET Core are implemented using the AddHealthChecks() extension, which registers custom checks for critical dependencies like databases, caches, and external services. These checks are then mapped to separate liveness and readiness endpoints that orchestrators like Kubernetes use to monitor application health and make deployment decisions.

    How it works

    Health checks provide a standardized way to communicate application state to infrastructure automation tools. The pattern involves three layers:

    Registration: You call AddHealthChecks() in the DI container and add individual checks using fluent methods like AddDbContextCheck(), AddCheck(), or custom implementations of IHealthCheck.

    Checks: Each check returns a HealthCheckResult indicating Healthy, Degraded, or Unhealthy. Checks run independently and report on specific components—a database query, Redis connection, or an HTTP call to a dependency.

    Endpoints: Liveness checks verify the process is running (should be fast, minimal I/O). Readiness checks confirm the app is ready to handle traffic (may query dependencies). Kubernetes uses these separately: liveness restarts containers, readiness routes traffic.

    You map health check endpoints via MapHealthChecks(), typically as /health/live and /health/ready. Each endpoint can execute a different subset of checks and return JSON or simple status codes.

    Example

    csharp
    
    public class Program
    {
        public static void Main(string[] args)
        {
            var builder = WebApplication.CreateBuilder(args);
    
            builder.Services.AddHealthChecks()
                .AddDbContextCheck<AppDbContext>(
                    tags: new[] { "ready" })
                .AddCheck("DatabaseSeeding", 
                    new DatabaseSeedingHealthCheck(), 
                    tags: new[] { "ready" })
                .AddRedis(
                    builder.Configuration.GetConnectionString("Redis"),
                    tags: new[] { "ready" })
                .AddCheck("SelfHealthy", 
                    new AlwaysHealthyCheck(), 
                    tags: new[] { "live" });
    
            var app = builder.Build();
    
            app.MapHealthChecks("/health/live", new HealthCheckOptions
            {
                Predicate = reg => reg.Tags.Contains("live"),
                ResponseWriter = WriteResponse
            });
    
            app.MapHealthChecks("/health/ready", new HealthCheckOptions
            {
                Predicate = reg => reg.Tags.Contains("ready"),
                ResponseWriter = WriteResponse
            });
    
            app.Run();
        }
    
        private static Task WriteResponse(HttpContext ctx, HealthReport report)
        {
            ctx.Response.ContentType = "application/json";
            return ctx.Response.WriteAsJsonAsync(new
            {
                status = report.Status.ToString(),
                checks = report.Entries.Select(e => new
                {
                    name = e.Key,
                    status = e.Value.Status.ToString(),
                    description = e.Value.Description
                })
            });
        }
    }
    
    public class DatabaseSeedingHealthCheck : IHealthCheck
    {
        private readonly AppDbContext _db;
    
        public DatabaseSeedingHealthCheck(AppDbContext db) => _db = db;
    
        public async Task<HealthCheckResult> CheckHealthAsync(
            HealthCheckContext context, CancellationToken ct)
        {
            var count = await _db.Users.CountAsync(ct);
            return count > 0
                ? HealthCheckResult.Healthy("Database seeded")
                : HealthCheckResult.Unhealthy("No seed data found");
        }
    }
    
    public class AlwaysHealthyCheck : IHealthCheck
    {
        public Task<HealthCheckResult> CheckHealthAsync(
            HealthCheckContext context, CancellationToken ct)
        {
            return Task.FromResult(HealthCheckResult.Healthy());
        }
    }
    

    This example registers database, Redis, and custom checks with tags, then maps separate endpoints that filter by tag—liveness runs only fast checks, readiness validates dependencies before accepting traffic.

    Key points to mention

    • Tags separate concerns: Use tags to group checks by purpose (liveness vs. readiness), so each endpoint executes only relevant checks and responds appropriately.
    • Custom IHealthCheck: Implement IHealthCheck for domain logic—verify seed data, check external APIs, validate feature flags—allowing fine-grained control over what "healthy" means.
    • Timeouts prevent hangs: Always set timeouts on checks to avoid blocking the orchestrator; if a database query hangs, the health check should fail fast rather than block Kubernetes probes.
    • ResponseWriter customization: Use HealthCheckOptions.ResponseWriter to format JSON, include additional metadata, or log failures for monitoring systems like Prometheus.
    • Production integration: Health checks work seamlessly with container orchestrators and CI/CD pipelines; Kubernetes uses them for pod lifecycle decisions, and monitoring tools scrape endpoints for alerting.

    Common follow-up questions

    • What is the difference between liveness and readiness probes, and when would each fail? (Readiness can fail on transient dependency issues; liveness should only fail if the process itself is corrupted.)
    • How do you test health checks in unit tests? (Mock IHealthCheck, inject test doubles, and assert on HealthCheckResult status and description.)
    • Can health checks affect performance? (Yes—if checks perform expensive I/O and run frequently, they can strain databases; keep them lightweight or increase probe intervals in Kubernetes configuration.)

    Related interview questions

    Further reading

    .NETCsharpaspnetcoreefcore
  143. 143What is a BackgroundService?

    Experienced

    BackgroundService is an abstract base class in the Microsoft.Extensions.Hosting namespace that simplifies building long-running hosted services in .NET. It implements IHostedService and handles the lifecycle management (start, stop, dispose) automatically, requiring you only to override ExecuteAsync and implement your background work while respecting the cancellation token.

    How it works

    BackgroundService wraps the complexity of IHostedService by providing default implementations of StartAsync and StopAsync. When the host starts, it calls StartAsync, which launches ExecuteAsync on a background thread pool thread. The service runs until either the host shuts down or an exception occurs. The cancellation token passed to ExecuteAsync signals shutdown, allowing graceful termination of your background work.

    The key pattern is that ExecuteAsync should be a long-running operation—typically an infinite loop checking the cancellation token. When the token is cancelled, your loop exits, the task completes, and StopAsync returns. This design ensures the host can shut down cleanly without killing threads abruptly.

    BackgroundService integrates seamlessly with dependency injection and the generic host builder, making it the standard choice for background tasks like message queues, timers, file watchers, and polling operations.

    Example

    csharp
    using Microsoft.Extensions.Hosting;
    using Microsoft.Extensions.Logging;
    
    public class EmailQueueService : BackgroundService
    {
        private readonly ILogger<EmailQueueService> _logger;
        private readonly IEmailQueue _queue;
    
        public EmailQueueService(ILogger<EmailQueueService> logger, IEmailQueue queue)
        {
            _logger = logger;
            _queue = queue;
        }
    
        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
        {
            _logger.LogInformation("Email queue service starting.");
    
            try
            {
                while (!stoppingToken.IsCancellationRequested)
                {
                    var email = await _queue.DequeueAsync(stoppingToken);
                    if (email != null)
                    {
                        await SendEmailAsync(email, stoppingToken);
                    }
                    else
                    {
                        await Task.Delay(1000, stoppingToken);
                    }
                }
            }
            catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
            {
                _logger.LogInformation("Email queue service stopping.");
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Unhandled exception in email queue service.");
                throw;
            }
        }
    
        private async Task SendEmailAsync(Email email, CancellationToken ct)
        {
            // Send email logic
            await Task.CompletedTask;
        }
    }
    
    // Register in Program.cs
    var host = Host.CreateDefaultBuilder(args)
        .ConfigureServices(services => services.AddHostedService<EmailQueueService>())
        .Build();
    
    await host.RunAsync();

    This example shows a background service that continuously dequeues emails from a queue and sends them, respecting the cancellation token for clean shutdown and catching exceptions to prevent host termination.

    Key points to mention

    • Override ExecuteAsync: This is where your long-running work happens; it receives a CancellationToken that signals shutdown.
    • Honor the cancellation token: Always check stoppingToken.IsCancellationRequested and pass the token to async operations to enable graceful shutdown.
    • Handle exceptions carefully: Unhandled exceptions terminate the host; catch and log them, or rethrow if intentional.
    • Dependency injection friendly: Constructor injection works naturally; register with AddHostedService<T>().
    • No threading complexity: BackgroundService runs on the thread pool; you don't manage threads directly.

    Common follow-up questions

    • How do I ensure my background service shuts down gracefully? (See how do you implement graceful shutdown?)
    • What's the difference between BackgroundService and IHostedService?
    • How do I unit test a BackgroundService?

    Related interview questions

    Further reading

    .NETCsharpasyncSecurity
  144. 144How do you implement graceful shutdown?

    Experienced

    Graceful shutdown means allowing your application to finish in-flight requests, clean up resources, and complete background work before the process terminates. You implement it by respecting CancellationToken in hosted services, configuring HostOptions.ShutdownTimeout, and letting Kestrel drain active connections before exit.

    How it works

    When a shutdown signal arrives (SIGTERM on Linux, Ctrl+C, or container stop), the .NET host orchestrates a multi-phase shutdown:

    1. Signal reception: The host receives a termination signal and begins the shutdown sequence.
    2. StopAsync phase: All IHostedService implementations have their StopAsync method called with a cancellation token.
    3. Request draining: Kestrel stops accepting new requests but allows existing ones to complete within the timeout window.
    4. Resource cleanup: Services dispose of connections, flush buffers, and release locks.
    5. Timeout enforcement: If shutdown takes longer than ShutdownTimeout, the process forcibly terminates.

    The CancellationToken signals to long-running loops that they should exit gracefully rather than abruptly. HostOptions.ShutdownTimeout (default 5 seconds) provides a hard deadline; exceeding it results in immediate termination.

    Example

    csharp
    
    public class GracefulBackgroundService : BackgroundService
    {
        private readonly ILogger<GracefulBackgroundService> _logger;
    
        public GracefulBackgroundService(ILogger<GracefulBackgroundService> logger)
        {
            _logger = logger;
        }
    
        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
        {
            try
            {
                while (!stoppingToken.IsCancellationRequested)
                {
                    _logger.LogInformation("Processing work...");
                    await Task.Delay(1000, stoppingToken);
                }
            }
            catch (OperationCanceledException)
            {
                _logger.LogInformation("Graceful shutdown initiated");
            }
        }
    
        public override async Task StopAsync(CancellationToken cancellationToken)
        {
            _logger.LogInformation("StopAsync called, cleaning up resources...");
            await Task.Delay(500, cancellationToken); // Simulate cleanup
            await base.StopAsync(cancellationToken);
        }
    }
    
    // In Program.cs
    var builder = Host.CreateDefaultBuilder(args);
    
    builder.ConfigureServices(services =>
    {
        services.AddHostedService<GracefulBackgroundService>();
        services.Configure<HostOptions>(opts =>
        {
            opts.ShutdownTimeout = TimeSpan.FromSeconds(10);
        });
    });
    
    var host = builder.Build();
    await host.RunAsync();
    

    This example shows a BackgroundService that respects the cancellation token in its loop, implements StopAsync for cleanup, and configures a 10-second shutdown timeout at the host level.

    Key points to mention

    • Always check CancellationToken: Use stoppingToken.IsCancellationRequested in loops or pass it to async operations so they terminate promptly.
    • Implement StopAsync: Hosted services must override StopAsync to perform synchronous resource cleanup (closing database connections, committing transactions) before the timeout expires.
    • Set appropriate timeout: Balance between safety (longer timeout) and responsiveness (shorter timeout); container orchestrators (Kubernetes, Docker) often have their own kill timeouts that must align with your shutdown timeout.
    • Avoid blocking indefinitely: Long-running operations in StopAsync can prevent graceful shutdown; use the cancellation token and respect the deadline.
    • Kestrel drains naturally: With proper configuration, Kestrel automatically closes the listener and drains active requests; tuning Kestrel for production includes setting Limits.KeepAliveTimeout appropriately.

    Common follow-up questions

    • What happens if StopAsync exceeds the shutdown timeout? The host forcibly terminates the process, potentially losing in-flight work. This is why you must respect the cancellation token and avoid indefinite waits.
    • How do you test graceful shutdown? Send SIGTERM, Ctrl+C, or use host.StopAsync() programmatically in tests; verify logs show cleanup and in-flight requests complete before process exit.
    • Should I use a saga pattern for distributed transactions during shutdown? Yes, if your shutdown involves coordinating cleanup across multiple services; a saga pattern ensures transactional consistency even if a step fails.

    Related interview questions

    .NETCsharpaspnetcoreSecurity
  145. 145What is the difference between ConfigureAwait(false) and default awaiting?

    Experienced

    ConfigureAwait(false) tells the await expression to not capture and resume on the original synchronization context. The default behavior (no ConfigureAwait or ConfigureAwait(true)) captures the current context and resumes the continuation on that same context after the awaited task completes. In ASP.NET Core, this distinction matters less because there is no synchronization context by default, but it remains crucial in libraries, UI applications (WinForms, WPF), and anywhere a SynchronizationContext is active.

    How it works

    When you await a task, the runtime checks if a SynchronizationContext exists. If one does, the continuation (code after the await) is queued back to that context. This ensures thread affinity and is essential for UI frameworks that require all UI updates to happen on the UI thread.

    ConfigureAwait(false) bypasses this mechanism. It tells the runtime: "I don't need to resume on the original context; use any available thread." This eliminates the overhead of context switching and prevents potential deadlocks caused by context capture.

    In ASP.NET Core, the default SynchronizationContext is null, so ConfigureAwait(false) has minimal performance impact. However, in library code that may be consumed by Windows Forms or WPF applications, or in console apps where a custom context might be set, ConfigureAwait(false) is a best practice to avoid unnecessarily blocking the UI thread.

    Example

    csharp
    // Library method – should use ConfigureAwait(false)
    public async Task<string> FetchDataAsync()
    {
        using var client = new HttpClient();
        var response = await client.GetAsync("https://api.example.com/data")
            .ConfigureAwait(false);
        
        var content = await response.Content.ReadAsStringAsync()
            .ConfigureAwait(false);
        
        return content;
    }
    
    // UI application – may omit ConfigureAwait(false) if UI updates needed
    public async void OnButtonClick()
    {
        var data = await FetchDataAsync(); // default: captures UI context
        MessageBox.Show(data); // safe to update UI
    }
    
    // ASP.NET Core – ConfigureAwait(false) is optional but recommended
    public async Task<IActionResult> GetDataAsync()
    {
        var data = await FetchDataAsync().ConfigureAwait(false);
        return Ok(data);
    }

    This example shows a reusable library method that uses ConfigureAwait(false) throughout, making it safe for any caller. The UI application omits it to maintain thread affinity for UI updates, and ASP.NET Core includes it as a best practice even though the context is typically null.

    Aspect Default await (no ConfigureAwait) ConfigureAwait(false)
    Context Capture Captures current SynchronizationContext if present Does not capture context
    Continuation Location Resumes on the same context thread Resumes on any available thread from thread pool
    Performance Overhead Context switch cost (if context exists) Minimal; avoids marshalling
    UI Safety Safe for UI updates (maintains thread affinity) Not safe for direct UI updates unless marshalled back
    Best Used In UI applications (WinForms, WPF), code that updates UI Libraries, service layers, ASP.NET Core, background tasks
    Deadlock Risk Higher if context is saturated and caller blocks Lower; no context dependency
    What is the difference between ConfigureAwait(false) and default awaiting diagram

    Key points to mention

    • ConfigureAwait(false) is a best practice in library code and utility methods to avoid forcing callers into a specific thread context.
    • ASP.NET Core has no SynchronizationContext by default, so ConfigureAwait(false) has minimal performance benefit but is still recommended for consistency and portability.
    • Forgetting ConfigureAwait(false) in libraries can cause deadlocks if a synchronous caller blocks waiting for an async operation on a limited-capacity context (classic example: blocking on async in console apps with a custom context).
    • UI frameworks (WinForms, WPF) rely on the default behavior to ensure UI updates occur on the UI thread; omitting ConfigureAwait(false) is correct for UI code.
    • In modern .NET, consider using CancellationToken alongside ConfigureAwait for robust async patterns.

    Common follow-up questions

    • Can ConfigureAwait(false) cause a deadlock? No; it actually prevents deadlocks by avoiding context starvation. However, the opposite (default await in saturated contexts) can cause deadlocks.
    • Should ASP.NET Core code always use ConfigureAwait(false)? While not strictly necessary (null context), it is still recommended as a defensive practice and for consistency when code may be reused in libraries.
    • How do I handle UI updates after ConfigureAwait(false)? Use Dispatcher.Invoke() or Control.Invoke() to marshal the update back to the UI thread if needed.

    Related interview questions

    Further reading

    What is the difference between ConfigureAwait(false) and default awaiting?
    .NETCsharpasync
  146. 146What is a CancellationToken and why pass it everywhere?

    Experienced

    A CancellationToken is a cooperative signaling mechanism that tells long-running operations to stop gracefully. It's passed through method chains to propagate cancellation requests—for example, when an HTTP client disconnects or a timeout occurs. Passing it everywhere ensures that database queries, API calls, and background work respect the cancellation signal and free resources immediately rather than completing unnecessarily.

    How it works

    CancellationToken operates on a cooperative model: it doesn't forcibly stop threads, but signals that cancellation has been requested. Methods that accept a token periodically check token.IsCancellationRequested or wait for the token to be signaled. When CancellationTokenSource.Cancel() is called, all tokens derived from it enter a cancelled state.

    In ASP.NET Core, HttpContext.RequestAborted is automatically signaled when the client disconnects or the request times out. By threading this token through your entire call stack—into database queries, external API calls, and business logic—you ensure the entire operation stops when the client is no longer waiting. This prevents wasted CPU, database connections, and network I/O.

    The token itself is lightweight and can be passed by value. You create tokens via CancellationTokenSource, which provides the Cancel() method to trigger cancellation and yields a CancellationToken to consumers. This separation allows callers to cancel without exposing control to every layer of your code.

    Example

    csharp
    // ASP.NET Core controller method
    [HttpPost("process")]
    public async Task<IActionResult> ProcessData(
        [FromBody] DataRequest request,
        CancellationToken cancellationToken)
    {
        try
        {
            // Pass the token to your service layer
            var result = await _dataService.ProcessAsync(request, cancellationToken);
            return Ok(result);
        }
        catch (OperationCanceledException)
        {
            // Client disconnected or request timed out
            return StatusCode(StatusCodes.Status499ClientClosedRequest);
        }
    }
    
    // Service layer passes token to database
    public async Task<ProcessResult> ProcessAsync(
        DataRequest request,
        CancellationToken cancellationToken)
    {
        // Entity Framework respects CancellationToken
        var data = await _context.Users
            .AsNoTracking()
            .Where(u => u.Active)
            .ToListAsync(cancellationToken);
    
        // External API call also respects the token
        var enriched = await _httpClient.PostAsync(
            "https://api.example.com/enrich",
            new StringContent(JsonSerializer.Serialize(data)),
            cancellationToken);
    
        return new ProcessResult { Count = data.Count };
    }
    

    This example shows how HttpContext.RequestAborted flows from the controller through the service layer, stopping both the database query and HTTP call if the client disconnects before either completes.

    Key points to mention

    • Cooperative, not forceful: A cancelled token signals intent; operations must check it or support it natively (as EF Core and HttpClient do).
    • Prevents resource waste: Stopping unnecessary work frees database connections, memory, and network bandwidth, improving server responsiveness under load.
    • ASP.NET Core integration: HttpContext.RequestAborted is automatically cancelled on client disconnect or timeout; pass it everywhere to propagate that signal.
    • Proper exception handling: When cancelled, async methods raise OperationCanceledException; catch it and return an appropriate HTTP status (e.g., 499 Client Closed Request).
    • Performance at scale: In high-traffic applications, propagating cancellation prevents cascading resource exhaustion and improves tail latencies.

    Common follow-up questions

    Related interview questions

    Further reading

    .NETCsharpSecurity
  147. 147What is ValueTask and when should you use it?

    Experienced

    ValueTask is a struct that represents an asynchronous operation result, designed to avoid heap allocation when the result is already available synchronously. Use it in performance-critical paths where you expect synchronous completion most of the time, but must never await the same ValueTask instance twice.

    How it works

    ValueTask<T> is a discriminated union that can hold either a synchronous result (T) or a Task<T>. When an async method completes synchronously, no Task object is allocated on the heap—the result is stored directly in the struct. This eliminates garbage collection pressure in hot paths.

    The runtime determines whether to allocate a Task based on the operation's state. If the operation completes before the first await, the ValueTask stores the result directly. If the operation genuinely requires asynchronous work, a Task is allocated and wrapped. The caller's code remains unchanged—they simply await the ValueTask, and the await expression unwraps either the synchronous result or waits for the underlying Task.

    This makes ValueTask ideal for scenarios like cached data lookups, in-memory queries, or operations that frequently complete immediately. However, ValueTask comes with strict constraints: you cannot await it multiple times, pass it to multiple consumers, or store it for later use. These restrictions exist because structs are value types—copying a ValueTask that contains a Task reference can lead to unpredictable behavior.

    Example

    csharp
    public class UserCache
    {
        private readonly Dictionary<int, User> _cache = new();
    
        // Returns ValueTask to avoid allocation when user is cached
        public ValueTask<User> GetUserAsync(int id)
        {
            if (_cache.TryGetValue(id, out var cachedUser))
            {
                // Synchronous path: no Task allocation
                return new ValueTask<User>(cachedUser);
            }
    
            // Asynchronous path: allocates Task only when needed
            return new ValueTask<User>(FetchFromDatabaseAsync(id));
        }
    
        private async Task<User> FetchFromDatabaseAsync(int id)
        {
            await Task.Delay(100); // Simulate DB call
            return new User { Id = id, Name = "John" };
        }
    }
    
    // Usage
    var cache = new UserCache();
    var user = await cache.GetUserAsync(1); // First call: allocates Task
    var cachedUser = await cache.GetUserAsync(1); // Second call: no allocation
    

    This example shows a cache lookup that returns synchronously most of the time (no Task allocation) but falls back to async database access when needed. The caller's code is identical whether the operation completes synchronously or asynchronously.

    Key points to mention

    • Allocation avoidance: ValueTask eliminates heap allocation for synchronous completions, reducing GC pressure in performance-critical code.
    • Single await only: Never await the same ValueTask instance twice or pass it to multiple consumers; doing so causes undefined behavior because structs are copied by value.
    • Expect synchronous completion: Use ValueTask only when synchronous completion is common; if operations are mostly asynchronous, a regular Task is clearer and more efficient.
    • Compiler support: Modern async state machines (C# 7.1+) are optimized for ValueTask, making it safe and efficient when used correctly.
    • Not a general-purpose replacement: ValueTask is not a drop-in replacement for Task; its constraints make it suitable only for specific scenarios like caching, validation, or in-memory operations.

    Common follow-up questions

    • When should I use ValueTask vs. Task? Use Task for normal async operations and APIs. Use ValueTask only in hot paths where synchronous completion is frequent (typically >80% of the time) and you need maximum performance. Most application code should use Task.
    • What happens if I await a ValueTask twice? The behavior is undefined. If the ValueTask contains a synchronous result, the struct gets copied and the second await may see corrupted state. Always treat ValueTask as a single-use type.
    • Can I use ValueTask with async/await patterns? Yes, ValueTask integrates seamlessly with async/await syntax. The compiler recognizes the GetAwaiter() method and handles both synchronous and asynchronous completions transparently.

    Related interview questions

    Further reading

    .NETCsharpasync
  148. 148What is the danger of sync over async?

    Experienced

    Sync over async—calling .Result, .Wait(), or .GetAwaiter().GetResult() on a Task—blocks the current thread indefinitely while waiting for an async operation to complete. This defeats the entire purpose of async/await, causes thread pool starvation under load, and can trigger deadlocks in UI or context-bound applications where the blocked thread is needed to resume the async operation.

    How it works

    When you call .Result or .Wait() on a Task, the calling thread enters a synchronous block, yielding to the OS scheduler but not relinquishing its thread pool slot. The async operation continues on another thread or I/O completion port, but the original thread sits idle, waiting. In a high-concurrency scenario—such as an ASP.NET web server handling 100 concurrent requests—if each request blocks a thread via .Wait(), you exhaust the thread pool. New requests queue up with no threads available to process them, and throughput collapses.

    The deadlock scenario is more insidious. Many UI frameworks (WinForms, WPF) and some synchronization contexts (like WindowsFormsSynchronizationContext) enforce single-threaded execution. When you block a thread with .Result, that thread cannot execute continuations that require the UI context. The async operation waits for the context to schedule its continuation; the blocked thread waits for the result. Classic deadlock.

    Example

    csharp
    // DANGEROUS - blocks thread and can deadlock
    public string FetchDataBadly()
    {
        var task = HttpClient.GetAsync("https://api.example.com/data");
        return task.Result; // Blocks current thread indefinitely
    }
    
    // CORRECT - propagates async all the way up
    public async Task<string> FetchDataCorrectly()
    {
        var response = await HttpClient.GetAsync("https://api.example.com/data");
        return await response.Content.ReadAsStringAsync();
    }
    
    // In ASP.NET Core controller:
    public async Task<IActionResult> GetData()
    {
        var data = await FetchDataCorrectly(); // Releases thread while waiting
        return Ok(data);
    }
    

    The first method blocks the thread pool thread; the second releases it, allowing the thread to handle other work while the I/O completes.

    Key points to mention

    • .Result, .Wait(), and .GetAwaiter().GetResult() all perform blocking synchronous waits on async operations, defeating async's concurrency benefits.
    • Thread pool starvation occurs under load: blocked threads cannot process new requests, queue depth explodes, and latency skyrockets.
    • Deadlock risk is highest in UI and context-bound scenarios where the blocked thread is required to resume the continuation.
    • The solution is to stay async all the way: propagate async/await from the leaf to the caller, avoiding sync wrappers entirely.
    • If you absolutely must bridge sync and async (legacy code), use Task.Run(() => asyncMethod()).Result on a thread pool thread, never on the UI thread; even better, refactor.

    Common follow-up questions

    • When is it acceptable to call .Result? (Answer: Main method entry point in console apps, async Main is now preferred, and rarely in background tasks—but generally never in request-handling code.)
    • How do you investigate a deadlock or hang? (Answer: Dump analysis, breakpoints, Task scheduler inspection, correlation with .Result / .Wait() calls.)
    • How does the thread pool work? and how does sync over async stress it? (Answer: Limited threads; blocking threads without returning work starves the pool.)

    Related interview questions

    .NETCsharpasync
  149. 149How do you run tasks in parallel safely?

    Experienced

    Run tasks in parallel safely by using Task.WhenAll for independent I/O-bound operations, Parallel.ForEachAsync with MaxDegreeOfParallelism for bounded CPU/I/O concurrency, and strictly avoiding sharing non-thread-safe objects like DbContext across threads. Always isolate mutable state per task and use thread-safe collections or synchronization primitives when state must be shared.

    How it works

    Parallel execution in .NET requires two separate concerns: task scheduling and thread safety. Task.WhenAll schedules multiple independent tasks to run concurrently on the thread pool but doesn't control concurrency level—it's ideal for fire-and-forget I/O like HTTP requests. Parallel.ForEachAsync, by contrast, enforces a MaxDegreeOfParallelism limit, ensuring only N tasks execute simultaneously, preventing resource exhaustion. The safety problem arises because DbContext, most collections, and stateful objects aren't thread-safe. Running multiple tasks against the same DbContext instance causes corruption because EF Core's change tracker and query cache aren't guarded for concurrent access. The solution: create isolated instances per task. Use dependency injection scopes, thread-local storage, or task-local captures to ensure each parallel operation owns its own context.

    Example

    csharp
    public class OrderProcessor
    {
        private readonly IServiceProvider _services;
    
        public OrderProcessor(IServiceProvider services) => _services = services;
    
        // Unsafe: shared DbContext across tasks
        public async Task ProcessOrdersUnsafeAsync(List<int> orderIds)
        {
            using var db = _services.GetRequiredService<AppDbContext>();
            var tasks = orderIds.Select(id => db.Orders
                .Where(o => o.Id == id)
                .ExecuteUpdateAsync(s => s.SetProperty(o => o.Status, "Processing")));
            await Task.WhenAll(tasks); // RACE CONDITION: same DbContext
        }
    
        // Safe: isolated scope per task
        public async Task ProcessOrdersSafeAsync(List<int> orderIds)
        {
            await Parallel.ForEachAsync(orderIds, new ParallelOptions { MaxDegreeOfParallelism = 5 },
                async (orderId, ct) =>
                {
                    // Each task gets its own DbContext scope
                    using var scope = _services.CreateScope();
                    var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
                    await db.Orders
                        .Where(o => o.Id == orderId)
                        .ExecuteUpdateAsync(s => s.SetProperty(o => o.Status, "Processing"), ct);
                });
        }
    
        // Alternative: Task.WhenAll for independent I/O (no DbContext sharing)
        public async Task FetchOrdersAsync(List<int> orderIds)
        {
            var tasks = orderIds.Select(async id =>
            {
                using var scope = _services.CreateScope();
                var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
                return await db.Orders.FirstOrDefaultAsync(o => o.Id == id);
            });
            var orders = await Task.WhenAll(tasks);
        }
    }
    

    The unsafe version shares one DbContext across all parallel tasks, causing change-tracker corruption. The safe version creates a new scope (and DbContext) per task using Parallel.ForEachAsync, which limits concurrency to 5 operations. The third example uses Task.WhenAll for pure reads where you don't need concurrency control.

    Key points to mention

    • DbContext isolation: Always create a new DbContext (via DI scope) per parallel task; never share instances across threads. See how to manage DbContext lifetime for details.
    • Task.WhenAll vs. Parallel.ForEachAsync: Task.WhenAll has no concurrency limit and suits independent I/O; Parallel.ForEachAsync enforces MaxDegreeOfParallelism and is safer for resource-intensive operations. (Read the detailed comparison.)
    • Thread-safe collections: If you must aggregate results, use ConcurrentBag<T>, ConcurrentDictionary<K,V>, or lock/SemaphoreSlim around non-thread-safe writes.
    • CancellationToken propagation: Pass CancellationToken through Parallel.ForEachAsync to enable graceful cancellation of all tasks.
    • Exception handling: Task.WhenAll re-throws aggregate exceptions; wrap in try-catch or use .Unwrap() to handle individual task failures.

    Common follow-up questions

    • How do you choose between Task.WhenAll and Parallel.ForEachAsync? Use Task.WhenAll for unbounded independent I/O (e.g., multiple API calls). Use Parallel.ForEachAsync when you need backpressure or resource limits (e.g., database writes, limited thread pool).
    • What happens if one task in Task.WhenAll throws an exception? An AggregateException is thrown containing all exceptions from failed tasks. Other running tasks continue to completion unless you cancel them manually.
    • Is async-await alone enough for thread safety? No. Async only enables concurrency; it doesn't guard shared mutable state. You still need isolation or synchronization primitives like lock, SemaphoreSlim, or thread-safe collections.

    Related interview questions

    Further reading

    .NETCsharpasync
  150. 150What is SemaphoreSlim used for?

    Experienced

    SemaphoreSlim is a lightweight synchronization primitive that limits the number of threads or tasks that can access a protected resource concurrently. It supports both synchronous and asynchronous waits, making it ideal for throttling concurrent operations, protecting critical sections in async code, and controlling resource consumption in high-concurrency scenarios.

    How it works

    SemaphoreSlim maintains an internal counter representing available "slots." When a task calls WaitAsync(), it decrements the counter; when it calls Release(), it increments it. If the counter is zero when WaitAsync() is called, the task waits asynchronously until another task releases a slot.

    Unlike the older Semaphore class, SemaphoreSlim is designed for async/await patterns and uses less memory because it doesn't create OS-level synchronization objects. You specify an initial count (and optionally a maximum count) in the constructor. The initial count represents how many concurrent acquisitions are allowed before subsequent callers must wait.

    This makes it perfect for scenarios like rate-limiting API calls, controlling database connection pools, or ensuring only a fixed number of long-running operations execute simultaneously without blocking threads.

    Example

    csharp
    public class ApiRateLimiter
    {
        private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(initialCount: 5, maxCount: 5);
    
        public async Task<string> CallExternalApiAsync(string endpoint)
        {
            await _semaphore.WaitAsync();
            try
            {
                using var client = new HttpClient();
                return await client.GetStringAsync($"https://api.example.com{endpoint}");
            }
            finally
            {
                _semaphore.Release();
            }
        }
    }
    
    // Usage: allows only 5 concurrent API calls
    var limiter = new ApiRateLimiter();
    var tasks = Enumerable.Range(1, 20)
        .Select(i => limiter.CallExternalApiAsync("/data"))
        .ToList();
    await Task.WhenAll(tasks);

    This example limits concurrent API calls to 5, queuing additional requests asynchronously until a slot becomes available.

    Key points to mention

    • Async-first design: SemaphoreSlim integrates naturally with async/await and doesn't block threads, making it suitable for high-concurrency applications.
    • Lightweight: It doesn't rely on OS kernel objects, reducing memory overhead compared to classic Semaphore.
    • Disposable: Implement using or using declarations to ensure the semaphore is properly cleaned up, especially when cancellation tokens are involved.
    • Cancellation support: WaitAsync() accepts a CancellationToken, allowing callers to be interrupted if waiting too long.
    • Common in throttling scenarios: Frequently used to limit concurrent HTTP requests, database queries, or background job processing.

    Common follow-up questions

    • What is the difference between SemaphoreSlim and Mutex? (A semaphore allows multiple concurrent accesses; a mutex allows only one.)
    • How would you combine SemaphoreSlim with concurrent collections to build a thread-safe work queue?
    • Can you use SemaphoreSlim in a distributed system with multiple machines? (No—it's in-process; use Redis or other distributed locks for that.)

    Related interview questions

    .NETCsharpasyncSecurity
  151. 151What are Channels in .NET?

    Experienced

    Channels are high-performance, thread-safe producer-consumer queues in System.Threading.Channels that enable efficient in-process communication between async tasks. They support bounded (size-limited) or unbounded capacity, backpressure handling, and are designed for building background pipelines and concurrent processing workflows with minimal allocation and lock overhead.

    How it works

    Channels provide a publish-subscribe pattern where producers write data to a channel and consumers read from it asynchronously. Unlike BlockingCollection<T> or ConcurrentQueue<T>, Channels are built specifically for async/await and ValueTask-based APIs, making them allocation-efficient and GC-friendly.

    A Channel consists of two parts: a ChannelWriter<T> for sending data and a ChannelReader<T> for receiving it. You create a channel using Channel.CreateUnbounded<T>() or Channel.CreateBounded<T>(capacity). Bounded channels apply backpressure—if the buffer is full, WriteAsync pauses the producer until space becomes available. This prevents memory exhaustion in scenarios where producers outpace consumers.

    Consumers use ReadAsync to wait for items, or TryRead for non-blocking attempts. When no data is available, ReadAsync completes only when an item arrives or the channel is closed. Channels signal completion via TryComplete(), allowing consumers to detect end-of-stream cleanly.

    Example

    csharp
    using System.Threading.Channels;
    
    var channel = Channel.CreateBounded<int>(new BoundedChannelOptions(10) 
    { 
        FullMode = BoundedChannelFullMode.Wait 
    });
    
    // Producer
    async Task ProduceAsync()
    {
        for (int i = 0; i < 100; i++)
        {
            await channel.Writer.WriteAsync(i);
        }
        channel.Writer.TryComplete();
    }
    
    // Consumer
    async Task ConsumeAsync()
    {
        await foreach (var item in channel.Reader.ReadAllAsync())
        {
            Console.WriteLine($"Received: {item}");
            await Task.Delay(100); // Simulate work
        }
    }
    
    await Task.WhenAll(ProduceAsync(), ConsumeAsync());
    

    This example creates a bounded channel with a capacity of 10. The producer writes 100 integers, and the consumer reads and processes them with simulated delay; backpressure automatically pauses the producer when the buffer fills.

    Key points to mention

    • Allocation-efficient: Returns ValueTask instead of Task, avoiding heap allocations for already-completed operations.
    • Backpressure: Bounded channels naturally throttle fast producers, preventing unbounded memory growth and improving system stability.
    • Graceful shutdown: TryComplete() signals end-of-stream; consumers using await foreach or ReadAllAsync() exit cleanly.
    • Fair contention: Channels handle concurrent readers and writers without locks, using optimized signaling mechanisms.
    • Integration with pipelines: Commonly used in background queue patterns and work distribution systems.

    Common follow-up questions

    • How do you choose between bounded and unbounded channels, and what are the trade-offs?
    • Can multiple producers or consumers read from the same channel safely?
    • How does System.Threading.Channels compare to System.IO.Pipelines?

    Related interview questions

    Further reading

    .NETCsharpasync
  152. 152What is the difference between lock and Monitor?

    Experienced

    lock is syntactic sugar over Monitor.Enter and Monitor.Exit wrapped in a try/finally block, providing a simpler, safer way to acquire mutual exclusion on an object. Monitor gives you more control over timing and behavior, including the ability to wait, pulse, and release with explicit method calls. Starting with .NET 9, the new System.Threading.Lock type offers a dedicated, faster locking primitive that avoids boxing and provides better performance than both.

    How it works

    When you write a lock statement, the compiler transforms it into calls to Monitor.Enter (acquiring the lock) and Monitor.Exit (releasing it) inside a try/finally block. This ensures the lock is always released, even if an exception occurs.

    Monitor is a static class that provides low-level synchronization primitives. It can lock any reference type object and supports additional operations like Wait() (release the lock and wait for a signal), Pulse() (wake one waiting thread), and PulseAll() (wake all waiting threads). This makes Monitor suitable for producer-consumer patterns and more complex signaling scenarios.

    System.Threading.Lock (.NET 9+) is a value type struct that wraps a dedicated internal lock mechanism. It avoids the overhead of boxing an object reference and provides faster acquisition and release. It supports EnterScope() which returns a disposable struct for use with the using statement, combining safety with performance.

    Example

    csharp
    // Traditional lock (syntax sugar over Monitor)
    object lockObj = new object();
    lock (lockObj)
    {
        // Critical section
        Console.WriteLine("Protected by lock");
    }
    
    // Equivalent to:
    Monitor.Enter(lockObj);
    try
    {
        Console.WriteLine("Protected by Monitor");
    }
    finally
    {
        Monitor.Exit(lockObj);
    }
    
    // .NET 9+ Lock type (faster, value-type)
    System.Threading.Lock myLock = new();
    using (myLock.EnterScope())
    {
        Console.WriteLine("Protected by System.Threading.Lock");
    }
    

    The lock statement is the most common choice for straightforward mutual exclusion; Monitor gives you fine-grained control when you need Wait/Pulse signaling; the new Lock type is optimal for performance-critical code in .NET 9+.

    Key points to mention

    • lock is compiler sugar that translates to Monitor.Enter/Exit in a try/finally, guaranteeing safe release
    • Monitor provides advanced operations (Wait, Pulse, PulseAll) for thread coordination, essential for producer-consumer and condition-based scenarios
    • System.Threading.Lock (.NET 9+) is a value-type alternative that avoids boxing overhead and delivers better performance than locking objects
    • All three are reentrant on the same thread; a thread can acquire the same lock multiple times if the lock tracks ownership
    • Choosing between them depends on your needs: simplicity (lock), signaling (Monitor), or raw performance (System.Threading.Lock)
    Feature lock Monitor System.Threading.Lock
    Syntax lock (obj) { } Monitor.Enter/Exit using (lock.EnterScope()) { }
    Type Reference type required Reference type required Value type (struct)
    Wait/Pulse support No (not directly) Yes (Wait, Pulse, PulseAll) No
    Boxing overhead Yes, if not using object Yes, if not using object None (value type)
    Performance Good Good Best (dedicated, .NET 9+)
    Availability .NET Framework 1.0+ .NET Framework 1.0+ .NET 9.0+
    What is the difference between lock and Monitor diagram

    Common follow-up questions

    • What happens if you try to lock on a null reference, or lock the same object from two different threads? (Null raises an exception; same object blocks until the first thread exits; see How do you investigate a deadlock or hang?)
    • Why would you use Interlocked over lock? (Interlocked operations are atomic without acquiring a full lock, better for simple value updates.)
    • Can lock be used with async/await? (No; lock blocks threads and cannot await inside it; use SemaphoreSlim or other async-safe primitives instead.)

    Related interview questions

    Further reading

    What is the difference between lock and Monitor?
    .NETCsharpasync
  153. 153What are concurrent collections?

    Experienced

    Concurrent collections are thread-safe data structures in the System.Collections.Concurrent namespace that allow multiple threads to safely access and modify them without explicit locking. Common types include ConcurrentDictionary, ConcurrentQueue, ConcurrentBag, and ConcurrentStack, each optimized for specific access patterns and use cases.

    How it works

    Concurrent collections use fine-grained locking or lock-free algorithms internally to protect individual buckets or segments rather than locking the entire collection. For example, ConcurrentDictionary partitions its hash table into segments, allowing multiple threads to operate on different segments simultaneously without contention. This approach significantly improves throughput compared to wrapping a regular dictionary with a single lock.

    Each concurrent collection is designed for specific scenarios: ConcurrentQueue is optimized for FIFO (first-in-first-out) access with lock-free dequeuing and enqueuing; ConcurrentBag provides an unordered collection ideal for producer-consumer patterns; ConcurrentDictionary supports atomic operations like AddOrUpdate and TryRemove that combine multiple steps safely.

    These collections also provide consistent snapshots through enumerators and support operations like TryAdd, TryRemove, and TryGetValue, which return boolean values indicating success rather than throwing exceptions, making them natural for concurrent scenarios where collisions are expected.

    Example

    csharp
    var cache = new ConcurrentDictionary<string, string>();
    var tasks = Enumerable.Range(0, 10)
        .Select(i => Task.Run(() =>
        {
            cache.AddOrUpdate($"key-{i % 3}", $"value-{i}", (k, old) => $"value-{i}");
            if (cache.TryGetValue($"key-{i % 3}", out var value))
                Console.WriteLine($"Thread {Thread.CurrentThread.ManagedThreadId}: {value}");
        }))
        .ToArray();
    
    Task.WaitAll(tasks);
    Console.WriteLine($"Final count: {cache.Count}");

    This code demonstrates multiple threads safely adding, updating, and reading from a ConcurrentDictionary without explicit locks; AddOrUpdate atomically checks if a key exists and either adds or updates it, preventing race conditions.

    Key points to mention

    • Concurrent collections use internal partitioning and fine-grained locking to reduce contention compared to a single lock protecting the entire structure.
    • Operations like TryAdd, TryRemove, and AddOrUpdate are atomic, combining multiple logical steps into a single thread-safe operation.
    • Enumerators reflect a point-in-time snapshot; they do not block writers, so the collection may change during enumeration without throwing exceptions.
    • Concurrent collections are preferred over manually locking regular collections in multi-threaded scenarios because they offer better scalability and clearer intent.
    • Choose the right collection for your pattern: ConcurrentQueue for FIFO, ConcurrentBag for unordered, ConcurrentDictionary for key-value lookups, and ConcurrentStack for LIFO.

    Common follow-up questions

    • When would you use a concurrent collection over a lock-protected regular collection? Concurrent collections are better for high-contention scenarios with many readers and writers because their fine-grained locking reduces bottlenecks. Use them when scalability matters; for simple, low-contention cases, a regular lock may be simpler.
    • What happens if you enumerate a ConcurrentDictionary while another thread modifies it? The enumerator provides a snapshot, so you see a consistent view of the collection at the point enumeration started; modifications after enumeration begins are not guaranteed to be visible.
    • How does AddOrUpdate differ from separate TryAdd and TryGetValue calls? AddOrUpdate is atomic and executes the update delegate only once under lock, whereas separate calls create a race condition where the value could change between TryGetValue and your update logic.

    Related interview questions

    Further reading

    .NETCsharpasync
  154. 154How does EF Core change tracking work?

    Experienced

    EF Core's ChangeTracker monitors entity state by maintaining snapshots of all loaded entities and comparing them against their current in-memory state when SaveChanges() is called. On SaveChanges, it generates the appropriate INSERT, UPDATE, or DELETE SQL statements based on detected changes, then executes them in a transaction. This mechanism is fundamental to how EF Core knows what to persist to the database.

    How it works

    When you load an entity via EF Core, the ChangeTracker creates an internal snapshot of that entity's property values. Every entity is assigned a state: Unchanged, Added, Modified, Deleted, or Detached.

    As your application code modifies entities in memory, the ChangeTracker does not automatically detect changes. Instead, when you call SaveChanges(), EF Core performs a change detection scan by comparing current property values against the stored snapshot. If values differ, the entity is marked as Modified. New entities added via Add() are marked Added, and entities passed to Remove() are marked Deleted.

    Once all entities are assigned states, EF Core generates SQL statements in dependency order (respecting foreign keys), wraps them in a transaction, and executes them. If any statement fails, the entire transaction rolls back.

    This design allows EF Core to batch changes and minimize database round trips. However, by default, change detection is relatively expensive for large object graphs because EF must compare each property. You can optimize this using AsNoTracking() for read-only queries, or use ExecuteUpdate and ExecuteDelete to bypass change tracking entirely for bulk operations.

    Example

    csharp
    using (var context = new AppDbContext())
    {
        // Load entity — snapshot created, state = Unchanged
        var user = context.Users.First(u => u.Id == 1);
        
        // Modify in memory — state still Unchanged until SaveChanges
        user.Email = "newemail@example.com";
        user.LastModified = DateTime.UtcNow;
        
        // Change detection runs here; user.Email differs from snapshot
        // State changes to Modified
        context.SaveChanges();
        // Generates: UPDATE Users SET Email = '...', LastModified = '...' WHERE Id = 1
        
        // Add new entity
        var newUser = new User { Name = "Alice", Email = "alice@example.com" };
        context.Users.Add(newUser);  // state = Added
        context.SaveChanges();
        // Generates: INSERT INTO Users (...) VALUES (...)
    }
    

    This example shows how a loaded user entity is tracked, modified, and then persisted. The ChangeTracker detects the Email and LastModified changes and generates an UPDATE statement.

    Key points to mention

    • Snapshot-based detection: EF Core stores original values and compares them on SaveChanges(); changes are not detected in real time.
    • Entity states: Entities move through states (Unchanged → Modified → SaveChanges → Unchanged again) as you work with them.
    • Tracking overhead: Tracking all entities in a large query can consume memory and slow change detection; use AsNoTracking() for read-only scenarios.
    • Foreign key relationships: EF Core respects referential integrity when ordering INSERT, UPDATE, DELETE statements to avoid constraint violations.
    • Transactions: All generated statements are wrapped in a single transaction, ensuring atomic persistence.

    Common follow-up questions

    • How do you improve change tracking performance for large datasets?
    • What happens if you modify an entity without calling SaveChanges()? Will the changes persist if you load it again later?
    • Can you manually set entity state, and why would you do it?

    Related interview questions

    Further reading

    .NETCsharpefcore
  155. 155What is the N+1 query problem in EF Core?

    Experienced

    The N+1 query problem in EF Core occurs when you execute one query to fetch a collection of entities, then execute one additional query for each entity to load related data—resulting in 1 + N database round-trips instead of 1 or 2. This severely degrades performance, especially with large datasets. You fix it using Include() for eager loading, projections, or split queries.

    How it works

    When you query a parent entity and access a child collection property, EF Core's default behavior depends on your configuration. If lazy loading is enabled or you're accessing an unmaterialized navigation property, EF Core fires a separate query for each parent row to fetch related children. For example, loading 100 orders and then iterating through each to access its order items triggers 100 additional queries—hence "N+1."

    This happens because the initial query only loads the parent entity. The related data isn't fetched until you access the navigation property, and by that time, you're in a loop where each access triggers a fresh query. Even in no-tracking scenarios or with AsNoTracking(), the lazy load still fires if the navigation property hasn't been loaded.

    The performance impact is compounded by network latency. Each round-trip to the database incurs overhead; executing 101 queries serially is far slower than combining the work into 1–2 intelligent queries.

    Example

    csharp
    // ❌ N+1 Problem
    public async Task<List<OrderDto>> GetOrdersNPlus1(AppDbContext db)
    {
        var orders = await db.Orders.ToListAsync(); // 1 query
        var result = new List<OrderDto>();
        
        foreach (var order in orders)
        {
            var items = order.OrderItems; // ❌ N queries (one per order)
            result.Add(new OrderDto 
            { 
                OrderId = order.Id, 
                ItemCount = items.Count 
            });
        }
        return result;
    }
    
    // ✅ Fixed with Include (eager loading)
    public async Task<List<OrderDto>> GetOrdersFixed(AppDbContext db)
    {
        return await db.Orders
            .Include(o => o.OrderItems)
            .Select(o => new OrderDto
            {
                OrderId = o.Id,
                ItemCount = o.OrderItems.Count
            })
            .ToListAsync(); // 1 query with JOIN
    }
    
    // ✅ Alternative: Split Query (SQL Server/PostgreSQL)
    public async Task<List<OrderDto>> GetOrdersSplit(AppDbContext db)
    {
        return await db.Orders
            .Include(o => o.OrderItems)
            .AsSplitQuery()
            .Select(o => new OrderDto
            {
                OrderId = o.Id,
                ItemCount = o.OrderItems.Count
            })
            .ToListAsync(); // 2 queries instead of N+1
    }

    The first method loads orders, then iterates and accesses the unmaterialized OrderItems property, triggering one query per order. The fixed version uses Include() to eagerly load related items in a single query with a JOIN, or AsSplitQuery() to use two efficient queries instead of N+1.

    Key points to mention

    • Root cause: Lazy loading or accessing unmaterialized navigation properties inside loops causes cascading queries.
    • Detection: Monitor SQL logs (EF.CreateLogger) or use tools like MiniProfiler to spot duplicate queries during iteration.
    • Eager loading with Include(): The most common fix; combines parent and child data in one query using JOINs.
    • Projections: Using Select() to fetch only the columns you need avoids materializing unnecessary navigation properties and reduces payload.
    • Split queries: In EF Core 5+, AsSplitQuery() executes multiple focused queries instead of one large JOIN, sometimes improving performance on large result sets.

    Common follow-up questions

    • When should you use AsSplitQuery() instead of Include()? (Answer: When JOINs cause cartesian products with large child collections; split queries fetch data separately and are faster.)
    • How do you detect N+1 problems in production? (Answer: Enable EF Core SQL logging, use Application Insights, or tools like MiniProfiler to track query count and execution time.)
    • Does AsNoTracking() eliminate N+1? (Answer: No; it disables change tracking but lazy loading still fires if navigation properties are accessed. Use Include() or Select() projections instead.)

    Related interview questions

    Further reading

    .NETCsharpefcore
  156. 156What are split queries?

    Experienced

    Split queries are an Entity Framework Core feature that loads related collections in separate SQL queries instead of using a single large join, preventing cartesian explosion and improving performance when eager-loading multiple collections. The AsSplitQuery() method tells EF Core to generate multiple queries: one for the root entity and one for each collection, then stitch the results together in memory.

    How it works

    When you use Include() to load multiple collections on a single entity, EF Core traditionally generates one query with multiple JOINs. This creates a cartesian product: if you load an Order with 10 OrderItems and 5 Payments, the result set will have 50 rows (10 × 5), with redundant Order data repeated. This wastes bandwidth and can severely degrade performance.

    Split queries solve this by executing separate queries. EF Core runs one query to fetch Orders, then additional queries to fetch OrderItems and Payments independently. The framework then correlates results by primary/foreign keys and reconstructs the object graph in memory. This approach eliminates redundancy and is often faster for larger result sets.

    Split queries work best when:

    • You're loading multiple collections on the same entity
    • Collections are large relative to the root entity count
    • Network latency is not the primary bottleneck
    • You're using a modern version of EF Core (6.0+) with good caching support

    Example

    csharp
    // Without split query – cartesian explosion
    var orders = await dbContext.Orders
        .Include(o => o.OrderItems)
        .Include(o => o.Payments)
        .ToListAsync();
    // Generates: SELECT * FROM Orders
    // JOIN OrderItems ON Orders.Id = OrderItems.OrderId
    // JOIN Payments ON Orders.Id = Payments.OrderId
    // Result: many duplicate Order rows
    
    // With split query – separate queries
    var orders = await dbContext.Orders
        .Include(o => o.OrderItems)
        .Include(o => o.Payments)
        .AsSplitQuery()
        .ToListAsync();
    // Generates:
    // Query 1: SELECT * FROM Orders
    // Query 2: SELECT * FROM OrderItems WHERE OrderId IN (...)
    // Query 3: SELECT * FROM Payments WHERE OrderId IN (...)
    // No duplication; cleaner result

    This example loads Orders with their items and payments without cartesian explosion; EF Core executes three queries and reconstructs the graph in memory.

    Key points to mention

    • Cartesian explosion – traditional JOINs multiply row counts; split queries eliminate this by separating the load
    • Memory vs. network – split queries trade extra round-trips for reduced data transfer; best when bandwidth is the constraint
    • Default behavior – EF Core 8+ may use split queries automatically for complex includes; you can override with AsSingleQuery() if needed
    • Tracking and identity – split queries still respect identity resolution, so duplicate entities in memory are deduplicated correctly
    • Configuration-wide setting – you can enable split queries by default: builder.UseSqlServer(conn, o => o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery))

    Common follow-up questions

    • When should I use split queries vs. single queries? – Use split queries for large, complex object graphs with multiple collections; use single queries for simple scenarios or when you need a specific SQL shape. Profile both approaches.
    • How does this relate to the N+1 query problem? – Split queries are intentional multiple queries to avoid cartesian explosion; N+1 is an unintended performance bug from missing Include() clauses.
    • Can split queries affect DISTINCT or filtering? – Yes; some query shapes (e.g., DISTINCT, GROUP BY) may not work correctly with split queries and require AsSingleQuery() instead.

    Related interview questions

    Further reading

    .NETCsharpefcore
  157. 157Why use projections with Select in EF Core?

    Experienced

    Projections with Select in EF Core allow you to retrieve only the columns you need into DTOs, eliminating unnecessary database queries and avoiding change tracking overhead. This approach translates directly to optimized SQL queries that fetch fewer columns, reduce memory consumption, and improve application performance significantly.

    How it works

    When you project data using Select, EF Core's query provider converts your LINQ expression into a SQL SELECT statement that retrieves only the specified columns. Unlike loading full entities, projections skip the change tracking machinery entirely—EF Core doesn't need to maintain state for objects that won't be modified. This dual benefit means both the database does less work and your application consumes less memory.

    The key mechanism is that EF Core examines your lambda expression in Select and generates a targeted SQL query before data ever leaves the database. If you select three properties from a ten-property entity, only those three columns appear in the SQL SELECT clause. This is dramatically different from loading the full entity and then filtering in memory, which would fetch all ten columns unnecessarily.

    Projections are especially powerful because they work seamlessly with complex queries: you can include filters, joins, and aggregations in the same LINQ query, and the entire expression is still translated to SQL.

    Example

    csharp
    // Without projection - fetches full Product entity with change tracking
    var products = await dbContext.Products
        .Where(p => p.Price > 100)
        .ToListAsync(); // Loads all columns, tracks all objects
    
    // With projection - fetches only needed columns, no change tracking
    var productDtos = await dbContext.Products
        .Where(p => p.Price > 100)
        .Select(p => new ProductDto
        {
            Id = p.Id,
            Name = p.Name,
            Price = p.Price
        })
        .ToListAsync(); // Generates: SELECT Id, Name, Price FROM Products WHERE Price > 100
    
    public record ProductDto(int Id, string Name, decimal Price);

    The projected query generates a SQL statement with only the three columns needed, while the full-entity version requests every column from the Products table, including metadata that won't be used.

    Key points to mention

    • Performance benefit: Less data transferred from database, smaller result sets in memory, faster deserialization.
    • No change tracking: Projected DTOs are never tracked, so there's zero overhead from the change tracking system—ideal for read-only scenarios.
    • Query composition: Projections can include computed properties, anonymous types, or explicit DTOs; the SQL is generated before execution.
    • Best for read operations: Projections excel in API responses, reports, and displays where you don't need to update entities.
    • Works with joins and aggregations: You can project data from multiple related entities or perform grouping operations within the projection.

    Common follow-up questions

    • Can I project from multiple related entities in a single query? (Yes, using joins and navigation properties; the entire query is still translated to SQL.)
    • What's the difference between Select projections and AsNoTracking? (AsNoTracking loads full entities but skips tracking; Select loads only needed columns and skips tracking, making Select more efficient for read-only scenarios.)
    • Can I use projections with updates? (No—you cannot track changes on projected DTOs. Map them back to entities if you need to update.)

    Related interview questions

    Further reading

    .NETCsharpefcore
  158. 158How do you handle concurrency conflicts in EF Core?

    Experienced

    Concurrency conflicts in EF Core occur when two users modify the same record simultaneously. You handle them by adding a concurrency token (typically a rowversion column), catching DbUpdateConcurrencyException, and then deciding whether to reload the database values, merge changes client-side, or return a 409 Conflict response to the client.

    How it works

    EF Core uses optimistic concurrency control by default. When you mark a property as a concurrency token, EF Core includes it in the WHERE clause of UPDATE and DELETE statements. If another user has modified the row since you loaded it, the rowversion (or timestamp) value will differ, causing the UPDATE to affect zero rows. EF Core detects this and throws DbUpdateConcurrencyException.

    You then have three main strategies:

    1. Reload from database: Discard your changes and reload the current state.
    2. Client wins: Overwrite the database values with your changes.
    3. Merge: Intelligently combine both versions.
    4. Return conflict to client: Send a 409 status so the UI can show differences and let the user decide.

    Example

    csharp
    // Model with rowversion concurrency token
    public class Product
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public decimal Price { get; set; }
        
        [Timestamp]
        public byte[] RowVersion { get; set; }
    }
    
    // DbContext configuration
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Product>()
            .Property(p => p.RowVersion)
            .IsRowVersion();
    }
    
    // Handle concurrency in your service
    public async Task<(bool Success, string Message)> UpdateProductAsync(Product product)
    {
        try
        {
            _context.Products.Update(product);
            await _context.SaveChangesAsync();
            return (true, "Product updated successfully");
        }
        catch (DbUpdateConcurrencyException ex)
        {
            // Strategy 1: Reload from database
            var entry = ex.Entries.First();
            var databaseValues = await entry.GetDatabaseValuesAsync();
            entry.OriginalValues.SetValues(databaseValues);
            
            // Strategy 2: Return conflict to client (recommended for APIs)
            return (false, "Product was modified by another user. Please refresh and try again.");
        }
    }
    
    // In a controller, return appropriate status
    [HttpPut("products/{id}")]
    public async Task<IActionResult> UpdateProduct(int id, UpdateProductDto dto)
    {
        var (success, message) = await _productService.UpdateProductAsync(product);
        return success ? Ok() : Conflict(new { error = message });
    }

    This example shows a Product entity with a rowversion token, how to configure it, and how to catch and handle the concurrency exception by returning a 409 Conflict response to the client.

    Key points to mention

    • Rowversion/Timestamp: Use [Timestamp] attribute or .IsRowVersion()—it's automatically updated by SQL Server on every change.
    • DbUpdateConcurrencyException: Thrown only if zero rows are affected by the UPDATE; catch it explicitly and decide on recovery strategy.
    • Optimistic vs. Pessimistic: EF Core uses optimistic locking by default (no locks); consider pessimistic if you need guaranteed isolation.
    • API best practice: Return 409 Conflict with current values so the client can show a merge dialog or request a refresh.
    • Shadow properties: You can use .Property<byte[]>("RowVersion").IsRowVersion() if you don't want to expose the token in your model.

    Common follow-up questions

    • How do you decide between client-wins, database-wins, and merge strategies? Client-wins is risky (data loss); database-wins is simpler but may surprise users; merge requires domain logic to intelligently combine changes. APIs typically return 409 to let the client decide.
    • Can you use multiple concurrency tokens? Yes, but rowversion is often sufficient. Multiple tokens let you track changes to specific columns and merge more intelligently.
    • What if you don't have a rowversion column? Use .IsConcurrencyToken() on a regular property (string, Guid, or int), though you must manually increment or update it; rowversion is automated and preferred.

    Related interview questions

    .NETCsharpefcoreasync
  159. 159What are owned types in EF Core?

    Experienced

    Owned types in EF Core are value objects that don't have their own identity and are always associated with an owner entity. They are mapped directly into the owner's table (table splitting) or into a separate table without a primary key, allowing you to model Domain-Driven Design (DDD) value objects cleanly in the database.

    How it works

    Owned types represent conceptual objects that only make sense in the context of their owner. Unlike regular entities that have their own identity and can exist independently, owned types are dependent objects. EF Core stores owned type data either:

    • In the owner's table (default): All columns from the owned type are prefixed and stored in the same table as the owner.
    • In a separate table: Via table splitting or explicit configuration, creating a one-to-one relationship without the owned type having its own primary key.

    You configure owned types using the OwnsOne() or OwnsMany() methods in OnModelCreating, or by using the [Owned] attribute. This tells EF Core to treat the type as a value object rather than a separate entity, preventing it from being tracked independently and ensuring it cannot exist without its owner.

    Example

    csharp
    using Microsoft.EntityFrameworkCore;
    
    [Owned]
    public class Address
    {
        public string Street { get; set; }
        public string City { get; set; }
        public string PostalCode { get; set; }
    }
    
    public class Customer
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public Address BillingAddress { get; set; }
        public Address ShippingAddress { get; set; }
    }
    
    public class AppDbContext : DbContext
    {
        public DbSet<Customer> Customers { get; set; }
    
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Customer>()
                .OwnsOne(c => c.BillingAddress, nav =>
                {
                    nav.Property(a => a.Street).HasColumnName("BillingStreet");
                    nav.Property(a => a.City).HasColumnName("BillingCity");
                })
                .OwnsOne(c => c.ShippingAddress, nav =>
                {
                    nav.Property(a => a.Street).HasColumnName("ShippingStreet");
                    nav.Property(a => a.City).HasColumnName("ShippingCity");
                });
        }
    }
    

    This example maps Address as an owned type on Customer. The database will store billing and shipping address columns in the Customers table with prefixed names, keeping related data together while modeling it as a distinct value object.

    Key points to mention

    • No independent identity: Owned types cannot exist without their owner and cannot be queried directly.
    • Table splitting by default: Owned type properties are stored in the owner's table with prefixed column names, reducing joins and improving query performance.
    • Multiple instances: An owner can have multiple owned type instances (e.g., BillingAddress and ShippingAddress) with configurable column naming.
    • Collections support: Use OwnsMany() to support collections of owned types, which are stored in a separate table linked by a foreign key.
    • DDD alignment: Owned types naturally model value objects from Domain-Driven Design, promoting better domain modeling and encapsulation.

    Common follow-up questions

    • What is the difference between OwnsOne() and OwnsMany()?
    • Can owned types have navigation properties to other entities?
    • How do you query data when the owned type is split into a separate table versus stored in the owner's table?

    Related interview questions

    Further reading

    .NETCsharpefcore
  160. 160What are global query filters?

    Experienced

    Global query filters are automatic Where conditions applied by Entity Framework Core to every query for a specific entity type. They enforce business rules consistently across the application without requiring developers to remember filter conditions in each query. The most common use cases are soft delete and multi-tenancy, where you need to exclude logically deleted records or filter by tenant automatically.

    How it works

    Global query filters are configured in the DbContext's OnModelCreating method using the HasQueryFilter extension method. When you define a filter, EF Core intercepts all LINQ queries targeting that entity and adds the filter condition to the generated SQL WHERE clause. This happens transparently at the query level, meaning every DbSet query inherits the filter unless explicitly bypassed using IgnoreQueryFilters().

    The filter accepts a lambda expression that returns a boolean predicate. This predicate is evaluated during query translation to SQL. Importantly, filters apply to all queries—LINQ, SQL queries, eager loading, and navigation properties—ensuring consistent enforcement of your business logic.

    You can bypass filters when necessary using IgnoreQueryFilters() on a DbSet query. This is useful for administrative operations where you need to access logically deleted data or data from other tenants temporarily.

    Example

    csharp
    // Entity classes
    public class Post
    {
        public int Id { get; set; }
        public string Title { get; set; }
        public bool IsDeleted { get; set; }
        public int TenantId { get; set; }
    }
    
    public class BlogContext : DbContext
    {
        private readonly int _tenantId;
    
        public BlogContext(DbContextOptions options, int tenantId) : base(options)
        {
            _tenantId = tenantId;
        }
    
        public DbSet<Post> Posts { get; set; }
    
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            // Global query filter: exclude soft-deleted posts for the current tenant
            modelBuilder.Entity<Post>()
                .HasQueryFilter(p => !p.IsDeleted && p.TenantId == _tenantId);
        }
    }
    
    // Usage
    var context = new BlogContext(options, tenantId: 1);
    var posts = context.Posts.ToList(); // Automatically filters out IsDeleted=true and TenantId!=1
    
    // Bypass filter to get all posts including deleted ones
    var allPosts = context.Posts.IgnoreQueryFilters().ToList();
    

    This example demonstrates a combined soft-delete and multi-tenant filter: every query on Posts automatically excludes deleted records and filters to the current tenant, but administrators can override this with IgnoreQueryFilters() when auditing is required.

    Key points to mention

    • Transparent enforcement: Filters apply to all queries automatically, reducing the risk of accidentally exposing sensitive data or showing deleted records.
    • IgnoreQueryFilters() bypass: Use this method when you need to access filtered-out data for reporting, auditing, or administrative tasks.
    • Performance consideration: Filters are translated to SQL WHERE clauses and indexed appropriately, so they have minimal performance impact when properly designed.
    • Filter expression limitations: The lambda cannot reference DbSet queries or external methods; it must translate to SQL. Use only properties, constants, and simple expressions.
    • Inheritance and composition: Filters can be combined using AND/OR logic and work with inheritance hierarchies, affecting both base and derived entity types.

    Common follow-up questions

    • How do you test global query filters? Use an in-memory database or mocking to verify filters apply correctly, and test both filtered and IgnoreQueryFilters() scenarios.
    • Can you combine multiple global filters on one entity? Yes, you can call HasQueryFilter multiple times; EF combines them with AND logic, or use expressions with || for OR conditions.
    • What happens to navigation properties with global filters applied? Filters apply to eager-loaded and lazy-loaded navigation collections, ensuring consistency, but explicit joins may require manual filtering if the related entity also has filters.

    Related interview questions

    Further reading

    .NETCsharpaspnetcoreefcore
  161. 161What are compiled queries in EF Core?

    Experienced

    Compiled queries in EF Core are LINQ queries that are pre-compiled and cached, eliminating the overhead of translating the query expression tree to SQL on each execution. Using EF.CompileQuery() or EF.CompileAsyncQuery(), you create a delegate that captures the compiled query plan, which is then reused whenever the query runs with different parameters. This is a performance optimization for frequently executed queries in hot paths.

    How it works

    When you execute a standard LINQ query against EF Core, the query provider translates the expression tree into SQL on each invocation. For queries that run hundreds or thousands of times with only parameter variations, this translation overhead accumulates significantly.

    Compiled queries short-circuit this process. You call EF.CompileQuery() or EF.CompileAsyncQuery() with a lambda expression representing your query, and EF Core immediately translates it to SQL and caches the compiled delegate. Subsequent executions skip the translation phase entirely—they just invoke the delegate with new parameters.

    The compiled query is effectively a Func delegate that accepts your DbContext and any query parameters, then returns the result. This delegate is thread-safe and can be stored as a static field for maximum reuse across requests.

    Example

    csharp
    public class OrderContext : DbContext
    {
        public DbSet<Order> Orders { get; set; }
    
        // Define compiled query at class level
        private static readonly Func<OrderContext, int, Task<Order?>> GetOrderByIdCompiled =
            EF.CompileAsyncQuery((OrderContext ctx, int orderId) =>
                ctx.Orders.FirstOrDefaultAsync(o => o.Id == orderId));
    
        public Task<Order?> GetOrderByIdAsync(int orderId)
        {
            return GetOrderByIdCompiled(this, orderId);
        }
    }
    
    // Usage
    using var context = new OrderContext();
    var order = await context.GetOrderByIdAsync(42); // Compiled query reused
    

    This example defines a compiled async query that finds an order by ID; the query translation happens once, and every subsequent call with a different orderId reuses the cached plan.

    Key points to mention

    • Static field storage: Compiled queries are typically stored as static fields to ensure they persist and are reused across the lifetime of the application, maximizing benefit.
    • Parameter passing: The compiled query delegate requires the DbContext as the first parameter, followed by your domain parameters; this allows parameterized queries without string concatenation.
    • Significant gains in hot paths: Compiled queries provide the most benefit for high-frequency queries (e.g., user lookups, permission checks, repeated dashboard queries) where translation overhead would otherwise compound.
    • Limited query complexity: Compiled queries work best with relatively simple, deterministic queries; dynamic filtering or complex conditional logic may reduce effectiveness or require separate compiled variants.
    • EF Core 5.0+: EF.CompileAsyncQuery() is the modern approach; synchronous EF.CompileQuery() still exists but async is generally preferred in modern applications.

    Common follow-up questions

    • How much performance improvement can you expect? In benchmarks, compiled queries can eliminate 30–50% of overhead for repeated executions by skipping LINQ-to-SQL translation, though the absolute gain depends on query complexity and parameter changes.
    • Can I use compiled queries with dynamic filters or .Where() conditions built at runtime? No; compiled queries must capture the complete query structure at compile time. Dynamic queries require either separate compiled variants or acceptance of per-execution translation cost.
    • How does this relate to deferred execution in LINQ? Deferred execution postpones evaluation until enumeration; compiled queries go further by caching the translation plan itself, making them orthogonal optimization strategies.

    Related interview questions

    Further reading

    .NETCsharpefcore
  162. 162What is ExecuteUpdate and ExecuteDelete?

    Experienced

    ExecuteUpdate and ExecuteDelete are EF Core methods that execute set-based UPDATE and DELETE statements directly at the database level without loading entities into memory. This approach bypasses the change tracker entirely, making bulk modifications dramatically faster than the traditional load-modify-save pattern.

    How it works

    Traditionally, updating or deleting multiple records requires loading each entity into memory, modifying it, and calling SaveChanges(). This triggers EF Core change tracking, generates individual UPDATE or DELETE statements for each entity, and consumes significant memory for large datasets.

    ExecuteUpdate and ExecuteDelete (introduced in EF Core 7) compile a LINQ query directly into a single SQL SET operation that runs entirely on the server. No entities are materialized; only the IQueryable filter and update expressions are translated to SQL. The database executes one atomic statement, and EF Core returns the number of rows affected. This is especially powerful for:

    • Bulk updates across thousands of records
    • Conditional deletes without loading data
    • Performance-critical operations where memory and round-trips matter

    Example

    csharp
    // Traditional approach: load and modify (slow for bulk)
    var users = await dbContext.Users
        .Where(u => u.LastLoginDate < DateTime.UtcNow.AddYears(-1))
        .ToListAsync();
    
    foreach (var user in users)
    {
        user.IsActive = false;
    }
    await dbContext.SaveChangesAsync(); // Multiple UPDATE statements
    
    
    // ExecuteUpdate: one SET statement in database (fast)
    int rowsAffected = await dbContext.Users
        .Where(u => u.LastLoginDate < DateTime.UtcNow.AddYears(-1))
        .ExecuteUpdateAsync(s => s
            .SetProperty(u => u.IsActive, false)
            .SetProperty(u => u.ModifiedDate, DateTime.UtcNow));
    
    // ExecuteDelete: one DELETE statement
    int deletedCount = await dbContext.Users
        .Where(u => u.Status == "Inactive")
        .ExecuteDeleteAsync();
    

    The ExecuteUpdate example deactivates all users inactive for one year in a single database operation; ExecuteDelete removes inactive users without materializing them.

    Key points to mention

    • No change tracking: These methods skip EF Core's change tracker entirely, so SaveChanges() is not required.
    • Set-based, not row-by-row: One SQL statement updates or deletes all matching rows atomically.
    • Returns affected count: Both return an int indicating how many rows were modified.
    • LINQ-to-SQL translation: The Where clause and property assignments are translated directly to SQL, so only supported LINQ operations work.
    • Related entities not loaded: Navigation properties and includes have no effect; only the queried entity is considered.

    Common follow-up questions

    • What happens if I call ExecuteUpdate on a DbSet without a Where clause? (It will update every row, so filtering is critical.)
    • Can I use ExecuteUpdate to modify related entities in one call? (No; you must run separate ExecuteUpdate calls per entity type.)
    • How does this compare to bulk operations with third-party libraries like EFCore.BulkInsert? (ExecuteUpdate/Delete handle updates and deletes; for inserts, specialized bulk libraries are often faster.)

    Related interview questions

    Further reading

    .NETCsharpefcore
  163. 163How do you manage DbContext lifetime?

    Experienced

    DbContext lifetime should be scoped per HTTP request in web applications by default—register it with AddDbContext<YourContext>() in dependency injection. For scenarios outside the request lifecycle (background jobs, Blazor, parallel operations), use IDbContextFactory<T> to create instances on-demand, because DbContext is not thread-safe and cannot be shared across concurrent operations.

    How it works

    DbContext is a stateful object that tracks entity changes, maintains identity resolution, and buffers queries. The scoped lifetime ensures each HTTP request gets its own isolated instance, with automatic disposal at the end of the request.

    When you register services.AddDbContext<ApplicationDbContext>(), the DI container creates one instance per scope. In ASP.NET Core, a new scope is created for each request, so the DbContext lives for the duration of that request and is disposed when the request completes. This prevents connection pooling exhaustion, stale change tracking, and memory leaks.

    However, scoped registration doesn't work for:

    • Background jobs / hosted services: No HTTP request scope exists.
    • Blazor Server: Circuit lifetime differs from request lifetime; multiple requests share one circuit.
    • Parallel operations: Each task needs its own DbContext instance.

    For these, inject IDbContextFactory<ApplicationDbContext> and call CreateDbContextAsync() to get a new instance. You manage its lifetime explicitly.

    Example

    csharp
    // Startup: scoped registration for HTTP requests
    services.AddDbContext<ApplicationDbContext>(options =>
        options.UseSqlServer(connectionString));
    
    // In a controller: DbContext is injected and managed by DI
    public class ProductsController : ControllerBase
    {
        private readonly ApplicationDbContext _context;
        
        public ProductsController(ApplicationDbContext context)
        {
            _context = context; // Scoped instance
        }
        
        [HttpGet("{id}")]
        public async Task<IActionResult> GetProduct(int id)
        {
            var product = await _context.Products.FindAsync(id);
            return Ok(product);
        } // DbContext disposed automatically after response
    }
    
    // For background jobs: use IDbContextFactory
    public class BackgroundJobService : BackgroundService
    {
        private readonly IDbContextFactory<ApplicationDbContext> _factory;
        
        public BackgroundJobService(IDbContextFactory<ApplicationDbContext> factory)
        {
            _factory = factory;
        }
        
        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
        {
            while (!stoppingToken.IsCancellationRequested)
            {
                using (var context = _factory.CreateDbContext())
                {
                    var orders = await context.Orders.Where(o => !o.IsProcessed)
                        .ToListAsync(stoppingToken);
                    // Process orders
                }
                await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
            }
        }
    }
    

    The first example shows scoped injection in a controller—DbContext lives for the request. The second shows factory usage in a hosted service—you create and dispose instances explicitly for background work.

    Key points to mention

    • Scoped is the default for web: One instance per HTTP request, automatic disposal—matches request lifetime.
    • DbContext is not thread-safe: Never share one instance across threads or concurrent operations; use a factory instead.
    • IDbContextFactory enables explicit lifetime control: Call CreateDbContext() to get a new instance you manage with using.
    • Pooling improves performance: Enable with .AddPooledDbContextFactory() or AddDbContextPool() to reuse pooled instances instead of creating new ones.
    • Change tracker state is isolated per instance: Each DbContext has its own identity map; sharing across requests causes tracking conflicts and stale data.

    Common follow-up questions

    • What happens if I don't dispose a DbContext? — Connection remains open in the pool, leaking resources; change tracker memory accumulates. Always use scoped injection or using statements.
    • Can I use a singleton DbContext? — No. Singletons are shared across all requests and threads, breaking thread-safety guarantees and creating race conditions on change tracking.
    • How does this relate to service lifetimes in ASP.NET Core? — DbContext follows the scoped lifetime pattern; understand scoped vs. singleton vs. transient to avoid common mistakes.

    Related interview questions

    Further reading

    .NETCsharpefcoredependency-injection
  164. 164What is DbContext pooling?

    Experienced

    DbContext pooling is a performance optimization that reuses DbContext instances across requests instead of creating and disposing new ones each time. It reduces memory allocations and initialization overhead by maintaining a pool of pre-configured contexts. You enable it with AddDbContextPool instead of AddScoped in dependency injection, making it ideal for high-throughput applications like APIs.

    How it works

    When you register a DbContext with AddDbContextPool, EF Core creates and maintains a pool of context instances. When a component requests a DbContext, the pool returns an available instance rather than constructing a new one. Once the context is released (typically at the end of a request), it's returned to the pool for reuse instead of being disposed.

    The pool clears the context state between reuses: it clears the change tracker, resets configuration, and ensures the instance is clean for the next consumer. This happens transparently, so from the consumer's perspective, they receive a fresh context.

    The pool size defaults to the number of CPU cores and can be configured. If all pooled instances are in use, a new temporary instance is created. This design balances memory efficiency with throughput under load.

    Example

    csharp
    // Startup configuration
    services.AddDbContextPool<ApplicationDbContext>(options =>
        options.UseSqlServer(connectionString),
        poolSize: 128  // Optional: set pool size
    );
    
    // Usage in a service—no different from AddScoped
    public class ProductService
    {
        private readonly ApplicationDbContext _context;
    
        public ProductService(ApplicationDbContext context)
        {
            _context = context;  // Reused from pool, not newly created
        }
    
        public async Task<Product> GetProductAsync(int id)
        {
            return await _context.Products.FindAsync(id);
        }
    }
    

    The service receives a DbContext from the pool; EF Core handles returning it when the request ends. The pool size can be tuned for your workload—higher values reduce contention but increase memory.

    Key points to mention

    • Do not store per-request state in pooled contexts: Avoid instance fields that hold request-specific data, as they persist between requests and cause state leakage.
    • Thread-safe and async-aware: The pool handles concurrent requests correctly; each request gets its own context instance.
    • Significant memory and CPU savings: Eliminates allocations, GC pressure, and DbContext initialization overhead—especially valuable in high-throughput scenarios like APIs serving thousands of requests/sec.
    • Transparent to consumers: Code looks identical to AddScoped; the pooling is implementation detail managed by EF Core.
    • Not ideal for all scenarios: Long-running operations, Blazor Server, or apps with persistent state should use AddScoped DbContext instead.

    Common follow-up questions

    • What state must be avoided in pooled contexts? — Instance fields, session-specific configuration, and any mutable fields that should not leak between requests. Use local variables or services for per-request state.
    • How do you monitor pool usage? — You can log pool events using a custom DbContext factory or enable EF Core diagnostics to track how often the pool creates vs. reuses instances.
    • Can pooling cause issues with lazy-loading or navigation properties? — No; once a context is used and returned to the pool, its tracked entities are detached. The next consumer receives a clean context without orphaned references.

    Related interview questions

    .NETCsharpefcorePerformance
  165. 165How do you write raw SQL safely in EF Core?

    Experienced

    In EF Core, write raw SQL safely using FromSql() or SqlQuery() with C# string interpolation, which automatically converts interpolated values into parameterized queries. Never concatenate user input directly into SQL strings. This approach prevents SQL injection while maintaining the flexibility of raw SQL when LINQ-to-Entities is insufficient.

    How it works

    EF Core's FromSql() method (for queries returning entities) and SqlQuery() (for arbitrary results) accept interpolated strings. When you use C# string interpolation syntax (the $ prefix with {variable}), EF Core recognizes this pattern and automatically converts each interpolated value into a SQL parameter rather than embedding it as a string literal. This parameterization is the foundation of SQL injection prevention.

    The key distinction is between FormattableString (which EF Core inspects and parameterizes) and simple string concatenation (which bypasses safety entirely). EF Core analyzes the interpolated string's structure at query-building time, extracts the values, and constructs a prepared statement with placeholders like @p0, @p1, etc.

    This mechanism works because the database engine receives the SQL structure and data separately. The database never interprets interpolated values as SQL code—only as data values—regardless of their content.

    Example

    csharp
    // ✅ SAFE: Using string interpolation with FromSql
    public async Task<List<User>> SearchUsers(string searchTerm, int minAge)
    {
        var users = await _context.Users
            .FromSql($"SELECT * FROM Users WHERE Name LIKE {searchTerm} AND Age > {minAge}")
            .ToListAsync();
        return users;
    }
    
    // ✅ SAFE: Using SqlQuery for scalar or anonymous results
    public async Task<List<(string Name, int Score)>> GetTopScores(int threshold)
    {
        var results = await _context.Database
            .SqlQuery<(string Name, int Score)>($"SELECT Name, Score FROM LeaderBoard WHERE Score > {threshold}")
            .ToListAsync();
        return results;
    }
    
    // ❌ DANGEROUS: String concatenation (never do this)
    var dangerousQuery = $"SELECT * FROM Users WHERE Name = '" + userInput + "'";
    var users = _context.Users.FromSqlRaw(dangerousQuery).ToList();
    

    The first example safely accepts user input via interpolation; EF Core converts it to a parameterized query. The dangerous example shows concatenation, which would allow injection if userInput contained SQL code.

    Key points to mention

    • Interpolation is mandatory: Only FormattableString (created with $"..." syntax) triggers automatic parameterization. Plain string concatenation or FromSqlRaw() bypass this safety.
    • Works with complex types: You can interpolate objects, collections, and even null values; EF Core handles the type mapping to SQL parameters.
    • Performance benefit: Parameterized queries allow the database to cache execution plans, improving performance on repeated queries with different parameter values.
    • Escaping is unnecessary: Because values are parameters, you don't need to escape quotes, backslashes, or other special characters.
    • Combine with LINQ when possible: Raw SQL should be a fallback for complex queries; LINQ-to-Entities is generally safer and more maintainable. See how to prevent SQL injection in .NET for broader context.

    Common follow-up questions

    • What's the difference between FromSql() and SqlQuery()? FromSql() returns mapped entity types and integrates with the DbSet; SqlQuery() works on the Database object and supports scalar or anonymous result types.
    • Can I use FromSqlRaw() or SqlQueryRaw() safely? Only if you construct the entire SQL string without user input or if you manually add parameters using SqlParameter objects—but this is verbose and error-prone compared to interpolation.
    • What happens if I pass a complex object to an interpolated query? EF Core attempts to convert it to a suitable SQL parameter type; if conversion fails, an exception is thrown before the query reaches the database.

    Related interview questions

    Further reading

    .NETCsharpefcore
  166. 166How do you seed data in EF Core?

    Experienced

    Data seeding in EF Core is the process of populating your database with initial or static data. You can seed data using HasData() in your model configuration (which creates data in migrations), or use UseSeeding() and UseAsyncSeeding() in EF Core 9+ for runtime seeding logic that runs after migrations.

    How it works

    EF Core provides two primary seeding mechanisms:

    Model-level seeding with HasData(): You configure seed data directly in your OnModelCreating method using the fluent API. This data is embedded into migrations as SQL INSERT statements, making it part of your schema version control. It's ideal for static reference data like roles, countries, or status codes that rarely change.

    Runtime seeding: In EF Core 9+, UseSeeding() and UseAsyncSeeding() allow you to execute custom seeding logic at runtime, after all migrations have been applied. This is useful for environment-specific data, data that needs computation, or conditional seeding based on the current state of the database. Unlike HasData(), runtime seeding doesn't create migration entries—it runs independently each time your application starts.

    The key difference: HasData() is deterministic and version-controlled via migrations, while runtime seeding is flexible but requires explicit idempotency handling to avoid duplicate inserts.

    Example

    csharp
    // Model configuration with HasData()
    public class ApplicationDbContext : DbContext
    {
        public DbSet<Role> Roles { get; set; }
    
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Role>().HasData(
                new Role { Id = 1, Name = "Admin" },
                new Role { Id = 2, Name = "User" },
                new Role { Id = 3, Name = "Guest" }
            );
        }
    }
    
    // EF Core 9+ runtime seeding
    services.AddDbContext<ApplicationDbContext>((sp, options) =>
    {
        options.UseSqlServer(connectionString)
            .UseSeeding((context, _) =>
            {
                if (!context.Set<Role>().Any())
                {
                    context.Set<Role>().AddRange(
                        new Role { Id = 1, Name = "Admin" },
                        new Role { Id = 2, Name = "User" }
                    );
                    context.SaveChanges();
                }
            });
    });
    

    The first example uses HasData() to seed roles into the migration SQL, ensuring they exist whenever the database is created or migrated. The second example uses runtime seeding to conditionally add roles if they don't already exist, running every time the application starts.

    Key points to mention

    • HasData() creates INSERT statements in migrations, so seed data is version-controlled and applied during database initialization or updates.
    • Runtime seeding with UseSeeding() (EF Core 9+) runs after all migrations and is useful for dynamic or environment-specific data; you must implement idempotency checks to avoid duplicates.
    • For HasData(), use only simple scalar values—complex objects or relationships require careful configuration or alternative seeding methods.
    • Always check if data exists before inserting in runtime seeding to prevent duplicate key violations on repeated application starts.
    • HasData() is best for static reference data; runtime seeding is better for computed or conditionally-required data.

    Common follow-up questions

    • How do you seed related data with foreign keys? — Use HasData() with both principal and dependent entities configured, ensuring foreign key values match. For complex relationships, runtime seeding often works better.
    • What's the difference between HasData() and a separate seeding service? — HasData() is automatic and migration-based; a seeding service gives you more control and flexibility but requires explicit invocation in your application startup logic.
    • Can you seed data conditionally based on the environment? — Yes, use runtime seeding with UseSeeding() or implement environment checks in your seeding service to apply different data for development, staging, and production.

    Related interview questions

    .NETCsharpefcore
  167. 167How do you deploy EF Core migrations to production?

    Experienced

    Deploy EF Core migrations to production by generating idempotent SQL scripts or migration bundles during CI/CD, then executing them as a dedicated release step before or during deployment—never by calling Migrate() at app startup across multiple instances. This prevents race conditions, ensures auditable schema changes, and gives you control over timing and rollback.

    How it works

    The production migration workflow separates script generation from execution. During CI, you use the EF Core CLI to produce idempotent SQL scripts or bundles that can run safely multiple times without error. These artifacts are stored as part of your release package. During deployment, a dedicated migration step (often a separate job or manual gate) applies the scripts to the production database before or after the application is updated, depending on your zero-downtime strategy.

    Idempotent scripts include IF NOT EXISTS or IF EXISTS checks, so re-running them is harmless. This is critical because you may need to retry failed deployments. Unlike calling app.Services.GetRequiredService<DataContext>().Database.Migrate() at startup (which can cause deadlocks and race conditions when multiple app instances start simultaneously), a planned migration job runs once, atomically, with proper monitoring and rollback procedures.

    You have two primary options: SQL scripts (human-readable, version-controlled, database-agnostic syntax) or migration bundles (self-contained executables that embed the EF Core runtime and your DbContext). Bundles are convenient for simple deployments; scripts offer more transparency and integration with existing database deployment tools.

    Example

    csharp
    // In your CI pipeline (e.g., GitHub Actions, Azure Pipelines)
    // Step 1: Generate idempotent SQL script
    dotnet ef migrations script --idempotent --output migrations.sql --project MyApp.Data
    
    // Step 2: Generate a migration bundle (alternative)
    dotnet ef migrations bundle --output efbundle.exe --self-contained
    
    // In your release pipeline (dedicated migration job)
    // Option A: Execute the SQL script using sqlcmd
    sqlcmd -S "server.database.windows.net" -d "MyDb" -U "admin" -P "***" -i migrations.sql
    
    // Option B: Run the bundle
    ./efbundle.exe --connection "Server=...;Database=MyDb;..."
    
    // In your Startup.cs or Program.cs (ONLY in development)
    if (app.Environment.IsDevelopment())
    {
        using (var scope = app.Services.CreateScope())
        {
            var context = scope.ServiceProvider.GetRequiredService<DataContext>();
            context.Database.Migrate(); // Safe only in dev
        }
    }

    This example shows generating an idempotent SQL script in CI, then executing it via sqlcmd as a separate release job, keeping the app startup code clean and migration-free for production.

    Key points to mention

    • Always use --idempotent when generating scripts; this ensures the script can run multiple times safely without causing errors on retry.
    • Separate concerns: Script generation happens in CI (where your code is built); execution happens in a dedicated, monitored release step with proper error handling and alerting.
    • Avoid Migrate() at startup in production; multiple app instances starting simultaneously can cause deadlocks, race conditions, and unpredictable schema states. Use it only in development.
    • Version-control your migrations; keep the generated SQL scripts in your repository (or at least in your artifact store) so you have an audit trail of every schema change and can correlate it with application releases.
    • Plan for rollback; idempotent scripts make forward recovery easier, but you should also maintain a rollback strategy (e.g., backup before deployment, or keep a reverse migration script).

    Common follow-up questions

    • What happens if a migration fails halfway through? — With transactions, the entire migration rolls back. With non-transactional operations, you may need manual intervention. This is why idempotent scripts are essential: re-running them after fixing the issue is safe. Consider a zero-downtime migration strategy for complex scenarios.
    • Should I commit the generated SQL to version control? — Yes, or store it in your artifact repository. It gives you a permanent record of what changed and when, and lets you audit schema evolution alongside code changes.
    • Can I use a migration bundle instead of SQL scripts? — Yes, if your DevOps workflow allows executable files. Bundles are convenient but less transparent; SQL scripts are more portable and easier to review with standard database tooling.

    Related interview questions

    Further reading

    .NETCsharpefcoreDevOps
  168. 168What is Dapper and when would you use it?

    Experienced

    Dapper is a lightweight, high-performance micro-ORM (Object-Relational Mapper) for .NET that maps SQL query results directly to C# objects. Unlike Entity Framework Core, which provides a full ORM abstraction, Dapper sits closer to raw SQL while eliminating manual object mapping boilerplate. Use it when you need performance-critical queries, complex stored procedures, bulk operations, or fine-grained control over SQL—often alongside EF Core in the same application.

    How it works

    Dapper extends IDbConnection with extension methods that execute SQL queries and automatically map result sets to objects. It uses reflection to match column names in query results to property names on your C# classes. The mapping is cached for performance, making Dapper extremely fast—often only marginally slower than raw ADO.NET but with much less code.

    Under the hood, Dapper parses your SQL query, executes it against the database connection, reads the result set row-by-row, and populates your objects by matching columns to properties. It handles type conversions, null values, and nested object graphs (with multi-mapping). Because it's a thin wrapper around ADO.NET, you write SQL directly, which gives you full control over query optimization and execution plans.

    Example

    csharp
    using Dapper;
    using System.Data;
    using System.Data.SqlClient;
    
    public class Product
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public decimal Price { get; set; }
    }
    
    public class ProductRepository
    {
        private const string ConnectionString = "Server=.;Database=Shop;Integrated Security=true;";
    
        public Product GetProductById(int id)
        {
            using (IDbConnection connection = new SqlConnection(ConnectionString))
            {
                connection.Open();
                var product = connection.QuerySingleOrDefault(
                    "SELECT Id, Name, Price FROM Products WHERE Id = @Id",
                    new { Id = id }
                );
                return product;
            }
        }
    
        public IEnumerable GetAllProducts()
        {
            using (IDbConnection connection = new SqlConnection(ConnectionString))
            {
                connection.Open();
                return connection.Query("SELECT Id, Name, Price FROM Products");
            }
        }
    
        public void InsertProduct(Product product)
        {
            using (IDbConnection connection = new SqlConnection(ConnectionString))
            {
                connection.Open();
                connection.Execute(
                    "INSERT INTO Products (Name, Price) VALUES (@Name, @Price)",
                    new { product.Name, product.Price }
                );
            }
        }
    }
    

    This example shows basic CRUD operations: QuerySingleOrDefault retrieves a single product, Query returns multiple products, and Execute performs an insert. Column names automatically map to property names, and Dapper handles SQL injection prevention through parameterized queries.

    Key points to mention

    • Performance: Dapper is one of the fastest .NET data access libraries because it does minimal abstraction and caches reflection metadata.
    • Control: You write raw SQL, giving you precise control over queries, joins, and performance tuning—no query translation or lazy loading surprises.
    • Simplicity: No DbContext setup, migrations, or lazy loading overhead; just execute SQL and map results.
    • Complementary to EF Core: Many teams use Dapper for complex queries or reporting while using EF Core for standard CRUD, reducing contention.
    • Multi-mapping: Dapper can map a single query result to multiple object graphs (e.g., parent-child relationships) in one round-trip.

    Common follow-up questions

    • "How does Dapper handle relationships differently than EF Core?" Dapper requires manual SQL joins and multi-mapping; it has no navigation properties or automatic eager loading. You control exactly what is fetched in each query.
    • "When would you choose EF Core over Dapper?" Choose EF Core for rapid development, standard CRUD, and scenarios where query optimization is less critical. Choose Dapper when performance, complex queries, or bulk operations dominate.
    • "Does Dapper support async operations?" Yes—Dapper has async variants like QueryAsync, QuerySingleOrDefaultAsync, and ExecuteAsync for non-blocking database calls.

    Related interview questions

    Further reading

    .NETCsharpefcore
  169. 169What is the repository pattern and is it needed with EF Core?

    Experienced

    The repository pattern is an abstraction layer that mediates between your business logic and data access code, typically wrapping a database or ORM. With EF Core, DbContext already implements both repository and Unit of Work patterns, so you don't need repositories by default—add them only when you need domain-focused interfaces, improved testability, or want to hide persistence details from your application.

    How it works

    The repository pattern abstracts data access behind a collection-like interface. Instead of scattering database queries throughout your code, you centralize them in repository classes that expose methods like GetById(), GetAll(), Add(), and Save().

    In the EF Core context, DbContext already does this work: DbSet<T> acts like an in-memory collection, and SaveChanges() implements the Unit of Work pattern by batching all changes into a single transaction. This means EF Core fulfills the core repository responsibilities out of the box.

    However, there are legitimate reasons to add repositories on top:

    • Domain alignment: Repositories can expose business-focused query methods (e.g., GetActiveCustomersInRegion()) instead of generic LINQ queries scattered in handlers or services.
    • Testability: An IRepository interface allows you to mock data access in unit tests without touching a real database.
    • Persistence hiding: Your domain logic stays unaware of EF Core; you can swap databases without touching business code.
    • Query complexity: Complex LINQ expressions or stored procedures can be encapsulated, keeping handlers and services clean.

    Example

    csharp
    // Domain repository interface (no EF Core references)
    public interface IOrderRepository
    {
        Task<Order> GetByIdAsync(int id);
        Task<List<Order>> GetPendingOrdersAsync();
        Task AddAsync(Order order);
        Task SaveAsync();
    }
    
    // EF Core implementation
    public class OrderRepository : IOrderRepository
    {
        private readonly AppDbContext _context;
        
        public OrderRepository(AppDbContext context) => _context = context;
        
        public async Task<Order> GetByIdAsync(int id) 
            => await _context.Orders.FirstOrDefaultAsync(o => o.Id == id);
        
        public async Task<List<Order>> GetPendingOrdersAsync()
            => await _context.Orders.Where(o => o.Status == OrderStatus.Pending).ToListAsync();
        
        public async Task AddAsync(Order order) 
            => await _context.Orders.AddAsync(order);
        
        public async Task SaveAsync() 
            => await _context.SaveChangesAsync();
    }
    
    // Usage in application service
    public class ProcessOrderHandler
    {
        private readonly IOrderRepository _repo;
        
        public ProcessOrderHandler(IOrderRepository repo) => _repo = repo;
        
        public async Task Handle(int orderId)
        {
            var order = await _repo.GetByIdAsync(orderId);
            order.Process();
            await _repo.SaveAsync(); // Single database call
        }
    }
    

    This example shows a repository providing domain-focused methods (GetPendingOrdersAsync) and allowing the handler to work with business objects without knowing about EF Core or database details.

    Key points to mention

    • DbContext and DbSet already implement repository and Unit of Work patterns; repositories are optional, not mandatory.
    • Add repositories when you want domain-aligned queries, improved testing, or loose coupling to EF Core.
    • Avoid creating generic repositories (IRepository<T>) for every entity—they usually add noise without benefit and hide your actual queries.
    • If you skip repositories, ensure your query logic is organized (e.g., in a query handler or service) and testable.
    • Repositories work well with domain events and CQRS to keep domain logic and persistence concerns separate.

    Common follow-up questions

    • Should I create a generic repository? Usually no. Generic repositories hide what queries you're actually running and don't align with your domain. Specific repositories with meaningful method names are clearer and easier to test.
    • How do I test code that uses repositories? Mock the IRepository interface in your unit tests, returning fake data. This avoids database dependencies and keeps tests fast.
    • Can I use repositories with CQRS? Yes. Query handlers can inject repositories to fetch data, and command handlers can inject repositories to persist changes. Repositories help keep persistence logic separate from query and command logic.

    Related interview questions

    Further reading

    .NETCsharpefcoreArchitecture
  170. 170What is the Unit of Work pattern?

    Experienced

    The Unit of Work pattern is a behavioral pattern that groups a collection of database operations (Create, Read, Update, Delete) into a single atomic transaction. In Entity Framework Core, the DbContext itself acts as the Unit of Work—you accumulate changes on tracked entities and then commit them all together with a single SaveChanges() call, ensuring all-or-nothing consistency.

    How it works

    The Unit of Work pattern maintains a list of objects affected by a business transaction and coordinates writing changes out to the database. Rather than each operation hitting the database independently, the pattern batches them together. When you call SaveChanges(), the DbContext:

    1. Detects all changes on tracked entities (additions, modifications, deletions)
    2. Generates the necessary SQL commands
    3. Executes them within a single database transaction
    4. Commits atomically—all succeed or all rollback

    This guarantees referential integrity and consistency. If halfway through your business operation something fails, the entire transaction rolls back and the database remains in a known state. Without this pattern, you risk partial updates that leave the database in an inconsistent state.

    Example

    csharp
    public class OrderService
    {
        private readonly ApplicationDbContext _context;
    
        public OrderService(ApplicationDbContext context)
        {
            _context = context;
        }
    
        public async Task PlaceOrderAsync(Order order, List<OrderItem> items)
        {
            try
            {
                // All changes tracked but not yet persisted
                _context.Orders.Add(order);
                
                foreach (var item in items)
                {
                    item.OrderId = order.Id;
                    _context.OrderItems.Add(item);
                    
                    // Update inventory
                    var product = await _context.Products.FindAsync(item.ProductId);
                    product.StockQuantity -= item.Quantity;
                }
    
                // Single transaction: all changes committed together
                await _context.SaveChangesAsync();
            }
            catch (DbUpdateException ex)
            {
                // Entire transaction rolls back; database remains consistent
                throw new InvalidOperationException("Order placement failed", ex);
            }
        }
    }

    This example demonstrates the Unit of Work pattern: the order, items, and inventory updates are grouped and committed atomically with one SaveChangesAsync() call.

    Key points to mention

    • Atomicity: All operations succeed or fail together; the database never sees a partial update.
    • DbContext as UoW: In EF Core, the DbContext itself is the Unit of Work implementation—you don't typically need a separate abstraction.
    • Change tracking: EF Core tracks entity state (Added, Modified, Deleted, Unchanged) and only writes changes on commit.
    • Transaction scope: You can explicitly wrap SaveChanges with DbContext.Database.BeginTransaction() for finer control or nested transactions.
    • Idempotency consideration: If an operation fails mid-transaction, your business logic should handle potential retries safely.

    Common follow-up questions

    • How does the Unit of Work pattern interact with the Repository pattern, and when should you use both?
    • What happens if you call SaveChanges multiple times within a single logical business operation—is each call a separate unit of work?
    • How do you handle long-running operations where you need to persist intermediate state without losing atomicity across the entire workflow?

    Related interview questions

    Further reading

    .NETCsharpArchitecture
  171. 171How do you implement soft delete?

    Experienced

    Soft delete is a pattern where records are marked as deleted rather than permanently removed from the database. You implement it by adding an IsDeleted or DeletedAt column, then automatically apply a global query filter to exclude deleted rows from all queries. This preserves data integrity and audit trails while appearing as deletion to the application.

    How it works

    Soft delete works by intercepting delete operations and converting them into update operations. Instead of removing a row, you set a flag or timestamp that marks it as deleted. EF Core's global query filters automatically apply a WHERE clause to all queries, filtering out soft-deleted records transparently.

    The pattern typically involves:

    • Adding a DeletedAt (nullable DateTime) or IsDeleted (boolean) property to your entity
    • Configuring a global query filter in ModelBuilder to exclude soft-deleted rows
    • Overriding SaveChanges or using EF Core interceptors to intercept deletions and convert them to updates
    • Optionally tracking who deleted what and when for audit purposes

    This approach maintains referential integrity, allows data recovery, and keeps audit trails intact—critical for compliance and troubleshooting.

    Example

    csharp
    public class User
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public DateTime? DeletedAt { get; set; }
    }
    
    public class AppDbContext : DbContext
    {
        public DbSet<User> Users { get; set; }
    
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            base.OnModelCreating(modelBuilder);
    
            // Global query filter: exclude soft-deleted rows
            modelBuilder.Entity<User>()
                .HasQueryFilter(u => u.DeletedAt == null);
        }
    
        public override int SaveChanges()
        {
            // Intercept deletions and convert to soft delete
            foreach (var entry in ChangeTracker.Entries<User>()
                .Where(e => e.State == EntityState.Deleted))
            {
                entry.State = EntityState.Modified;
                entry.Entity.DeletedAt = DateTime.UtcNow;
            }
    
            return base.SaveChanges();
        }
    }
    
    // Usage
    var user = context.Users.First();
    context.Users.Remove(user);        // Intercepted in SaveChanges
    context.SaveChanges();             // Sets DeletedAt instead of deleting
    // user still exists with DeletedAt = DateTime.UtcNow
    
    var activeUsers = context.Users.ToList();  // Filter applied automatically
    // Soft-deleted user not included

    The example adds a DeletedAt column to User, applies a query filter to hide soft-deleted rows, and intercepts deletions in SaveChanges to set the timestamp instead of removing the row.

    Key points to mention

    • Query filters are transparent: Developers don't need to remember to add .Where(x => x.DeletedAt == null)—the filter applies automatically to all queries
    • Interception strategy: Use SaveChanges override for simplicity, or EF Core interceptors for more complex scenarios or dependency injection
    • Allow recovery: Store DeletedAt and optionally DeletedBy for audit trails and restoration capabilities
    • Querying deleted data: Use .IgnoreQueryFilters() when you need to access soft-deleted records (admin panels, recovery tools)
    • Cascade behavior: Ensure child entities are also soft-deleted or handled according to your business rules

    Common follow-up questions

    • How do you restore a soft-deleted record? Set DeletedAt = null and update the entity. You may want a separate Restore() method for clarity and audit logging.
    • What about performance with large tables? Add an index on DeletedAt to improve filter performance, or consider partitioning deleted vs. active data if the table grows very large.
    • How do you handle unique constraints with soft delete? Use filtered unique indexes: .HasIndex(u => u.Email).IsUnique().HasFilter("DeletedAt IS NULL") so deleted records don't block reuse of unique fields.

    Related interview questions

    Further reading

    .NETCsharpaspnetcoreefcore
  172. 172What are EF Core interceptors?

    Experienced

    EF Core interceptors are middleware-like hooks that intercept database operations at key points—before and after command execution, connection open/close, and SaveChanges—allowing you to inject cross-cutting concerns like auditing, logging, soft deletes, and tenant filtering without modifying business logic. They integrate directly into the DbContext lifecycle and execute synchronously or asynchronously with full access to command details and context state.

    How it works

    EF Core provides several interceptor interfaces, each targeting a specific operation stage. The most common are ICommandInterceptor (database commands), IConnectionInterceptor (connection lifecycle), and ISaveChangesInterceptor (SaveChanges pipeline). When you register an interceptor on the DbContextOptions, EF Core automatically calls your implementation at the appropriate moment with the relevant context object—before the operation executes (allowing modification) and after it completes (allowing observation or correction).

    Interceptors are designed for scenarios where the same logic applies across many queries or commands. Unlike query filters (which work only on queries) or event handlers (which are DbContext-specific), interceptors operate at a lower layer and can modify SQL, add parameters, handle connection pooling events, or enrich entities during materialization. They're particularly useful for implementing auditing and soft delete patterns globally.

    Example

    csharp
    public class AuditingInterceptor : SaveChangesInterceptor
    {
        public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
            DbContextEventData eventData,
            InterceptionResult<int> result,
            CancellationToken cancellationToken = default)
        {
            var context = eventData.Context;
            var now = DateTime.UtcNow;
    
            foreach (var entry in context.ChangeTracker.Entries<IAuditableEntity>())
            {
                if (entry.State == EntityState.Added)
                {
                    entry.Entity.CreatedAt = now;
                    entry.Entity.CreatedBy = "CurrentUser";
                }
                else if (entry.State == EntityState.Modified)
                {
                    entry.Entity.ModifiedAt = now;
                    entry.Entity.ModifiedBy = "CurrentUser";
                }
            }
    
            return base.SavingChangesAsync(eventData, result, cancellationToken);
        }
    }
    
    // Register in DbContext
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder
            .UseSqlServer("connection-string")
            .AddInterceptors(new AuditingInterceptor());
    }
    

    This interceptor automatically stamps CreatedAt, CreatedBy, ModifiedAt, and ModifiedBy on all auditable entities before SaveChanges executes, centralizing audit logic in one place.

    Key points to mention

    • Multiple interceptor types: ICommandInterceptor (modify SQL), ISaveChangesInterceptor (enrich entities), IConnectionInterceptor (connection lifecycle), ITransactionInterceptor (transaction events), and others.
    • Both sync and async: Each interceptor method has synchronous and asynchronous variants; use async to avoid blocking for I/O-bound operations like logging to an external service.
    • Registered on DbContextOptions: Interceptors are configured once during context setup via AddInterceptors() and apply to all queries and commands for that context instance.
    • Inspection and modification: Before-execution interceptors can inspect and modify the operation (SQL, parameters, connection string), while after-execution interceptors can log, suppress exceptions, or enrich results.
    • Performance consideration: Interceptors run on every operation, so keep logic lightweight; avoid N+1 queries or heavy computations inside them.

    Common follow-up questions

    • How do interceptors differ from query filters? Query filters apply to LINQ queries only and generate SQL WHERE clauses; interceptors hook into the entire EF Core pipeline and work on raw commands and SaveChanges, so they're more powerful but lower-level.
    • Can I modify the SQL command inside an interceptor? Yes, in CommandInterceptor you can modify the DbCommand.CommandText and parameters before execution; this is useful for query rewriting or adding tenant filtering at the SQL level.
    • What happens if an interceptor throws an exception? By default, the exception propagates and stops the operation; you can return a suppressed result using InterceptionResult.SuppressWithResult() or InterceptionResult<T>.SuppressWithResult(value) to prevent the database operation and return a default value instead.

    Related interview questions

    Further reading

    .NETCsharpefcore
  173. 173What is the difference between Clean Architecture and N-tier?

    Experienced

    Clean Architecture and N-tier are both layered approaches, but they differ fundamentally in dependency direction. In N-tier, layers depend downward on data access and infrastructure; in Clean Architecture, all dependencies point inward toward the domain core, keeping frameworks and databases at the outer edges as interchangeable details.

    How it works

    N-tier (typically Presentation → Business Logic → Data Access → Database) is a horizontal layering pattern where upper layers depend on lower layers. The business logic layer imports data access classes, which import database-specific libraries. This creates tight coupling to infrastructure and makes it difficult to swap implementations or test without a real database.

    Clean Architecture inverts this dependency flow using the Dependency Inversion Principle. The domain layer (Entities and Use Cases) remains at the core and depends on nothing. Application services depend only on domain abstractions. Infrastructure adapters (repositories, API clients, database drivers) live at the outer circle and depend on interfaces defined by the core, not the other way around. This creates a testable, framework-agnostic business logic layer.

    The key architectural advantage is testability and flexibility. In Clean Architecture, you test domain logic without mocking database calls because the domain knows nothing about persistence. You can replace SQL Server with PostgreSQL, REST with gRPC, or add new features without modifying core business rules.

    Aspect N-tier Clean Architecture
    Dependency Direction Top-down (UI → Business → Data) Inward-pointing (all toward domain core)
    Database Coupling Business logic depends on data access layer Domain core independent; DB at edges
    Testability Requires mocks/stubs for infrastructure Domain logic tests need no infrastructure
    Framework Lock-in Business logic imports ORM, EF DbContext Core depends only on abstractions
    Typical Layers 4–5 horizontal layers (strict order) 4 circles (Entities, Use Cases, Interface Adapters, Frameworks)
    Complexity Simpler for CRUD applications Higher upfront cost; pays off in scale
    What is the difference between Clean Architecture and N-tier diagram

    Example

    csharp
    // Clean Architecture: Domain core (no dependencies)
    public class Order
    {
        public int Id { get; set; }
        public decimal Total { get; set; }
        
        public void ApplyDiscount(decimal percentage)
        {
            Total *= (1 - percentage / 100);
        }
    }
    
    // Use Case (Application layer)
    public interface IOrderRepository
    {
        Task SaveAsync(Order order);
    }
    
    public class PlaceOrderUseCase
    {
        private readonly IOrderRepository _repo;
        
        public PlaceOrderUseCase(IOrderRepository repo) => _repo = repo;
        
        public async Task ExecuteAsync(Order order)
        {
            order.ApplyDiscount(10);
            await _repo.SaveAsync(order);
        }
    }
    
    // Infrastructure (outer layer) - EF repository implementation
    public class EfOrderRepository : IOrderRepository
    {
        private readonly DbContext _db;
        public EfOrderRepository(DbContext db) => _db = db;
        
        public async Task SaveAsync(Order order)
        {
            _db.Orders.Add(order);
            await _db.SaveChangesAsync();
        }
    }
    
    // Test: no database needed
    [Test]
    public async Task PlaceOrder_AppliesDiscount()
    {
        var mockRepo = new Mock<IOrderRepository>();
        var useCase = new PlaceOrderUseCase(mockRepo.Object);
        var order = new Order { Total = 100 };
        
        await useCase.ExecuteAsync(order);
        
        Assert.That(order.Total, Is.EqualTo(90));
    }

    This example shows the domain Order class containing pure business logic with zero infrastructure knowledge, while the repository interface is defined by the use case and implemented by infrastructure—the dependency points inward.

    Key points to mention

    • Dependency Inversion: Clean Architecture uses interfaces to flip dependency direction; business logic defines what it needs, infrastructure provides it.
    • Testability: Domain logic is trivial to unit test; you never need a test database in Clean Architecture.
    • Maintainability: Business rules stay isolated from framework changes (EF version bump, database migration, API protocol switch).
    • Framework Independence: Clean Architecture treats frameworks (ASP.NET, EF, logging) as plugins; N-tier embeds them in business logic.
    • Complexity Trade-off: N-tier is faster for small CRUD apps; Clean Architecture shines at domain-driven, enterprise-scale systems (see Domain-Driven Design in .NET terms).

    Common follow-up questions

    • Isn't Clean Architecture overengineering for a simple CRUD API? — Yes, for simple projects N-tier may be faster. But consider: does your business logic ever change? If yes, Clean Architecture scales better.
    • How does Clean Architecture handle cross-cutting concerns like logging or transactions? — Use decorators, middleware, or aspect-oriented patterns at the adapter layer to keep the domain clean.
    • How does this relate to vertical slice architecture? — Vertical slice architecture organizes by feature rather than layer; it's an alternative to layering that can coexist with Clean Architecture principles.

    Related interview questions

    Further reading

    What is the difference between Clean Architecture and N-tier?
    .NETCsharpArchitecture
  174. 174What is CQRS?

    Experienced

    CQRS (Command Query Responsibility Segregation) is an architectural pattern that separates read and write operations into distinct command and query paths. Commands modify state and return nothing; queries retrieve data and cause no side effects. This separation allows you to optimize each path independently—using different models, databases, or technologies for reads versus writes.

    How it works

    Traditional applications use a single model for both reading and writing data. CQRS inverts this by creating separate concerns:

    • Commands represent intent to change state (CreateOrder, UpdateInventory). They are executed, validated, and may trigger events or side effects. The command handler processes the request and typically returns nothing or a confirmation identifier.
    • Queries retrieve current or historical data without modifying it. They bypass domain logic and often read from denormalized read models optimized for specific views.
    • Read Models are synchronized copies of data shaped specifically for query patterns. They may live in separate databases or caches, allowing independent scaling.
    • Event Sourcing (optional but common) logs every state change as an immutable event, enabling audit trails and eventual consistency between write and read models.

    The pattern works well with event-driven architectures and message buses (MediatR, NServiceBus) where commands and queries are routed through handlers.

    Example

    csharp
    // Command: intent to change state
    public record CreateOrderCommand(int CustomerId, List<OrderItem> Items) : IRequest<OrderId>;
    
    // Query: intent to retrieve data
    public record GetOrderDetailsQuery(OrderId OrderId) : IRequest<OrderDetailsDto>;
    
    // Command handler: modifies write model
    public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, OrderId>
    {
        private readonly IOrderRepository _repository;
        public async Task<OrderId> Handle(CreateOrderCommand cmd, CancellationToken ct)
        {
            var order = Order.Create(cmd.CustomerId, cmd.Items);
            await _repository.SaveAsync(order, ct);
            return order.Id;
        }
    }
    
    // Query handler: reads from optimized read model
    public class GetOrderDetailsQueryHandler : IRequestHandler<GetOrderDetailsQuery, OrderDetailsDto>
    {
        private readonly OrderReadDbContext _readDb;
        public async Task<OrderDetailsDto> Handle(GetOrderDetailsQuery query, CancellationToken ct)
        {
            return await _readDb.Orders
                .AsNoTracking()
                .Where(o => o.Id == query.OrderId)
                .Select(o => new OrderDetailsDto(o.Id, o.Total, o.Items))
                .FirstOrDefaultAsync(ct);
        }
    }
    
    // Usage
    var orderId = await mediator.Send(new CreateOrderCommand(123, items));
    var details = await mediator.Send(new GetOrderDetailsQuery(orderId));

    This example shows a command handler updating the write model (database) and a query handler reading from a denormalized read model without change tracking, demonstrating independent optimization.

    Key points to mention

    • CQRS enables independent scaling: read models can scale horizontally on cache layers while write models remain strongly consistent.
    • Read models are eventually consistent when using event sourcing; the gap depends on event propagation latency.
    • It adds complexity: multiple data stores, synchronization logic, and distributed consistency challenges. Use only when read/write patterns differ significantly.
    • Scaling reads with EF Core and denormalization are natural companions to CQRS.
    • MediatR simplifies CQRS implementation in .NET by providing a mediator pipeline for routing commands and queries to handlers.

    Common follow-up questions

    • When should you use CQRS? When read and write volumes are asymmetric, domain logic is complex, or you need audit trails and event history.
    • How do you keep read and write models in sync? Use event sourcing with event subscribers, or message-driven synchronization (publish-subscribe).
    • Is CQRS the same as event sourcing? No—CQRS separates command and query responsibilities; event sourcing logs state changes as events. They complement each other but are independent patterns.

    Related interview questions

    Further reading

    .NETCsharpArchitecture
  175. 175What is MediatR and why do teams use it?

    Experienced

    MediatR is a .NET library that implements the Mediator pattern, providing in-process request and notification handling to decouple command/query logic from their handlers. Teams use it to organize cross-cutting concerns like logging, validation, and caching through pipeline behaviors, and to enforce CQRS (Command Query Responsibility Segregation) patterns without tight coupling.

    How it works

    MediatR acts as a central dispatcher for requests and notifications. When you send a request through the mediator, it resolves the appropriate handler from your dependency injection container and executes it. Requests expect a response; notifications are fire-and-forget.

    Before the handler executes, MediatR can run pipeline behaviors—middleware-like components that wrap handler execution. This enables cross-cutting concerns (logging, validation, authorization, caching) without polluting handler code. The request pipeline order ensures predictable execution: pre-behaviors → handler → post-behaviors.

    The library uses generics and reflection to match requests to handlers at runtime, so you write minimal wiring code. Dependency injection integration is seamless, making handler injection straightforward.

    Example

    csharp
    // Define a request and response
    public record CreateUserCommand(string Name, string Email) : IRequest<UserResponse>;
    
    public record UserResponse(int Id, string Name, string Email);
    
    // Define the handler
    public class CreateUserCommandHandler : IRequestHandler<CreateUserCommand, UserResponse>
    {
        private readonly IUserRepository _repository;
        
        public CreateUserCommandHandler(IUserRepository repository) => _repository = repository;
        
        public async Task<UserResponse> Handle(CreateUserCommand request, CancellationToken cancellationToken)
        {
            var user = new User { Name = request.Name, Email = request.Email };
            var created = await _repository.AddAsync(user, cancellationToken);
            return new UserResponse(created.Id, created.Name, created.Email);
        }
    }
    
    // Usage in a controller
    public class UsersController : ControllerBase
    {
        private readonly IMediator _mediator;
        
        public UsersController(IMediator mediator) => _mediator = mediator;
        
        [HttpPost]
        public async Task<ActionResult<UserResponse>> Create(CreateUserCommand command, CancellationToken ct)
        {
            var result = await _mediator.Send(command, ct);
            return CreatedAtAction(nameof(Create), result);
        }
    }

    The controller sends a CreateUserCommand through the mediator, which automatically resolves and invokes the handler without the controller knowing handler details, enabling loose coupling.

    Key points to mention

    • Decoupling: Handlers are isolated from request sources; controllers don't reference handler logic directly.
    • Pipeline behaviors: Cross-cutting concerns (logging, validation, error handling, caching) are applied consistently via behaviors, not duplicated across handlers.
    • CQRS support: Commands and queries are naturally separated; different handlers process writes vs. reads.
    • Testability: Mocking the mediator in tests is straightforward; handlers are pure, focused functions.
    • Indirection trade-off: While it improves organization, MediatR adds a layer of indirection; use it where it genuinely reduces complexity, not everywhere.

    Common follow-up questions

    • How do pipeline behaviors work, and what order do they execute in?
    • What's the difference between sending requests and publishing notifications in MediatR?
    • When should you not use MediatR—what are scenarios where it adds unnecessary overhead?

    Related interview questions

    Further reading

    .NETCsharpArchitecture
  176. 176What are pipeline behaviors?

    Experienced

    Pipeline behaviors are interceptors in MediatR that wrap request handlers to apply cross-cutting concerns—such as validation, logging, caching, and transaction management—before and after handler execution. They follow the decorator pattern and execute in a chain, allowing you to separate infrastructure concerns from business logic and apply policies consistently across multiple handlers without duplicating code.

    What are pipeline behaviors diagram

    How it works

    Pipeline behaviors implement IPipelineBehavior<TRequest, TResponse> and intercept the request flow between the MediatR dispatcher and the actual handler. When a request is sent, MediatR passes it through each registered behavior in order. Each behavior receives a handle delegate pointing to the next behavior (or the handler itself). This creates a chain-of-responsibility pattern where each behavior decides whether to pass control forward, short-circuit, modify the request/response, or apply side effects.

    The execution order follows registration order: behaviors registered first execute outermost (run their pre-logic first, post-logic last). This allows you to stack concerns logically—for example, transaction management wraps validation, which wraps the actual business logic.

    Example

    csharp
    public class LoggingBehavior<TRequest, TResponse> 
        : IPipelineBehavior<TRequest, TResponse>
        where TRequest : IRequest<TResponse>
    {
        private readonly ILogger<LoggingBehavior<TRequest, TResponse>> _logger;
    
        public LoggingBehavior(ILogger<LoggingBehavior<TRequest, TResponse>> logger)
            => _logger = logger;
    
        public async Task<TResponse> Handle(
            TRequest request,
            RequestHandlerDelegate<TResponse> next,
            CancellationToken cancellationToken)
        {
            var requestName = typeof(TRequest).Name;
            _logger.LogInformation("Handling {RequestName}", requestName);
    
            try
            {
                var response = await next();
                _logger.LogInformation("Handled {RequestName} successfully", requestName);
                return response;
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Error handling {RequestName}", requestName);
                throw;
            }
        }
    }
    
    // Registration in Program.cs
    services.AddMediatR(config =>
        config.RegisterServicesFromAssemblyContaining<Program>()
              .AddOpenBehavior(typeof(LoggingBehavior<,>)));
    

    This logging behavior wraps every request handler: it logs before execution, awaits the next behavior/handler, logs the result, and re-throws any exceptions—all without modifying handler code.

    Key points to mention

    • Decorator pattern: Behaviors wrap handlers without changing their interfaces, enabling open/closed principle compliance.
    • Execution order: Behaviors execute in registration order; the first registered behavior is outermost, running its pre-logic before others.
    • Generic and open: Use IPipelineBehavior<TRequest, TResponse> for specific request types or open generics (typeof(Behavior<,>)) to apply to all requests.
    • Common use cases: Validation, logging, performance monitoring, caching, transaction/unit-of-work management, and authorization.
    • Testability: Behaviors are independently testable; you can test them in isolation without coupling to handlers.

    Common follow-up questions

    • How do you handle conditional behavior execution or skip behaviors for certain request types?
    • What is the difference between pipeline behaviors and middleware?
    • How would you implement caching or validation behaviors, and what order should they run in?

    Steps to implement and use pipeline behaviors

    1. Create the behavior class: Implement IPipelineBehavior<TRequest, TResponse> with a constructor accepting dependencies (logger, validator, etc.) and a Handle method.
    2. Implement the Handle method: Apply pre-logic, await next() to pass control to the next behavior or handler, then apply post-logic (logging results, exceptions, side effects).
    3. Register with dependency injection: In Program.cs, call services.AddMediatR() and chain .AddOpenBehavior(typeof(YourBehavior<,>)) for generic or specific registrations for concrete types.
    4. Verify execution order: Test that behaviors run in the expected sequence (e.g., validation before transactions) by inspecting logs or test assertions.
    5. Test in isolation: Unit test each behavior with mock delegates and verify it correctly handles successful and exceptional cases.

    Related interview questions

    Further reading

    What are pipeline behaviors?
    .NETCsharp
  177. 177How do you validate requests with FluentValidation?

    Experienced

    FluentValidation allows you to define validation rules declaratively using a fluent API in separate validator classes, then execute them via dependency injection and either a MediatR pipeline behavior or an ASP.NET Core filter. This keeps validation logic out of your domain models and handlers, making it testable, reusable, and maintainable.

    How it works

    FluentValidation works by separating validation concerns from your business logic. You create a validator class that inherits from AbstractValidator<T> where T is the request or entity type you're validating. Inside the validator's constructor, you define rules using a fluent API: RuleFor(x => x.Property) chains methods like NotEmpty(), MaximumLength(), Matches(), and Custom() to express constraints.

    When integrated with MediatR, you register validators and add a validation behavior to the pipeline. This behavior runs before your handler executes, collecting all validation errors into a ValidationException. You then map that exception to a standardized ProblemDetails response in your exception handler or middleware. This approach also works with ASP.NET Core model binding filters or custom middleware for non-MediatR scenarios.

    Example

    csharp
    // Request/Command
    public class CreateUserCommand
    {
        public string Email { get; set; }
        public string Name { get; set; }
        public int Age { get; set; }
    }
    
    // Validator
    public class CreateUserCommandValidator : AbstractValidator<CreateUserCommand>
    {
        public CreateUserCommandValidator()
        {
            RuleFor(x => x.Email)
                .NotEmpty().WithMessage("Email is required")
                .EmailAddress().WithMessage("Email format is invalid");
    
            RuleFor(x => x.Name)
                .NotEmpty().WithMessage("Name is required")
                .MaximumLength(100).WithMessage("Name must not exceed 100 characters");
    
            RuleFor(x => x.Age)
                .InclusiveBetween(18, 120).WithMessage("Age must be between 18 and 120");
        }
    }
    
    // Register in Program.cs
    builder.Services.AddValidatorsFromAssembly(typeof(Program).Assembly);
    builder.Services.AddMediatR(config =>
    {
        config.RegisterServicesFromAssembly(typeof(Program).Assembly);
        config.AddOpenBehavior(typeof(ValidationBehavior<,>));
    });
    
    // MediatR Pipeline Behavior
    public class ValidationBehavior<TRequest, TResponse> 
        : IPipelineBehavior<TRequest, TResponse>
        where TRequest : notnull
    {
        private readonly IEnumerable<IValidator<TRequest>> _validators;
    
        public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
        {
            _validators = validators;
        }
    
        public async Task<TResponse> Handle(TRequest request, 
            RequestHandlerDelegate<TResponse> next, CancellationToken ct)
        {
            var context = new ValidationContext<TRequest>(request);
            var failures = (await Task.WhenAll(
                _validators.Select(v => v.ValidateAsync(context, ct))))
                .SelectMany(r => r.Errors)
                .Where(f => f != null)
                .ToList();
    
            if (failures.Count != 0)
            {
                throw new ValidationException(failures);
            }
    
            return await next();
        }
    }
    
    // Exception handler
    app.UseExceptionHandler(exceptionHandlerApp =>
    {
        exceptionHandlerApp.Run(async context =>
        {
            var exception = context.Features.Get<IExceptionHandlerFeature>()?.Error;
            
            if (exception is ValidationException vex)
            {
                context.Response.StatusCode = StatusCodes.Status400BadRequest;
                await context.Response.WriteAsJsonAsync(new ProblemDetails
                {
                    Type = "https://example.com/problems/validation-error",
                    Title = "One or more validation errors occurred.",
                    Status = 400,
                    Extensions = new Dictionary<string, object?>
                    {
                        { "errors", vex.Errors
                            .GroupBy(e => e.PropertyName)
                            .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray()) }
                    }
                });
            }
        });
    });

    This example defines a validator for a user creation command, registers it with MediatR, injects it into a pipeline behavior that runs before the handler, and returns validation errors as a structured ProblemDetails response.

    Key points to mention

    • Separation of concerns: Validators live in separate classes, keeping domain models and handlers clean and focused.
    • Reusability: A single validator can be used across MediatR handlers, API controllers, and background jobs by injecting it directly.
    • Composability: Validators can inherit from and compose other validators, and you can use Include() to reuse rule sets.
    • Async validation: Rules support MustAsync() and Custom() with async callbacks for database or external API checks.
    • Localization: Error messages are easily localized via resource files or message factories.

    Common follow-up questions

    • How do you handle cross-property validation or conditional rules? Use RuleFor(x => x.Property).Must((model, value) => ...) or When() conditions to validate based on other property values.
    • Can you validate nested objects or collections? Yes, use RuleForEach(x => x.Items).SetValidator(new ItemValidator()) or RuleFor(x => x.Address).SetValidator(new AddressValidator()).
    • What's the difference between validation and enforcing aggregate invariants? Validation ensures input correctness (format, range); invariants ensure domain consistency (business rules, state transitions) and typically live in your aggregate or domain service.

    Related interview questions

    Further reading

    .NETCsharpaspnetcore
  178. 178What is the Result pattern?

    Experienced

    The Result pattern is a functional programming approach where methods return a Result object that contains either a success value or an error information, instead of throwing exceptions for expected failures. This makes error handling explicit, composable, and more efficient by treating failures as ordinary return values rather than exceptional control flow.

    How it works

    The Result pattern encapsulates the outcome of an operation in a type-safe container. A Result is typically a discriminated union (or sealed type hierarchy) with two states: Success (containing a value) and Failure (containing error details). Instead of relying on exceptions for recoverable errors, code returns a Result and the caller pattern-matches or unwraps it to handle both paths.

    This approach separates concerns: exceptions remain for truly exceptional situations (null references, out-of-memory), while expected failures (validation errors, business rule violations, API failures) flow through the Result type. Result types compose well with LINQ, enable railway-oriented programming, and reduce the cognitive overhead of exception handling. They also avoid the performance cost of stack unwinding for expected errors.

    Modern C# supports this well through pattern matching, allowing clean, readable syntax for working with Results.

    Example

    csharp
    public abstract record Result<T>
    {
        public sealed record Success(T Value) : Result<T>;
        public sealed record Failure(string Error) : Result<T>;
    }
    
    public static class ResultExtensions
    {
        public static Result<TOut> Bind<T, TOut>(
            this Result<T> result,
            Func<T, Result<TOut>> f) =>
            result switch
            {
                Result<T>.Success(var value) => f(value),
                Result<T>.Failure(var error) => new Result<TOut>.Failure(error),
                _ => throw new InvalidOperationException()
            };
    }
    
    public class UserService
    {
        public Result<User> GetUser(int id) =>
            id <= 0
                ? new Result<User>.Failure("Invalid user ID")
                : new Result<User>.Success(new User { Id = id, Name = "John" });
    
        public Result<string> UpdateUserEmail(int id, string email) =>
            GetUser(id)
                .Bind(user => ValidateEmail(email)
                    .Bind(validEmail => new Result<string>.Success($"User {user.Name} updated")));
    
        private Result<string> ValidateEmail(string email) =>
            string.IsNullOrEmpty(email)
                ? new Result<string>.Failure("Email is required")
                : new Result<string>.Success(email);
    }
    
    // Usage
    var result = new UserService().UpdateUserEmail(1, "test@example.com");
    var message = result switch
    {
        Result<string>.Success(var value) => $"Success: {value}",
        Result<string>.Failure(var error) => $"Error: {error}",
    };
    

    This demonstrates a composable Result type using records and pattern matching to chain operations elegantly without throwing exceptions.

    Key points to mention

    • Explicit error handling: Callers see that a method can fail and must decide how to handle it, making bugs less likely.
    • Composability: Result types work well with monadic operators like Bind and Map, enabling fluent chaining of operations.
    • Performance: Returning error values is faster than exception handling's stack unwinding and allocation overhead.
    • Exceptions still matter: Use exceptions for truly exceptional, unrecoverable conditions; use Result for expected, recoverable failures.
    • Type safety: Languages with discriminated unions (C# records, Rust) make Result patterns safe and ergonomic.

    Common follow-up questions

    • How does the Result pattern differ from throwing exceptions for error handling?
    • What libraries implement Result patterns in .NET (FluentResults, LanguageExt, CSharpFunctionalExtensions)?
    • How do you handle multiple chained Result operations and short-circuit on the first failure?

    Related interview questions

    Further reading

    .NETCsharpArchitecture
  179. 179What is vertical slice architecture?

    Experienced

    Vertical slice architecture organizes code by feature rather than by technical layer. Instead of grouping all controllers, services, and repositories together, each feature (or slice) is self-contained and includes its own endpoint, business logic, validation, and data access. This approach reduces coupling and makes features easier to understand, test, and modify independently.

    How it works

    In traditional layered architecture, a request flows through multiple layers: presentation → business logic → data access. Code for the same feature is scattered across different folders and projects. Vertical slice architecture flips this perspective—imagine slicing a cake vertically so each slice contains all the layers needed for one feature.

    Each slice is organized by business domain or feature (e.g., CreateOrder, GetProductDetails, UpdateUserProfile). Within each slice, you define everything needed: the endpoint handler, request/response models, validation logic, and database queries. Slices are independent units that can be developed, tested, and deployed with minimal impact on other features.

    This architecture aligns well with Minimal APIs and the mediator pattern (CQRS), where each request is routed to a specific handler. It also encourages high cohesion—related code lives together—while maintaining loose coupling between features.

    Example

    csharp
    // Features/Orders/CreateOrder/CreateOrderRequest.cs
    public record CreateOrderRequest(string CustomerId, List Items);
    
    // Features/Orders/CreateOrder/CreateOrderHandler.cs
    public class CreateOrderHandler
    {
        private readonly IOrderRepository _repository;
        public CreateOrderHandler(IOrderRepository repository) => _repository = repository;
        
        public async Task Handle(CreateOrderRequest request)
        {
            var order = new Order { CustomerId = request.CustomerId, Items = request.Items };
            await _repository.SaveAsync(order);
            return new OrderResponse(order.Id, order.Status);
        }
    }
    
    // Features/Orders/CreateOrder/CreateOrderValidator.cs
    public class CreateOrderValidator : AbstractValidator
    {
        public CreateOrderValidator()
        {
            RuleFor(x => x.CustomerId).NotEmpty();
            RuleFor(x => x.Items).NotEmpty();
        }
    }
    
    // Program.cs - endpoint registration
    app.MapPost("/orders", async (CreateOrderRequest req, CreateOrderHandler handler) =>
    {
        var validator = new CreateOrderValidator();
        var validation = await validator.ValidateAsync(req);
        if (!validation.IsValid) return Results.BadRequest(validation.Errors);
        
        var result = await handler.Handle(req);
        return Results.Created($"/orders/{result.Id}", result);
    });
    

    This example shows a CreateOrder slice containing the request model, handler, validator, and endpoint registration—all the code needed for that feature in one place.

    Key points to mention

    • High cohesion, loose coupling: Features are self-contained and independent, making the codebase easier to navigate and refactor.
    • Scalability: New features can be added without modifying existing code; teams can work on different slices in parallel.
    • Testability: Each slice can be unit tested in isolation without dependencies on other features.
    • Works well with Minimal APIs and CQRS: Natural fit for handling individual requests through dedicated handlers and mediators.
    • Prevents the "shared services trap": Discourages creating bloated, god-like service classes that handle unrelated business logic.

    Common follow-up questions

    • How do you share code between slices without creating coupling?
    • How does vertical slice architecture compare to Clean Architecture?
    • What happens when slices need to communicate or share database entities?

    Related interview questions

    Further reading

    .NETCsharpArchitecture
  180. 180What is a modular monolith?

    Experienced

    A modular monolith is a single, deployable application organized into well-bounded, independent modules—each with its own data storage, business logic, and clearly defined public contracts. It provides microservice-like organizational benefits and loose coupling without the operational complexity of distributed systems, making it an ideal intermediate step between a traditional monolith and microservices.

    How it works

    In a modular monolith, the application is divided into feature-based or domain-driven modules that communicate through well-defined interfaces rather than shared databases. Each module owns its data and exposes only a public API (contracts or interfaces) to other modules. Communication typically occurs in-process via dependency injection or event-driven patterns, avoiding network calls.

    The key principle is independence without distribution: modules can be developed, tested, and reasoned about in isolation, but they run in the same process and memory space. This means you gain architectural clarity and maintainability without the latency, eventual consistency, or deployment complexity of microservices. Each module is also easier to extract into a microservice later if scaling or organizational needs demand it.

    Data separation is enforced through separate schemas, separate database instances (logical or physical), or a shared database with strict access controls. Communication patterns typically include synchronous in-process calls, asynchronous events via a message bus, or both.

    Example

    csharp
    // OrderModule - public contract
    public interface IOrderService
    {
        Task<OrderResponse> CreateOrderAsync(CreateOrderRequest request);
    }
    
    public class OrderService : IOrderService
    {
        private readonly OrderDbContext _dbContext;
        private readonly IEventPublisher _eventPublisher;
    
        public async Task<OrderResponse> CreateOrderAsync(CreateOrderRequest request)
        {
            var order = new Order { CustomerId = request.CustomerId, Total = request.Total };
            _dbContext.Orders.Add(order);
            await _dbContext.SaveChangesAsync();
    
            // Publish domain event—other modules subscribe
            await _eventPublisher.PublishAsync(new OrderCreatedEvent { OrderId = order.Id });
            return new OrderResponse { OrderId = order.Id, Status = "Created" };
        }
    }
    
    // InventoryModule - listens to events
    public class InventoryEventHandler
    {
        private readonly IInventoryService _inventoryService;
    
        public async Task HandleOrderCreatedAsync(OrderCreatedEvent evt)
        {
            // React asynchronously without direct coupling
            await _inventoryService.ReserveItemsAsync(evt.OrderId);
        }
    }
    
    // Startup - register modules
    services.AddScoped<IOrderService, OrderService>();
    services.AddScoped<IInventoryService, InventoryService>();
    services.AddEventPublisher(); // Shared event bus, in-process
    

    This example shows two modules—Order and Inventory—communicating through an event-driven contract rather than shared tables or HTTP calls, maintaining independence while staying in the same process.

    Key points to mention

    • Single deployment unit: The entire modular monolith is deployed together, avoiding coordination overhead and distributed transaction complexity.
    • Data ownership: Each module has its own data store or schema, preventing tight coupling and enabling independent evolution.
    • Clear contracts: Modules expose only interfaces and events, not internal domain models, encouraging loose coupling.
    • Stepping stone to microservices: A well-structured modular monolith can be decomposed into microservices when scale or organizational boundaries demand it.
    • In-process communication: No network overhead; modules interact via method calls or local event buses, keeping latency low and debugging simple.

    Common follow-up questions

    • How do you enforce module boundaries and prevent circular dependencies? (Answer: dependency graphs, module facades, and code organization; some teams use NDepend or Roslyn analyzers.)
    • When should you move from a modular monolith to microservices? (Answer: when scaling, team autonomy, or deployment frequency demands it; not before you have clear module boundaries.)
    • How do you handle transactions across modules? (Answer: use saga patterns or compensating transactions for async workflows; keep sync operations within a single module.)

    Related interview questions

    Further reading

    .NETCsharpArchitecture
  181. 181How do you map objects in .NET?

    Experienced

    Object mapping in .NET transforms data from one object type to another—commonly converting domain models to DTOs, API responses to entities, or database records to view models. You can use manual mapping, reflection-based libraries like AutoMapper, or modern source-generated mappers like Mapperly, each with trade-offs between explicitness, performance, and maintainability.

    How it works

    Object mapping bridges the gap between different layers of your application. When data flows between your database, business logic, and API response layers, the shapes often differ. Mapping copies values from a source object to a target object, transforming the structure and sometimes the data itself.

    Manual mapping means writing assignment code by hand—explicit but verbose. Reflection-based mappers like AutoMapper inspect types at runtime, discovering properties and creating mappings on-the-fly; this adds convenience but runtime overhead. Source-generated mappers like Mapperly analyze your code at compile-time and generate optimized mapping code, combining the performance of manual mapping with the convenience of convention-based tools. Generated code is visible and debuggable, making issues easier to trace.

    Most modern .NET projects use a combination: manual mapping for complex business logic transformations, and source-generated or reflection-based mappers for straightforward property-to-property conversions.

    Example

    csharp
    // Domain model
    public class User
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public string Email { get; set; }
    }
    
    // DTO
    public class UserDto
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public string Email { get; set; }
    }
    
    // Manual mapping
    public class UserMapper
    {
        public static UserDto MapToDto(User user)
            => new UserDto
            {
                Id = user.Id,
                Name = user.Name,
                Email = user.Email
            };
    }
    
    // Using Mapperly (source-generated)
    [Mapper]
    public partial class UserAutoMapper
    {
        public partial UserDto MapToDto(User user);
    }
    
    // Usage
    var user = new User { Id = 1, Name = "Alice", Email = "alice@example.com" };
    var dto = UserMapper.MapToDto(user); // Manual
    // or
    var mapper = new UserAutoMapper();
    var dto = mapper.MapToDto(user); // Generated
    

    This example shows three approaches: manual mapping (explicit control), and Mapperly (attribute-based source generation with zero runtime overhead). Both produce fast, predictable results.

    Key points to mention

    • Manual mapping is most explicit and fastest; use it for complex transformations or performance-critical code paths.
    • Source-generated mappers (Mapperly, Riok.Mapperly) offer performance similar to manual code but with less boilerplate; generated code is debuggable and transparent.
    • Reflection-based mappers (AutoMapper) provide convenience and reduce boilerplate but incur runtime reflection overhead and can mask breaking changes until runtime.
    • Flatten or unflatten hierarchical data structures—convert nested objects to flat DTOs and vice versa, often a key responsibility of mappers.
    • Consider flattening for DTOs; deeply nested API responses are harder to consume, and mappers handle this translation cleanly.

    Common follow-up questions

    • What is the performance difference between manual mapping, AutoMapper, and Mapperly?
    • How do you handle circular references or complex nested object graphs during mapping?
    • When would you use a mapper versus a factory pattern or a constructor that accepts another object?

    Related interview questions

    .NETCsharp
  182. 182What are source generators?

    Experienced

    Source generators are compile-time tools that analyze your code and automatically generate new C# source files during compilation. They run in the compiler pipeline before the CLR executes, eliminating the need for runtime reflection in many scenarios and producing faster, more efficient code.

    How it works

    Source generators are .NET libraries that implement ISourceGenerator or IIncrementalGenerator (the newer, more efficient API). When you compile your project, the compiler invokes these generators with access to your syntax trees and semantic models. The generators analyze your code—looking for specific attributes, patterns, or types—and produce new C# source code that the compiler then includes in the compilation.

    The process is entirely compile-time: no reflection happens at runtime. The generated code is as if you had written it manually, but you didn't have to. This is transformative for performance-critical scenarios like JSON serialization, where System.Text.Json source generation can eliminate thousands of reflection calls per serialization operation.

    Generators see your code through Roslyn analyzers—they get syntax trees, symbols, and semantic information—allowing them to make intelligent decisions about what code to generate. You reference generators via NuGet packages, and they activate automatically when you include them in your project.

    Example

    csharp
    using System.Text.Json.Serialization;
    
    [JsonSerializable(typeof(Person))]
    public partial class PersonContext : JsonSerializerContext { }
    
    public class Person
    {
        public string Name { get; set; }
        public int Age { get; set; }
    }
    
    // Usage
    var person = new Person { Name = "Alice", Age = 30 };
    var json = JsonSerializer.Serialize(person, PersonContext.Default.Person);
    

    The [JsonSerializable] attribute signals the source generator to create fast, reflection-free serialization code for the Person type—you never see the generated code, but the compiler includes it in the build.

    Key points to mention

    • Compile-time execution: Generators run during compilation, not at runtime, eliminating reflection overhead and improving performance.
    • Zero runtime cost: The generated code is static and compiled into your assembly—there's no dynamic invocation or discovery cost.
    • IDE integration: Modern IDEs support generators, showing generated code and enabling IntelliSense on generated members.
    • Incremental generation: The newer IIncrementalGenerator API caches results and only regenerates when dependencies change, speeding up rebuilds.
    • Common use cases: JSON serialization, logging, regex patterns, dependency injection, and object mapping frameworks all benefit significantly.

    Common follow-up questions

    • How do you debug a source generator? — Attach a debugger to the compiler process or use [System.Diagnostics.Debugger.Launch()] in your generator's Execute method.
    • Can you write your own source generator? — Yes; create a library targeting netstandard2.0 that implements IIncrementalGenerator, register it in a .csproj with <CompilerVisibleProperty> items, and distribute via NuGet.
    • How do they differ from reflection? — Generators produce static, compiled code with zero runtime overhead; reflection discovers types and members at runtime, incurring performance penalties every time it runs.

    Related interview questions

    Further reading

    .NETCsharp
  183. 183How does System.Text.Json source generation help?

    Experienced

    System.Text.Json source generation uses a compile-time code generator to create serialization logic ahead of time, eliminating reflection overhead and enabling trimming and Native AOT support. By deriving a class from JsonSerializerContext and marking it with [JsonSourceGenerationOptions], you instruct the compiler to generate optimized serialization code for your types during the build process, dramatically improving startup performance and throughput in constrained environments.

    How it works

    System.Text.Json source generation leverages source generators to analyze your types at compile time and emit IL-equivalent serialization methods directly into your assembly. When you create a JsonSerializerContext subclass and apply the [JsonSerializerContext] attribute, the Roslyn compiler examines the context's generic type parameters (your DTO classes) and generates specialized serialization and deserialization code tailored to each type.

    This contrasts sharply with reflection-based serialization, which discovers type structure at runtime. The generated code is monolithic, fast, and deterministic—there are no dynamic method invocations or repeated reflection calls. The compiler also performs tree-shaking: only types and properties you explicitly register in the context are included in the final binary, reducing code size and enabling full Native AOT compatibility.

    The process is transparent: you pass your context instance to JsonSerializer.Serialize() or Deserialize() overloads, and the runtime automatically uses the generated fast paths instead of falling back to reflection.

    Example

    csharp
    using System.Text.Json;
    using System.Text.Json.Serialization;
    
    public class Person
    {
        public string Name { get; set; }
        public int Age { get; set; }
    }
    
    [JsonSerializerContext]
    [JsonSourceGenerationOptions(WriteIndented = true)]
    public partial class AppJsonSerializerContext : JsonSerializerContext
    {
        public override JsonTypeInfo GetTypeInfo(Type type)
        {
            if (type == typeof(Person))
                return GetTypeInfo(typeof(Person)) as JsonTypeInfo;
            return base.GetTypeInfo(type);
        }
    }
    
    class Program
    {
        static void Main()
        {
            var person = new Person { Name = "Alice", Age = 30 };
            var options = new JsonSerializerOptions { TypeInfoResolver = new AppJsonSerializerContext() };
            
            string json = JsonSerializer.Serialize(person, options);
            var deserialized = JsonSerializer.Deserialize(json, options);
            
            Console.WriteLine(json);
        }
    }

    This example defines a context for Person and uses it during serialization; the compiler generates optimized code that avoids reflection entirely.

    Key points to mention

    • Compile-time code generation: Serialization logic is baked into the assembly as regular IL, eliminating runtime reflection and JIT costs.
    • Startup and throughput gains: Especially significant in containerized or serverless scenarios where cold starts matter; throughput can improve 2–5× for large payloads.
    • Native AOT and trimming: Source generation is the primary enabler for JSON serialization in .NET applications compiled with Native AOT, since reflection is unavailable at runtime.
    • Explicit registration: Only types added to the context are generated; this provides control but requires upfront declaration of all serialized types.
    • Backward compatible: You can still use reflection-based JsonSerializerOptions as a fallback; the context simply provides a faster path.

    Common follow-up questions

    • What happens if I serialize a type not in the context? By default, the runtime falls back to reflection. You can configure JsonSerializerOptions.TypeInfoResolver to throw or handle unknown types explicitly.
    • Can source generation handle inheritance and polymorphism? Yes, but you must explicitly register derived types and configure a discriminator via attributes like [JsonDerivedType].
    • Does this work with ASP.NET Core serialization? Yes; ASP.NET Core 8+ automatically uses source-generated contexts for controller responses when available, and you can inject a custom context in dependency injection.

    Related interview questions

    Further reading

    .NETCsharpPerformanceDevOps
  184. 184What is high-performance logging with LoggerMessage?

    Experienced

    LoggerMessage is a code-generation feature in .NET that uses the [LoggerMessage] attribute to create strongly-typed, compile-time-generated logging methods that eliminate boxing, string template parsing, and allocations on each call. This makes logging in high-performance scenarios (hot paths) significantly faster and more efficient than traditional logger.LogInformation() calls.

    How it works

    When you decorate a partial method with [LoggerMessage], the .NET source generator analyzes the method signature and log message template at compile time. It then generates an optimized implementation that:

    • Parses the message template once during code generation, not at runtime
    • Pre-computes parameter names and positions
    • Avoids boxing of value types by using generics and struct parameters directly
    • Generates an efficient state object for structured logging without intermediate allocations

    The generated code is essentially hand-optimized logging logic, eliminating the overhead that occurs with reflection-based approaches. Traditional logging calls like logger.LogInformation("User {UserId} logged in", userId) parse the template string at runtime, potentially box the userId integer, and allocate a parameters array. LoggerMessage generates a bespoke method for each call site.

    Example

    csharp
    public static partial class Log
    {
        [LoggerMessage(Level = LogLevel.Information, Message = "User {UserId} logged in from {IpAddress}")]
        public static partial void UserLoggedIn(this ILogger logger, int userId, string ipAddress);
    
        [LoggerMessage(Level = LogLevel.Error, Message = "Database error: {ErrorCode}")]
        public static partial void DatabaseError(this ILogger logger, int errorCode, Exception ex);
    }
    
    // Usage in your application
    var logger = app.Services.GetRequiredService<ILogger<Program>>();
    logger.UserLoggedIn(userId: 42, ipAddress: "192.168.1.1");
    logger.DatabaseError(errorCode: 5000, new SqlException());
    

    The source generator creates the full implementation of UserLoggedIn and DatabaseError methods at compile time, making the calls as efficient as possible with zero-allocation logging in most scenarios.

    Key points to mention

    • Compile-time code generation: The method body is generated by a Roslyn source generator, so there's no runtime reflection or parsing overhead.
    • Zero boxing for value types: Unlike params object[] approaches, LoggerMessage methods accept strongly-typed parameters directly, avoiding boxing and unboxing.
    • Structured logging friendly: Integrates seamlessly with Serilog, Application Insights, and other structured logging frameworks, extracting key-value pairs automatically.
    • Performance in hot paths: Ideal for scenarios where logging is called millions of times per second (request handlers, real-time systems, or tight loops).
    • Requires .NET 6+: Source generators were introduced in .NET 5, but LoggerMessage attribute was formalized in .NET 6 for broader support.

    Common follow-up questions

    • When should I use LoggerMessage vs. traditional logging? — Use LoggerMessage in hot paths and performance-critical code; traditional logging is fine for non-critical paths where overhead is negligible.
    • Can LoggerMessage work with dynamic templates? — No; the template must be a compile-time constant string, so you cannot pass templates from configuration at runtime.
    • What happens if the log level is not enabled? — The generated code checks log level first, so if LogLevel.Information is disabled, the method exits immediately without allocating or processing arguments.

    Related interview questions

    Further reading

    .NETCsharpPerformance
  185. 185What is structured logging?

    Experienced

    Structured logging is a logging approach where you record data as named key-value properties rather than concatenating everything into a single text string. This allows log aggregation tools like Seq, Elasticsearch, or Application Insights to parse, filter, search, and aggregate logs by individual fields—making troubleshooting and analysis far more powerful than unstructured plain-text logs.

    How it works

    Instead of writing a log message like "Order 12345 processed for customer John Doe at 2:45 PM," structured logging captures discrete properties: OrderId = 12345, CustomerId = "john_doe", Timestamp = 2025-01-15T14:45:00Z. These properties are serialized (typically as JSON) and sent to a log aggregation platform. The platform indexes each field, enabling you to query logs like "show me all errors where OrderId = 12345" or "average processing time by CustomerId" without parsing text.

    Structured logging is built into ASP.NET Core via ASP.NET Core's logging framework using log scopes and the ILogger interface. When you pass anonymous objects or use LogContext, the logger automatically captures those fields as properties in the log event.

    Example

    csharp
    using Microsoft.Extensions.Logging;
    using Serilog;
    using Serilog.Core;
    
    var logger = new LoggerConfiguration()
        .WriteTo.Console()
        .CreateLogger();
    
    int orderId = 12345;
    string customerId = "cust_001";
    decimal amount = 99.99m;
    int retryCount = 2;
    
    // Structured logging with named properties
    logger.Information(
        "Order processed successfully. OrderId: {OrderId}, CustomerId: {CustomerId}, Amount: {Amount}, Retries: {RetryCount}",
        orderId,
        customerId,
        amount,
        retryCount
    );
    
    // Output (JSON):
    // {"Timestamp":"2025-01-15T14:45:00.0000000Z","Level":"Information","MessageTemplate":"Order processed...","Properties":{"OrderId":12345,"CustomerId":"cust_001","Amount":99.99,"RetryCount":2}}
    

    This example uses Serilog (a popular structured logging library for .NET) to log an order with named placeholders in curly braces; each property is captured as a separate field in the JSON output, allowing tools to query by OrderId, Amount, or RetryCount independently.

    Key points to mention

    • Machine-readable format: Structured logs are typically JSON, making them easily parseable and queryable by aggregation platforms rather than requiring regex or text parsing.
    • Performance and searchability: Indexed fields enable fast filtering and aggregation queries; finding all errors for a specific OrderId is orders of magnitude faster than grepping unstructured text logs.
    • Correlation and tracing: Structured logging makes it easy to add correlation IDs or request IDs to every log entry, enabling end-to-end request tracing across services.
    • Extensibility: You can add contextual properties (user ID, tenant ID, environment) to every log in a scope without repeating them in each message.
    • Serilog and Seq: Serilog is the de facto standard for structured logging in .NET; Seq is a dedicated server for ingesting, searching, and alerting on structured logs.

    Common follow-up questions

    • "How do you add correlation IDs to logs across multiple requests?" — Use LogContext.PushProperty("CorrelationId", id) or middleware to inject a correlation ID into the logging context so every log in that request automatically includes it.
    • "What is the performance overhead of structured logging?" — Minimal if you use a buffered or asynchronous sink; the main cost is JSON serialization, which is negligible compared to network I/O to the aggregation server.
    • "How do you decide what properties to log?" — Log business-critical identifiers (OrderId, UserId, TenantId), timing information (duration, retries), and any value needed to reproduce or diagnose the issue; avoid logging sensitive data like passwords or PII.

    Related interview questions

    Further reading

    .NETCsharpaspnetcoreArchitecture
  186. 186What is OpenTelemetry in .NET?

    Experienced

    OpenTelemetry is a vendor-neutral, open standard framework for collecting telemetry data—traces, metrics, and logs—from .NET applications. It provides standardized APIs and SDKs that allow developers to instrument code once and export data to any observability backend (Jaeger, Prometheus, Grafana, Datadog, etc.) without vendor lock-in. In .NET, the core components are ActivitySource for distributed tracing and Meter for metrics collection.

    How it works

    OpenTelemetry operates on a three-pillar model: traces, metrics, and logs. Traces capture the flow of requests across services and components, recording timing and events. Metrics measure numerical values like request count, latency, and CPU usage. Logs provide detailed event information.

    In .NET, you instrument your code using System.Diagnostics.ActivitySource (for tracing) and System.Diagnostics.Metrics.Meter (for metrics). These APIs emit telemetry events that are collected by providers—components that listen for these signals. Providers format and export data via exporters, typically using the OpenTelemetry Protocol (OTLP), which sends structured data to a backend like Jaeger or Prometheus.

    The architecture separates concerns: instrumentation (your code) is decoupled from collection (providers) and export (exporters). This means you write instrument code once, then configure exporters based on your observability stack, without changing application code.

    Example

    csharp
    using System.Diagnostics;
    using OpenTelemetry.Trace;
    using OpenTelemetry.Metrics;
    
    var tracerProvider = new TracerProviderBuilder()
        .AddSource("MyApp")
        .AddConsoleExporter()
        .Build();
    
    var meterProvider = new MeterProviderBuilder()
        .AddMeter("MyApp")
        .AddConsoleExporter()
        .Build();
    
    var activitySource = new ActivitySource("MyApp");
    var meter = new Meter("MyApp");
    var counter = meter.CreateCounter<int>("request_count");
    
    using (var activity = activitySource.StartActivity("ProcessOrder"))
    {
        activity?.SetTag("order_id", "12345");
        // Simulate work
        Thread.Sleep(100);
        counter.Add(1);
    }
    

    This code creates a tracer and meter, instruments a process with an Activity (trace), sets a tag, and increments a counter metric—all exported to console but easily switched to Jaeger or Prometheus.

    Key points to mention

    • Vendor-neutral standard: Write instrumentation once; export to any backend by changing configuration, not code.
    • Three pillars: Traces (request flow), metrics (numerical measurements), logs (detailed events) work together for complete observability.
    • ActivitySource and Meter: The main .NET APIs; ActivitySource replaces older DiagnosticSource patterns for tracing.
    • Automatic instrumentation available: Libraries like ASP.NET Core, HttpClient, and Entity Framework can emit telemetry automatically via instrumentation packages.
    • OTLP protocol: Standard, language-agnostic export format supported by backends like Jaeger, Prometheus, and managed services like Azure Monitor and Datadog.

    Common follow-up questions

    • How does OpenTelemetry differ from Application Insights or Datadog? OpenTelemetry is the standard; Application Insights and Datadog are backends that consume OpenTelemetry data. OpenTelemetry ensures portability; you're not locked into a vendor's APIs.
    • How do you correlate traces across microservices? OpenTelemetry uses W3C Trace Context headers to propagate trace IDs across service boundaries; observability in microservices relies on this correlation.
    • What's the performance impact of instrumentation? When properly configured with sampling and efficient exporters, overhead is typically under 5%; exporters batch and compress data to minimize network impact.

    Related interview questions

    .NETCsharp
  187. 187What is distributed tracing?

    Experienced

    Distributed tracing is a technique for tracking a single request as it flows through multiple microservices, using correlation IDs and span metadata to record timing, dependencies, and failures at each step. It provides end-to-end visibility into system behavior and helps identify performance bottlenecks and failure points in complex architectures.

    How it works

    Distributed tracing works by assigning a unique trace ID to each incoming request. As that request propagates through services, the trace ID is included in headers (typically traceparent following W3C Trace Context standard) and passed along in every subsequent call. Each service segment is tracked as a span—a named, timed unit of work with its own span ID, start time, duration, and metadata.

    The tracing infrastructure collects these spans and correlates them by trace ID, reconstructing a complete timeline of the request journey. Each span records what happened: which service handled it, how long it took, what errors occurred, and what child spans it spawned. Distributed tracing tools (like Jaeger, Zipkin, or Application Insights) then visualize this data as a trace waterfall, showing exactly where time was spent and where failures originated.

    Modern .NET uses System.Diagnostics.ActivitySource to emit spans, and middleware automatically propagates trace context headers. This allows automatic instrumentation of HTTP calls, database queries, and message bus operations without explicit code in every service.

    Example

    csharp
    using System.Diagnostics;
    
    public class OrderService
    {
        private static readonly ActivitySource _activitySource = 
            new("OrderService");
    
        public async Task ProcessOrderAsync(string orderId)
        {
            using var activity = _activitySource.StartActivity("ProcessOrder");
            activity?.SetTag("order.id", orderId);
    
            using var paymentSpan = _activitySource.StartActivity("CallPaymentService");
            paymentSpan?.SetTag("service", "payment");
            await PaymentServiceClient.ChargeAsync(orderId);
    
            using var inventorySpan = _activitySource.StartActivity("UpdateInventory");
            inventorySpan?.SetTag("service", "inventory");
            await InventoryServiceClient.ReserveAsync(orderId);
    
            activity?.SetTag("order.status", "completed");
        }
    }
    
    // In Program.cs:
    builder.Services.AddOpenTelemetry()
        .WithTracing(tracing => tracing
            .AddAspNetCoreInstrumentation()
            .AddHttpClientInstrumentation()
            .AddJaegerExporter(opts => 
                opts.Endpoint = new Uri("http://localhost:14250")));
    

    This code manually creates spans within the OrderService, tags them with business context (order ID, service name), and the trace context is automatically propagated to downstream HTTP calls; the entire flow is then visible in Jaeger.

    Key points to mention

    • W3C Trace Context standard: The traceparent header format ensures interoperability across languages and platforms.
    • Automatic instrumentation: ASP.NET Core, HttpClient, and database providers emit spans automatically; you don't need to add tracing code everywhere.
    • Correlation of failures: When a request fails partway through, the trace shows exactly which service and span failed, greatly reducing debugging time.
    • Performance analysis: Traces reveal not just which service is slow, but whether slowness is in your code, I/O, or downstream calls, enabling targeted optimization.
    • OpenTelemetry standard: .NET uses OpenTelemetry for consistent, vendor-agnostic trace emission and export to backends like Jaeger, Zipkin, or cloud providers.

    Common follow-up questions

    • "How do you sample traces in high-traffic systems?" – Sampling strategies (probabilistic, rate-based) prevent overwhelming the tracing backend; you might sample 1% of traces but 100% of errors.
    • "How does distributed tracing relate to metrics and logging?" – Tracing shows the causal flow of a request, metrics aggregate performance data, and logs record discrete events; all three together provide complete observability.
    • "What happens when trace context is lost between services?" – If a service fails to propagate headers, a new trace is created and the chain breaks; this often indicates a missing instrumentation or incorrect middleware ordering.

    Related interview questions

    Further reading

    .NETCsharpPerformance
  188. 188How do you write integration tests for ASP.NET Core?

    Experienced

    Integration tests for ASP.NET Core are written using WebApplicationFactory to host your application in memory, allowing you to test the full request-response pipeline against a real database or test doubles. You create a test factory, optionally replace dependencies with test implementations, then use HttpClient to make actual HTTP requests and verify responses.

    How it works

    WebApplicationFactory<T> is a generic class that bootstraps your ASP.NET Core application in a test environment. It reads your Program.cs, registers all services, and creates an in-memory server. Before the app starts, you override the ConfigureWebHost method to swap out real services (like database contexts or external APIs) with test versions. Once configured, you obtain an HttpClient connected to this test server and make HTTP requests exactly as a real client would.

    The key advantage is that integration tests exercise your entire stack: routing, middleware, filters, model binding, validation, and business logic. Unlike unit tests, they catch integration issues. You typically use a separate test database—either an in-memory SQLite instance or Testcontainers for more realistic database behavior.

    The test factory is usually created once per test class or shared across multiple test classes via IAsyncLifetime or ICollectionFixture to avoid repeated startup overhead.

    Example

    csharp
    public class ProductApiFactory : WebApplicationFactory<Program>
    {
        protected override void ConfigureWebHost(IWebHostBuilder builder)
        {
            builder.ConfigureServices(services =>
            {
                var descriptor = services.SingleOrDefault(d => 
                    d.ServiceType == typeof(DbContextOptions<AppDbContext>));
                if (descriptor != null)
                    services.Remove(descriptor);
    
                services.AddDbContext<AppDbContext>(opts =>
                    opts.UseInMemoryDatabase("TestDb"));
            });
        }
    }
    
    public class ProductIntegrationTests : IAsyncLifetime
    {
        private ProductApiFactory _factory;
        private HttpClient _client;
    
        public async Task InitializeAsync()
        {
            _factory = new ProductApiFactory();
            _client = _factory.CreateClient();
            
            using var scope = _factory.Services.CreateScope();
            var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
            await dbContext.Database.EnsureCreatedAsync();
        }
    
        public async Task DisposeAsync()
        {
            _factory.Dispose();
        }
    
        [Fact]
        public async Task GetProduct_WithValidId_ReturnsOk()
        {
            var response = await _client.GetAsync("/api/products/1");
            
            response.StatusCode.Should().Be(System.Net.HttpStatusCode.OK);
            var content = await response.Content.ReadAsAsync<ProductDto>();
            content.Name.Should().NotBeNullOrEmpty();
        }
    
        [Fact]
        public async Task CreateProduct_WithValidData_ReturnsCreated()
        {
            var command = new { name = "Test Product", price = 99.99 };
            var response = await _client.PostAsJsonAsync("/api/products", command);
            
            response.StatusCode.Should().Be(System.Net.HttpStatusCode.Created);
        }
    }
    

    This example creates a test factory that replaces the real database with an in-memory SQLite context, then runs two endpoint tests: one verifies a GET request returns 200 OK, the other verifies POST creates a product with 201 Created.

    Key points to mention

    • Service replacement: Override ConfigureWebHost to swap DbContext, authentication, external APIs, or caching with test doubles before the host builds.
    • In-memory or test databases: Use SQLite in-memory for speed or Testcontainers for production-like database behavior during tests.
    • HttpClient usage: Make real HTTP calls via the test client—you test routing, model binding, filters, and middleware alongside business logic.
    • Async lifecycle management: Implement IAsyncLifetime to initialize and dispose the factory cleanly, or use class fixtures for shared state across tests.
    • Avoid side effects: Reset or isolate test data between tests; use separate database instances or transactions to prevent test interdependencies.

    Common follow-up questions

    • How do you test authorization in integration tests? See How do you test authorization rules in integration tests? for detailed approaches using test identity or bearer tokens.
    • Should integration tests use real databases or mocks? Real databases (in-memory or Testcontainers) are preferred for integration tests because mocks defeat the purpose of testing actual database interaction and SQL issues.
    • How do you handle test data isolation? Use separate in-memory databases per test, seed data in setup, or run tests in parallel with independent schemas. For shared databases, wrap each test in a transaction that rolls back.

    Related interview questions

    Further reading

    .NETCsharpaspnetcoreTesting
  189. 189What is Testcontainers?

    Experienced

    Testcontainers is a library that spins up real Docker containers—databases, message brokers, caches—during test execution, enabling true integration tests that run identically across local machines and CI/CD pipelines. It eliminates mocking infrastructure and provides a realistic test environment without requiring developers to manually manage containers.

    How it works

    Testcontainers automatically manages the full lifecycle of Docker containers within your test suite. When a test class initializes, Testcontainers starts the required container(s); when tests complete, it cleans them up. The library exposes container connection details (ports, connection strings) programmatically, so your application code connects to real services.

    The workflow is straightforward: define which container you need (PostgreSQL, SQL Server, RabbitMQ, etc.), let Testcontainers handle startup and teardown, and write tests against the actual service. This approach catches real integration bugs—connection pooling issues, query timeouts, transaction handling—that mocks would miss. Since Docker runs identically everywhere, tests pass locally and in CI with the same reliability.

    Testcontainers supports both xUnit and NUnit through specialized fixtures and also works with IAsyncLifetime for advanced lifecycle management. It handles port allocation automatically, preventing conflicts when multiple tests run in parallel.

    Example

    csharp
    using Testcontainers.PostgreSql;
    using Xunit;
    
    public class UserRepositoryTests : IAsyncLifetime
    {
        private PostgreSqlContainer _container;
        private string _connectionString;
    
        public async Task InitializeAsync()
        {
            _container = new PostgreSqlBuilder()
                .WithImage("postgres:16")
                .Build();
            
            await _container.StartAsync();
            _connectionString = _container.GetConnectionString();
        }
    
        public async Task DisposeAsync()
        {
            await _container.StopAsync();
        }
    
        [Fact]
        public async Task CreateUser_Should_Persist_To_Database()
        {
            using var connection = new NpgsqlConnection(_connectionString);
            await connection.OpenAsync();
            
            var repository = new UserRepository(connection);
            await repository.CreateUserAsync(new User { Name = "Alice" });
            
            var user = await repository.GetUserAsync("Alice");
            Assert.NotNull(user);
        }
    }

    This test spins up a real PostgreSQL container, connects to it, performs CRUD operations, and verifies behavior against actual database behavior—not a mock.

    Key points to mention

    • Real services, not mocks: Tests run against actual databases, queues, and caches, catching integration bugs mocks cannot detect.
    • Automatic lifecycle management: Containers start before tests and clean up after, with no manual Docker commands or manual container management required.
    • CI/CD ready: Identical test behavior across local development and CI pipelines because Docker environment is consistent everywhere.
    • Parallel test support: Dynamic port allocation allows multiple containers to run simultaneously without conflicts.
    • Works with common frameworks: Integrates with xUnit, NUnit, and IAsyncLifetime for clean test patterns as shown in integration test best practices.

    Common follow-up questions

    • How do you handle database migrations in Testcontainers tests? — Use Entity Framework's Database.Migrate() or execute migration scripts after container startup, ensuring schema is up-to-date before tests run.
    • What's the performance impact of spinning up containers per test? — Startup is typically 1–3 seconds per container; use container reuse, fixtures that persist across tests, or docker-compose for complex multi-service stacks to optimize performance.
    • Can you use Testcontainers with SQL Server or other databases? — Yes; Testcontainers supports PostgreSQL, MySQL, SQL Server, MongoDB, RabbitMQ, Redis, and dozens of other services through dedicated modules.

    Related interview questions

    Further reading

    .NETCsharpTestingDevOps
  190. 190How do you test code that depends on time?

    Experienced

    In .NET 8+, inject TimeProvider into code that depends on time, then use FakeTimeProvider in tests to control the current time and advance timers deterministically. This replaces the older pattern of mocking DateTime.Now and makes time testable without static dependencies or reflection hacks.

    How it works

    TimeProvider is an abstract base class introduced in .NET 8 that encapsulates all time-related operations: GetUtcNow(), GetLocalNow(), and timer creation. The default implementation uses the system clock, but you can inject a FakeTimeProvider in tests to:

    • Set a specific current time
    • Advance time forward without waiting (no flaky delays)
    • Control timer behavior and callbacks
    • Test timeout logic, expiration, and scheduling in milliseconds instead of seconds

    This pattern is dependency-injection friendly: your service accepts TimeProvider via constructor injection, making it testable from day one. The fake provider lets you simulate weeks of elapsed time in a single test run without actual delays.

    Example

    csharp
    // Production service
    public class TokenService
    {
        private readonly TimeProvider _timeProvider;
        
        public TokenService(TimeProvider timeProvider)
        {
            _timeProvider = timeProvider;
        }
        
        public bool IsTokenExpired(DateTime issuedAt, TimeSpan ttl)
        {
            var now = _timeProvider.GetUtcNow().UtcDateTime;
            return (now - issuedAt) > ttl;
        }
    }
    
    // xUnit test
    [Fact]
    public void IsTokenExpired_WithExpiredToken_ReturnsTrue()
    {
        var fakeTime = new FakeTimeProvider(new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc));
        var service = new TokenService(fakeTime);
        
        var issuedAt = new DateTime(2024, 1, 1, 11, 0, 0, DateTimeKind.Utc);
        var ttl = TimeSpan.FromMinutes(30);
        
        // Token issued 1 hour ago, TTL is 30 minutes → expired
        Assert.True(service.IsTokenExpired(issuedAt, ttl));
        
        // Advance fake time forward 2 hours
        fakeTime.Advance(TimeSpan.FromHours(2));
        Assert.True(service.IsTokenExpired(issuedAt, ttl));
    }
    

    The test creates a FakeTimeProvider at a fixed date, injects it into the service, and verifies expiration logic without waiting—all tests run in milliseconds.

    Key points to mention

    • Dependency injection over static calls: Never use DateTime.Now directly; always inject TimeProvider so tests can override it.
    • No test delays: FakeTimeProvider.Advance() moves time instantly; no Thread.Sleep() needed, making tests fast and deterministic.
    • Timer support: FakeTimeProvider handles CreateTimer(), allowing you to test scheduled tasks and callbacks without real waits.
    • .NET 8+ standard: This is the modern, built-in way; previous versions required third-party mocking libraries or manual clock abstractions.
    • Works with xUnit, NUnit, MSTest: Framework-agnostic; use with any test framework via DI containers.

    Common follow-up questions

    • What if my code already uses DateTime.Now everywhere? Refactor to accept TimeProvider in the constructor; use TimeProvider.System as the default in production. Gradual migration is safe.
    • How do I test timers and callbacks? FakeTimeProvider allows you to call TimerCallback manually or advance time to trigger scheduled callbacks in tests.
    • Can I use this with integration tests? Yes, register FakeTimeProvider in the test WebApplicationFactory service collection.

    Related interview questions

    Further reading

    .NETCsharpTesting
  191. 191What is the difference between a mock, stub and fake?

    Experienced

    A stub returns predetermined canned data without verification, a mock verifies that specific methods were called with expected arguments, and a fake is a simplified but functional implementation that behaves like the real thing. These are all test doubles used in unit testing, but they serve different purposes: stubs isolate code by providing fixed responses, mocks ensure your code interacts correctly with its dependencies, and fakes replace expensive or complex components with lightweight working alternatives.

    How it works

    In unit testing, you often need to isolate the code under test from its external dependencies. Test doubles are stand-in objects that replace those dependencies. A stub is the simplest—it just returns hardcoded or predetermined values when called. It makes no assertions about how it was used; it simply responds. A mock goes further by recording method calls and allowing you to verify that the code under test interacted with it in a specific way—calling the right method with the right arguments in the right order. It fails the test if expectations are not met. A fake is a different animal altogether: it's a working implementation that behaves like the real dependency but in a simplified way. An in-memory database or a fake email service that writes to a list instead of actually sending emails are classic examples. Fakes are useful when you need more realistic behavior than a stub provides, but you can't use the real implementation in tests.

    The choice between them depends on your testing goal. Use a stub when you need to control input. Use a mock when you need to verify interactions. Use a fake when you need realistic behavior at low cost.

    Example

    csharp
    // Interface to test against
    public interface IUserRepository
    {
        Task<User> GetUserByIdAsync(int id);
        Task SaveUserAsync(User user);
    }
    
    // STUB: returns canned data, no verification
    public class UserRepositoryStub : IUserRepository
    {
        public Task<User> GetUserByIdAsync(int id) 
            => Task.FromResult(new User { Id = id, Name = "John Doe" });
        
        public Task SaveUserAsync(User user) => Task.CompletedTask;
    }
    
    // FAKE: working lightweight implementation
    public class InMemoryUserRepository : IUserRepository
    {
        private readonly List<User> _users = new();
        
        public Task<User> GetUserByIdAsync(int id) 
            => Task.FromResult(_users.FirstOrDefault(u => u.Id == id));
        
        public Task SaveUserAsync(User user)
        {
            _users.RemoveAll(u => u.Id == user.Id);
            _users.Add(user);
            return Task.CompletedTask;
        }
    }
    
    // MOCK: verifies interactions using Moq
    [Fact]
    public async Task UpdateUserProfile_ShouldCallSaveUser()
    {
        var mockRepo = new Mock<IUserRepository>();
        mockRepo.Setup(r => r.GetUserByIdAsync(1))
            .ReturnsAsync(new User { Id = 1, Name = "John" });
        
        var service = new UserService(mockRepo.Object);
        await service.UpdateUserNameAsync(1, "Jane");
        
        // Verify SaveUserAsync was called exactly once with a user named "Jane"
        mockRepo.Verify(r => r.SaveUserAsync(It.Is<User>(u => u.Name == "Jane")), 
            Times.Once);
    }
    
    public class User { public int Id { get; set; } public string Name { get; set; } }
    public class UserService 
    { 
        private readonly IUserRepository _repo;
        public UserService(IUserRepository repo) => _repo = repo;
        
        public async Task UpdateUserNameAsync(int id, string newName)
        {
            var user = await _repo.GetUserByIdAsync(id);
            user.Name = newName;
            await _repo.SaveUserAsync(user);
        }
    }

    This example shows a stub returning fixed data, a fake maintaining an actual in-memory list, and a mock using Moq to verify that SaveUserAsync was called with the correct user object.

    Aspect Stub Fake Mock
    Purpose Provide canned responses to isolate code under test Replace real component with simplified working version Verify interactions and method calls
    Behavior Returns hardcoded or predetermined values Implements the interface realistically but simply (e.g., in-memory) Records calls and can assert on them
    Verification None; stub doesn't track calls Minimal; you might check final state but not call history Full; verifies method calls, arguments, call count, order
    Complexity Very simple; just returns values More complex; has logic and state Complex; requires assertion framework like Moq
    When to use When you only need to control what a dependency returns When you need realistic behavior but can't use the real implementation When you need to ensure your code calls dependencies correctly
    Example A repository that always returns the same user An in-memory database or in-memory cache Verifying a service called repository.Save() exactly once
    What is the difference between a mock, stub and fake diagram

    Key points to mention

    • All three are test doubles: They replace real dependencies during testing to isolate the code under test and control its environment.
    • Stubs are passive: They respond but don't verify. Use them when you only care about what a dependency returns.
    • Mocks are active verifiers: They record and assert on interactions. Use them when behavior (how code interacts) matters as much as state.
    • Fakes are functional: They're real implementations that work correctly but in a simplified way. In-memory repositories and test email services are common examples.
    • Moq and NSubstitute are common frameworks: They make creating mocks easy in .NET by allowing setup, verification, and argument matching through fluent APIs.

    Common follow-up questions

    • Should I always use mocks? No. Over-mocking leads to brittle tests that verify implementation details rather than behavior. Use stubs for simple isolation and fakes for realistic behavior. Reserve mocks for critical interactions you genuinely need to verify.
    • What's the difference between a mock and a spy? A spy is like a mock but wraps the real object, recording calls while still executing the real behavior. Mocks are pure test doubles; spies are partial test doubles.
    • How do fakes relate to integration tests? Fakes are ideal for unit tests because they're fast and deterministic. For integration tests, you might use real implementations or a containerized database. Fakes bridge the gap—more realistic than stubs, faster than real integration.

    Related interview questions

    What is the difference between a mock, stub and fake?
    .NETCsharpTesting
  192. 192How do you secure configuration and data in transit?

    Experienced

    Securing configuration and data in transit involves enforcing HTTPS with HSTS headers, using TLS for all database and service-to-service connections, and encrypting sensitive data at rest using the Data Protection API (DAPI) or database-native encryption. This multi-layered approach ensures that credentials, API keys, and personally identifiable information remain protected whether in motion over the network or stored on disk.

    How it works

    Data security operates across three vectors: in transit, at rest, and in use. For transit security, HTTPS with TLS 1.2+ encrypts all HTTP communication between clients and servers, preventing man-in-the-middle attacks. HSTS (HTTP Strict-Transport-Security) headers instruct browsers and clients to always use HTTPS, eliminating downgrade attacks. Database connections must also use encrypted protocols—SQL Server supports TLS connections via connection string encryption, and cloud databases like Azure SQL Database enforce encryption by default.

    For data at rest, the Data Protection API provides symmetric encryption at the operating system level, binding encrypted data to the machine or user account. This prevents decryption if the encrypted key material is stolen. Alternatively, database-level encryption (Transparent Data Encryption in SQL Server, or column-level encryption) ensures sensitive data is unreadable at the storage layer.

    Configuration containing secrets (connection strings, API keys, tokens) should never be hardcoded or stored in source control. Use Azure Key Vault, AWS Secrets Manager, or .NET User Secrets in development to externalize and rotate secrets safely. Environment variables can bridge configuration management systems with your application runtime.

    Example

    csharp
    // Program.cs - ASP.NET Core 8 with secrets and encrypted connection strings
    var builder = WebApplication.CreateBuilder(args);
    
    // Load secrets from Azure Key Vault
    var keyVaultUrl = "https://myvault.vault.azure.net/";
    builder.Configuration.AddAzureKeyVault(
        new Uri(keyVaultUrl),
        new DefaultAzureCredential());
    
    // Configure HSTS and HTTPS
    builder.Services.AddHsts(options =>
    {
        options.MaxAge = TimeSpan.FromDays(365);
        options.IncludeSubDomains = true;
        options.Preload = true;
    });
    
    // Add DbContext with encrypted connection string from Key Vault
    var connString = builder.Configuration["ConnectionStrings:Default"];
    builder.Services.AddDbContext<AppDbContext>(options =>
        options.UseSqlServer(connString));
    
    // Data Protection API for encrypting sensitive fields in database
    builder.Services.AddDataProtection()
        .PersistKeysToFileSystem(new DirectoryInfo("/secure/keys"))
        .ProtectKeysWithCertificate(new X509Certificate2("cert.pfx", "password"));
    
    var app = builder.Build();
    
    // Enforce HTTPS redirect and HSTS middleware
    app.UseHttpsRedirection();
    app.UseHsts();
    
    app.MapGet("/api/data", (IDataProtectionProvider provider) =>
    {
        var protector = provider.CreateProtector("MyApp.API");
        var sensitiveData = "credit-card-4111111111111111";
        var encrypted = protector.Protect(sensitiveData);
        return Results.Ok(new { encrypted });
    });
    
    app.Run();
    

    This example demonstrates loading secrets from Azure Key Vault, enforcing HTTPS with HSTS, configuring Data Protection API with persistent key storage, and encrypting sensitive data before storage.

    Key points to mention

    • HTTPS + HSTS: Always enforce TLS 1.2+ and set HSTS headers to prevent downgrade attacks and ensure browsers never fall back to HTTP.
    • Secrets management: Use external vaults (Azure Key Vault, AWS Secrets Manager) instead of hardcoding or embedding in appsettings.json; rotate secrets regularly.
    • Database encryption: Enable TLS for database connections and use Transparent Data Encryption (TDE) or column-level encryption for sensitive data at rest.
    • Data Protection API: Use DAPI for symmetric encryption of sensitive data, with keys managed securely and optionally persisted to a certificate store.
    • End-to-end security: Apply security consistently across API layers—see how to secure a .NET API end to end for comprehensive strategies.

    Common follow-up questions

    • How do you handle certificate pinning for API clients? Use HttpClientHandler with ServerCertificateCustomValidationCallback to validate specific certificate thumbprints, preventing man-in-the-middle attacks even if a CA is compromised.
    • What's the difference between encryption at rest and encryption in transit? In-transit encryption protects data as it travels over networks; at-rest encryption protects stored data on disk or in databases. Both are essential for defense in depth.
    • How do you encrypt database columns selectively? SQL Server supports Always Encrypted, which encrypts data client-side before sending to the database, so the server never sees plaintext sensitive columns.

    Related interview questions

    Further reading

    .NETCsharpSecurity
  193. 193What is the Data Protection API?

    Experienced

    The Data Protection API (DPAPI) is a .NET framework for encrypting sensitive data at rest using automatic key management and machine-specific credentials. It transparently encrypts cookies, authentication tokens, connection strings, and other payloads without requiring you to manage encryption keys directly. In distributed applications, keys must be persisted to shared storage like Azure Key Vault or a database so multiple instances can decrypt data encrypted by their peers.

    How it works

    DPAPI encrypts data using a key hierarchy rooted in the machine or user context. When you call IDataProtectionProvider.CreateProtector(), the framework generates a unique encryption key tied to your application's identity and purpose. The same protector can decrypt data it encrypted, but different purposes create different keys—preventing misuse even if one key is compromised.

    By default, DPAPI stores keys in the local file system. On a single machine, this works seamlessly: the OS protects keys using the machine's Data Protection API. However, in horizontally scaled applications, each instance has a different file system. If Instance A encrypts a cookie and Instance B receives it, Instance B cannot decrypt it without access to A's keys. The solution is to configure a shared key storage provider (Azure Key Vault, SQL Server, Redis, or file shares) so all instances access the same key ring.

    The framework also supports automatic key rotation, expiration, and revocation—critical for compliance and security hygiene in long-running applications.

    Example

    csharp
    // In Program.cs - single instance (default file storage)
    builder.Services.AddDataProtection();
    
    // In Program.cs - distributed apps (using Azure Key Vault)
    builder.Services.AddDataProtection()
        .PersistKeysToAzureBlobStorage(new Uri("https://mystg.blob.core.windows.net/keys"))
        .ProtectKeysWithAzureKeyVault(
            new Uri("https://mykeyvault.vault.azure.net/keys/my-key/version"),
            new DefaultAzureCredential());
    
    // In a service - encrypt and decrypt
    public class UserService
    {
        private readonly IDataProtectionProvider _provider;
        public UserService(IDataProtectionProvider provider) => _provider = provider;
    
        public string EncryptUserId(string userId)
        {
            var protector = _provider.CreateProtector("UserService.UserId");
            return protector.Protect(userId); // Returns encrypted string
        }
    
        public string DecryptUserId(string encrypted)
        {
            var protector = _provider.CreateProtector("UserService.UserId");
            return protector.Unprotect(encrypted); // Recovers original
        }
    }
    

    This example shows how DPAPI transparently encrypts a user ID on one instance and decrypts it on another because both access the shared key storage in Azure.

    Key points to mention

    • Automatic key management: DPAPI generates, rotates, and stores encryption keys without manual intervention, reducing human error.
    • Purpose-based isolation: Each call to CreateProtector() with a different purpose creates a separate key, so leaking one key does not compromise others.
    • Shared storage requirement: Multi-instance apps must configure a backing store (Azure Key Vault, SQL, Redis) so keys are accessible across servers; local file storage only works on a single machine.
    • Automatic expiration and revocation: Keys can be marked as revoked, forcing re-encryption or re-authentication without code changes.
    • ASP.NET Core integration: Cookie middleware and authentication handlers use DPAPI automatically when configured; you rarely call it directly unless encrypting custom payloads.

    Common follow-up questions

    • What happens if I don't configure shared key storage in a load-balanced environment? Each instance will have its own key ring. Cookies encrypted by Instance A will fail to decrypt on Instance B, causing users to lose session state or authentication unexpectedly.
    • Can I rotate keys without downtime? Yes—DPAPI supports graceful key rollover. New encryptions use the latest key, but old keys remain valid for decryption, allowing existing tokens to survive the rotation window.
    • Is DPAPI sufficient for password storage? No; DPAPI is for encryption at rest, not password hashing. Passwords must use bcrypt, Argon2, or PBKDF2 with a salt. Learn more about securing configuration and data in transit.

    Related interview questions

    Further reading

    .NETCsharpSecurity
  194. 194How do you prevent SQL injection in .NET?

    Experienced

    Use parameterized queries (prepared statements) to separate SQL code from user input, ensuring that user-supplied values are never interpreted as executable SQL. This is the primary defense against SQL injection. In .NET, achieve this through Entity Framework Core, Dapper parameter bindings, or safe interpolation in raw SQL queries. Additionally, apply input validation and use database accounts with minimal privileges.

    How it works

    SQL injection occurs when untrusted user input is concatenated directly into SQL queries, allowing attackers to inject malicious SQL commands. Parameterized queries prevent this by using placeholders (parameters) for values, which are transmitted to the database separately from the SQL structure itself. The database driver ensures that parameter values are treated as data, never as executable code, regardless of their content.

    When you use parameterized queries, the SQL statement is compiled once on the database server, and parameters are bound to that compiled plan. This means even if a user enters a string like ' OR '1'='1, it is stored as a literal string value, not as SQL logic. The database engine never reinterprets parameters as commands.

    Defense-in-depth also involves validating input types and lengths before they reach the database, using least-privilege database accounts that limit what operations are possible if a breach occurs, and escaping data appropriately when parameterized queries are not feasible (though this is rare in modern .NET).

    Example

    csharp
    // ❌ VULNERABLE: String concatenation
    string userId = userInput; // Untrusted input
    string query = $"SELECT * FROM Users WHERE Id = {userId}"; // DANGER
    var users = dbContext.Users.FromSqlRaw(query).ToList();
    
    // ✅ SAFE: Parameterized query with EF Core
    var userId = userInput;
    var users = dbContext.Users
        .FromSqlInterpolated($"SELECT * FROM Users WHERE Id = {userId}")
        .ToList();
    
    // ✅ SAFE: Dapper with parameters
    using var connection = new SqlConnection(connectionString);
    var userId = userInput;
    var users = connection.Query<User>(
        "SELECT * FROM Users WHERE Id = @UserId",
        new { UserId = userId }
    ).ToList();
    
    // ✅ SAFE: EF Core DbSet query (highest level)
    var userId = int.Parse(userInput); // Type validation
    var users = dbContext.Users
        .Where(u => u.Id == userId)
        .ToList();
    

    The first example is vulnerable because the query string is built by concatenating user input. The remaining examples are safe: FromSqlInterpolated detects interpolated values and converts them to parameters automatically, Dapper explicitly binds named parameters, and LINQ-to-Entities generates parameterized queries automatically.

    Key points to mention

    • Parameterized queries are the gold standard. They separate SQL structure from data and are supported natively by EF Core, Dapper, and ADO.NET.
    • Use FromSqlInterpolated in EF Core, not FromSqlRaw. Interpolated queries are automatically converted to parameterized queries; raw queries require manual parameter management and are error-prone.
    • Input validation is a secondary control. Validate data types, lengths, and formats before use, but do not rely on validation alone to prevent injection—parameterized queries are essential.
    • Principle of least privilege: Create database users with only the permissions required for their operations (e.g., read-only accounts for reporting, limited insert/update for application writes).
    • Avoid dynamic SQL construction. If you must build queries dynamically, use an ORM or a library that handles parameterization automatically rather than string concatenation.

    Common follow-up questions

    • What if I need to write raw SQL safely in EF Core? Use FromSqlInterpolated instead of FromSqlRaw, which automatically detects interpolated values and converts them to parameters.
    • What is the difference between parameterized queries and stored procedures? Stored procedures can be parameterized, but they are not inherently safer than parameterized queries. Both prevent injection when parameters are used correctly; stored procedures add an extra layer of encapsulation but add complexity.
    • Does LINQ-to-Entities prevent SQL injection? Yes. LINQ queries are translated to parameterized SQL at the database layer. However, if you use string concatenation within LINQ (e.g., .Where(u => EF.Functions.Like(u.Name, userInput)) with raw strings), you can still introduce vulnerabilities.

    Related interview questions

    Further reading

    .NETCsharpefcoreSecurityArchitecture
  195. 195How do you prevent XSS in ASP.NET Core?

    Experienced

    ASP.NET Core prevents XSS (Cross-Site Scripting) primarily through automatic HTML encoding in Razor views, strict avoidance of Html.Raw() with untrusted content, sanitization of rich text input, and implementation of Content Security Policy (CSP) headers. These layered defenses ensure that user-supplied data cannot be interpreted as executable code in the browser.

    How it works

    XSS attacks occur when malicious scripts are injected into web pages and executed in users' browsers. ASP.NET Core mitigates this through multiple defense mechanisms:

    Automatic HTML Encoding: Razor's default behavior encodes output by converting special characters (<, >, &, ", ') into HTML entities. When you write @Model.UserInput, the runtime automatically encodes any HTML tags, rendering them as harmless text rather than executable markup.

    Avoiding Html.Raw(): The Html.Raw() method bypasses encoding entirely. It should never be used with user-supplied data, only with content you completely control—such as hardcoded markup or database content you've explicitly validated.

    Rich Text Sanitization: If your application accepts rich HTML (like in a WYSIWYG editor), you must use a library like HtmlSanitizer to strip dangerous tags and attributes while preserving safe formatting. This is separate from encoding because users legitimately need to input some HTML.

    Content Security Policy: CSP headers tell the browser which resources are trusted and prevent inline scripts from executing. A policy like script-src 'self' blocks all external scripts and inline <script> tags unless explicitly allowed.

    Example

    csharp
    // Program.cs
    var builder = WebApplication.CreateBuilder(args);
    builder.Services.AddScoped<IHtmlSanitizer, HtmlSanitizer>();
    builder.Services.AddControllersWithViews();
    
    var app = builder.Build();
    app.Use(async (context, next) => {
        context.Response.Headers.Add("Content-Security-Policy", 
            "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'");
        await next();
    });
    app.MapControllerRoute("default", "{controller=Home}/{action=Index}/{id?}");
    app.Run();
    
    // Controller.cs
    public class CommentController : Controller {
        private readonly IHtmlSanitizer _sanitizer;
        
        public CommentController(IHtmlSanitizer sanitizer) {
            _sanitizer = sanitizer;
        }
        
        [HttpPost]
        public IActionResult Post(string content) {
            // Sanitize rich HTML
            string safe = _sanitizer.Sanitize(content);
            // Store safe content
            return Ok();
        }
    }
    
    // View.cshtml - Automatic encoding
    <p>@Model.UserComment</p>  <!-- Encoded automatically -->
    <div>@Html.Raw(Model.AdminNote)</div>  <!-- Only for trusted content -->
    

    The example demonstrates CSP headers, HtmlSanitizer integration, automatic Razor encoding in views, and controlled use of Html.Raw() only for trusted admin content.

    Key points to mention

    • Razor encodes by default: You don't need to do anything special—@Model.property is safe.
    • Html.Raw() is a footgun: Never use it with user input; reserve it for hardcoded or pre-validated content only.
    • Use HtmlSanitizer for rich text: Libraries like HtmlSanitizer (NuGet package) allow safe HTML while removing dangerous tags and event handlers.
    • Content Security Policy adds defense-in-depth: Even if encoding fails, CSP prevents inline scripts from running.
    • Validate on the server, not the client: Browser-side validation is easily bypassed; always sanitize and validate server-side.

    Common follow-up questions

    • What's the difference between encoding and sanitization? Encoding converts all HTML to entities (making it safe but unreadable as markup); sanitization strips dangerous tags while preserving safe HTML structure.
    • Can you use ViewData or TempData safely? Yes—they're encoded like any Razor output unless explicitly marked with Html.Raw(). Be equally cautious with both.
    • How do you handle JavaScript event attributes in user input? Don't allow them at all. Use HtmlSanitizer to remove onclick, onerror, onload, etc., or use a whitelist-based approach that only permits specific tags and attributes.

    Related interview questions

    .NETCsharpaspnetcoreSecurityArchitecture
  196. 196What is SignalR?

    Experienced

    SignalR is a .NET library that enables real-time, bidirectional communication between server and client. It uses WebSockets when available, with automatic fallback to Server-Sent Events or long polling on older browsers. The core abstraction is the Hub—a server-side class that broadcasts messages to connected clients, either individually, in groups, or to all users.

    How it works

    SignalR maintains a persistent connection between client and server. When you call a hub method from the server, it's instantly pushed to connected clients without polling. The connection is established when a client connects to a hub endpoint; the server assigns it a unique connection ID. You can then target that client directly, add it to named groups, or broadcast to everyone.

    The transport layer automatically selects the best protocol: WebSocket (full duplex, lowest latency), Server-Sent Events, or long polling. This makes SignalR work across different network topologies and corporate proxies. On the client side, you typically use the JavaScript client to listen for server-invoked methods and invoke server methods. For scaling across multiple servers, you use a backplane like Redis or Azure SignalR Service to relay messages between server instances.

    Example

    csharp
    // Server Hub
    public class ChatHub : Hub
    {
        public async Task SendMessage(string user, string message)
        {
            // Broadcast to all connected clients
            await Clients.All.SendAsync("ReceiveMessage", user, message);
        }
    
        public async Task SendToGroup(string groupName, string message)
        {
            await Clients.Group(groupName).SendAsync("ReceiveMessage", message);
        }
    
        public override async Task OnConnectedAsync()
        {
            await Groups.AddToGroupAsync(Context.ConnectionId, "Users");
            await base.OnConnectedAsync();
        }
    }
    
    // Program.cs
    var builder = WebApplication.CreateBuilder(args);
    builder.Services.AddSignalR();
    
    var app = builder.Build();
    app.MapHub<ChatHub>("/chatHub");
    app.Run();
    
    // Client (JavaScript)
    const connection = new signalR.HubConnectionBuilder()
        .withUrl("/chatHub")
        .withAutomaticReconnect()
        .build();
    
    connection.on("ReceiveMessage", (user, message) => {
        console.log(`${user}: ${message}`);
    });
    
    connection.start().catch(err => console.error(err));
    

    This example shows a chat hub that broadcasts messages to all clients, demonstrates group messaging, and shows how to automatically add clients to a group on connection. The JavaScript client listens for the ReceiveMessage method and displays incoming messages.

    Key points to mention

    • Hub-based architecture—servers invoke methods on clients; clients invoke methods on the server, enabling true request–response patterns
    • Automatic reconnection—the client library handles disconnections and attempts to reconnect, with configurable retry policies
    • Groups and users—broadcast to subsets of clients using named groups or target specific users by connection ID or user ID
    • Transport fallback—WebSocket is preferred but automatically falls back to SSE or long polling if needed, ensuring broad browser support
    • Scaling requires a backplane—single-server deployments work out of the box, but distributed systems need Redis or Azure SignalR Service to synchronize messages across instances

    Common follow-up questions

    • How do you handle authentication and authorization in SignalR, and can you restrict which clients see which messages?
    • What's the difference between Clients.All, Clients.Caller, and Clients.Others?
    • How do you implement eventual consistency when messages may not arrive in order or at all?

    Related interview questions

    Further reading

    .NETCsharpReal-time
  197. 197How do you scale SignalR across servers?

    Experienced

    SignalR scales across multiple servers by using a backplane (such as Redis, Azure Service Bus, or Azure SignalR Service) that acts as a central message broker, ensuring messages sent by any client reach all connected clients regardless of which server instance they connect to. Without a backplane, each server only knows about its own connections, so cross-server messaging fails in a scaled environment.

    How it works

    In a single-server SignalR deployment, the hub maintains an in-memory collection of all connected clients. When you call a method like Clients.All.SendMessage(), it broadcasts to every connection on that instance. However, in a load-balanced, multi-server environment, this breaks down: Server A doesn't know about clients connected to Server B.

    A backplane solves this by intercepting all hub method invocations and connection/disconnection events, routing them through a shared communication channel. When a client on Server A sends a message, the hub publishes it to the backplane, which then distributes it to all server instances. Each instance receives the message and delivers it to its locally connected clients.

    For maximum performance, use sticky sessions (session affinity) at the load balancer level so clients reconnect to the same server, reducing unnecessary backplane traffic. However, the backplane handles failover gracefully—if a server goes down, clients will reconnect to another instance and continue receiving messages.

    Common backplane choices include Redis (fast, cost-effective on-premises), Azure Service Bus (for enterprise messaging), and Azure SignalR Service (fully managed, serverless option that eliminates the backplane entirely by managing scale internally).

    Example

    csharp
    // Startup configuration with Redis backplane
    public void ConfigureServices(IServiceCollection services)
    {
        services
            .AddSignalR()
            .AddStackExchangeRedis("localhost:6379", options =>
            {
                options.Configuration.ChannelPrefix = "SignalR_";
            });
        
        services.AddCors(options =>
        {
            options.AddDefaultPolicy(builder =>
                builder.WithOrigins("https://client.example.com")
                       .AllowAnyMethod()
                       .AllowAnyHeader()
                       .AllowCredentials());
        });
    }
    
    public void Configure(IApplicationBuilder app)
    {
        app.UseRouting();
        app.UseCors();
        app.UseEndpoints(endpoints =>
        {
            endpoints.MapHub<ChatHub>("/chatHub");
        });
    }
    
    // Hub implementation
    public class ChatHub : Hub
    {
        public async Task SendMessage(string user, string message)
        {
            // This automatically goes through Redis to all servers
            await Clients.All.SendAsync("ReceiveMessage", user, message);
        }
    }
    

    This configuration registers Redis as the SignalR backplane; all hub broadcasts automatically flow through Redis to reach clients on any server instance.

    Key points to mention

    • Backplane is transparent: Your hub code doesn't change; the backplane intercepts Clients.All, Clients.Others, Clients.User(), etc., and routes through the broker.
    • Sticky sessions improve efficiency: Configure your load balancer to route a client to the same server, reducing backplane traffic, though failover still works without it.
    • Consider managed options: Azure SignalR Service eliminates backplane complexity by handling scale internally and offering better cost predictability at scale.
    • Message ordering and delivery: Backplanes guarantee ordering within a connection but may not guarantee delivery if a server crashes before forwarding; use acknowledgments for critical messages.
    • Groups and users still work: Sending to a group (e.g., Clients.Group("room1")) automatically broadcasts through the backplane to all users in that group across all servers.

    Common follow-up questions

    • What happens if Redis goes down? — Clients on all servers lose the ability to send cross-server messages; they can still communicate within their own server. Use Redis Sentinel or a managed Redis service for high availability.
    • How do you handle user-specific broadcasts across servers? — SignalR tracks user IDs via authentication (cookie or JWT); calling Clients.User(userId) queries the backplane for all connections linked to that user.
    • What's the latency impact of a backplane? — Redis backplanes add milliseconds of latency; for most real-time applications this is negligible, but high-frequency trading or gaming may need local-only broadcasts with client-side synchronization.

    Related interview questions

    Further reading

    .NETCsharpReal-time
  198. 198What is gRPC in .NET?

    Experienced

    gRPC is a high-performance, open-source RPC framework built on HTTP/2 and Protocol Buffers (protobuf) that enables fast, strongly-typed communication between services with native streaming support. ASP.NET Core provides first-class hosting for gRPC services through Kestrel, making it straightforward to build microservice architectures that demand low latency and high throughput.

    How it works

    gRPC operates by defining service contracts in .proto files using Protocol Buffer syntax. These definitions describe RPC methods and message types in a language-agnostic way. The protobuf compiler generates strongly-typed client and server code in C#, eliminating the ambiguity of loosely-typed REST APIs.

    Communication happens over HTTP/2, which provides multiplexing—multiple requests can travel on a single TCP connection without head-of-line blocking. Protocol Buffers serialize data into compact binary format, reducing payload size and parsing overhead compared to JSON. This combination delivers exceptional performance: lower latency, reduced bandwidth, and faster serialization.

    gRPC supports four communication patterns: unary (request-response), server streaming, client streaming, and bidirectional streaming. This makes it ideal for scenarios requiring real-time data flows, such as chat applications, live notifications, or sensor data collection.

    ASP.NET Core hosts gRPC services natively through Kestrel. You configure gRPC endpoints in your startup and implement generated service interfaces, allowing you to leverage the full ASP.NET Core ecosystem including middleware, dependency injection, and logging.

    Example

    csharp
    // Greeter.proto
    syntax = "proto3";
    package greet;
    
    service Greeter {
      rpc SayHello (HelloRequest) returns (HelloReply);
    }
    
    message HelloRequest {
      string name = 1;
    }
    
    message HelloReply {
      string message = 1;
    }
    
    // GreeterService.cs - Server implementation
    public class GreeterService : Greeter.GreeterBase
    {
        public override Task<HelloReply> SayHello(HelloRequest request, ServerCallContext context)
        {
            return Task.FromResult(new HelloReply 
            { 
                Message = $"Hello {request.Name}" 
            });
        }
    }
    
    // Program.cs
    var builder = WebApplication.CreateBuilder(args);
    builder.Services.AddGrpc();
    var app = builder.Build();
    app.MapGrpcService<GreeterService>();
    app.Run();
    
    // Client usage
    var channel = GrpcChannel.ForAddress("https://localhost:5001");
    var client = new Greeter.GreeterClient(channel);
    var reply = await client.SayHelloAsync(new HelloRequest { Name = "World" });
    Console.WriteLine(reply.Message);
    

    This example defines a simple Greeter service that accepts a name and returns a greeting; the proto file auto-generates type-safe client and server classes.

    Key points to mention

    • Protocol Buffers: Compact binary serialization format; smaller than JSON, faster to parse, and versioning-friendly through field numbering.
    • HTTP/2 multiplexing: Multiple requests share one TCP connection, eliminating head-of-line blocking and improving throughput compared to HTTP/1.1 (REST).
    • Four communication patterns: Unary, server streaming, client streaming, and bidirectional streaming enable flexible service interactions.
    • Native ASP.NET Core integration: Kestrel hosts gRPC services with middleware support, dependency injection, and unified configuration.
    • Language interoperability: Proto definitions generate clients and servers in multiple languages (C#, Go, Java, Python, etc.), simplifying polyglot architectures.

    Common follow-up questions

    • When should I choose gRPC over REST? Use gRPC for internal service-to-service communication, real-time streaming, or low-latency systems where bandwidth and speed matter. See detailed comparison.
    • How do I secure gRPC calls? Use TLS/SSL for transport security, implement authentication (JWT, OAuth) via interceptors, and apply authorization policies just like HTTP services. Learn more about service-to-service security.
    • Can gRPC and REST coexist? Yes; ASP.NET Core can host both on different ports or share Kestrel with protocol negotiation, letting you adopt gRPC incrementally.

    Related interview questions

    Further reading

    .NETCsharpReal-time
  199. 199When would you choose gRPC over REST?

    Experienced

    Choose gRPC for internal, high-performance, low-latency communication with strict contract requirements and bidirectional streaming needs. Choose REST for public-facing APIs, browser compatibility, and scenarios where simplicity and discoverability matter more than raw performance. gRPC excels in microservice architectures where you control both client and server; REST remains the standard for open ecosystems and when HTTP semantics and caching are valuable.

    How it works

    gRPC uses HTTP/2 and Protocol Buffers (protobuf) to deliver high-performance, strongly-typed communication with built-in streaming capabilities. REST relies on HTTP/1.1, JSON or XML, and standard request-response patterns. gRPC's binary serialization is faster and more compact than JSON, and HTTP/2 multiplexing allows multiple concurrent streams over a single connection. However, this performance comes at a cost: gRPC requires client libraries and tooling to generate code from .proto files, whereas REST works with any HTTP client, including browsers. gRPC also doesn't naturally fit browser scenarios—browsers cannot easily make gRPC calls without a proxy or gRPC-Web. REST's text-based format is human-readable, easier to debug, and integrates seamlessly with caching proxies, CDNs, and standard HTTP infrastructure.

    Example

    Define a gRPC service for internal order processing:

    csharp
    // order.proto
    syntax = "proto3";
    
    service OrderService {
      rpc ProcessOrder(OrderRequest) returns (OrderResponse);
      rpc StreamOrderUpdates(OrderId) returns (stream OrderUpdate);
    }
    
    message OrderRequest {
      string customer_id = 1;
      repeated LineItem items = 2;
    }
    
    message OrderResponse {
      string order_id = 1;
      string status = 2;
    }
    
    message OrderUpdate {
      string order_id = 1;
      string event = 2;
      int64 timestamp = 3;
    }
    
    // Server implementation
    public class OrderServiceImpl : OrderService.OrderServiceBase
    {
        public override async Task ProcessOrder(
            OrderRequest request, 
            ServerCallContext context)
        {
            // Process order
            return new OrderResponse { OrderId = "ORD123", Status = "Confirmed" };
        }
    
        public override async Task StreamOrderUpdates(
            OrderId request, 
            IServerStreamWriter<OrderUpdate> responseStream,
            ServerCallContext context)
        {
            for (int i = 0; i < 5; i++)
            {
                await responseStream.WriteAsync(new OrderUpdate
                {
                    OrderId = request.Id,
                    Event = $"Update {i}",
                    Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
                });
                await Task.Delay(500);
            }
        }
    }
    
    // Client call
    var channel = GrpcChannel.ForAddress("https://localhost:7000");
    var client = new OrderService.OrderServiceClient(channel);
    var response = await client.ProcessOrderAsync(new OrderRequest { CustomerId = "CUST001" });

    This example shows gRPC's ability to define strict schemas, handle server-side streaming naturally, and leverage code generation for type safety and performance in internal microservice communication.

    Key points to mention

    • Performance: gRPC uses HTTP/2 multiplexing and binary protobuf serialization for lower latency and bandwidth; REST uses HTTP/1.1 and text-based JSON.
    • Streaming: gRPC natively supports bidirectional and server-side streaming; REST requires workarounds like Server-Sent Events or WebSockets.
    • Contract strength: gRPC enforces strict .proto contracts with code generation; REST relies on documentation and conventions.
    • Browser and public APIs: REST integrates seamlessly with browsers and public ecosystems; gRPC requires special handling (gRPC-Web) and is best for internal systems.
    • Debugging and tooling: REST is human-readable and works with curl and standard proxies; gRPC requires specialized tools like grpcurl or Postman extensions.

    Common follow-up questions

    • Can you use gRPC with browsers? Not directly—you need gRPC-Web, which translates browser HTTP requests to gRPC calls via a proxy.
    • How do you version gRPC APIs? Proto versioning uses field numbering and the reserved keyword to maintain backward compatibility without version numbers in the URL path.
    • Should you use gRPC for all internal APIs? No—use gRPC only where latency, throughput, or streaming justify the complexity. Simple internal REST APIs may offer better maintainability.

    Related interview questions

    Further reading

    .NETCsharpReal-time
  200. 200How do you consume messages from a queue in .NET?

    Experienced

    To consume messages from a queue in .NET, implement a BackgroundService that continuously polls or subscribes to a message broker (Azure Service Bus, RabbitMQ, AWS SQS), deserializes each message, processes it idempotently, and acknowledges successful completion—routing failures to a dead-letter queue for later investigation. Libraries like MassTransit or NServiceBus abstract away broker complexity, while raw SDKs offer more control.

    How it works

    Message consumption follows a pull or push model depending on the broker. In a pull model, your consumer actively polls the queue at intervals; in a push model, the broker delivers messages as they arrive. Either way, the pattern is: receive → deserialize → process → acknowledge → move to DLQ on failure.

    A BackgroundService runs indefinitely in the application lifetime, making it ideal for this task. It runs on a thread pool thread and respects graceful shutdown signals. The service should be registered in dependency injection so the host manages its lifetime. When processing, idempotency is critical—if a message is redelivered (due to network failure or crash), processing it again should produce the same result, not duplicate side effects.

    Modern brokers support automatic dead-lettering: if processing fails after a configurable number of retries, the message moves to a separate queue for manual review or fallback logic. This prevents message loss and supports operational debugging.

    Example

    csharp
    public class QueueConsumerService : BackgroundService
    {
        private readonly IServiceProvider _serviceProvider;
        private readonly ILogger<QueueConsumerService> _logger;
    
        public QueueConsumerService(IServiceProvider serviceProvider, ILogger<QueueConsumerService> logger)
        {
            _serviceProvider = serviceProvider;
            _logger = logger;
        }
    
        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
        {
            while (!stoppingToken.IsCancellationRequested)
            {
                try
                {
                    using var scope = _serviceProvider.CreateScope();
                    var queueClient = scope.ServiceProvider.GetRequiredService<IQueueClient>();
    
                    var message = await queueClient.ReceiveMessageAsync(stoppingToken);
                    if (message != null)
                    {
                        var payload = JsonSerializer.Deserialize<OrderMessage>(message.Body);
                        var processor = scope.ServiceProvider.GetRequiredService<IOrderProcessor>();
    
                        await processor.ProcessAsync(payload, stoppingToken);
                        await queueClient.CompleteAsync(message);
                        _logger.LogInformation("Message processed: {MessageId}", message.MessageId);
                    }
                }
                catch (Exception ex)
                {
                    _logger.LogError(ex, "Error processing message");
                    await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
                }
            }
        }
    }
    
    // In Program.cs:
    services.AddSingleton<IQueueClient, AzureServiceBusClient>();
    services.AddScoped<IOrderProcessor, OrderProcessor>();
    services.AddHostedService<QueueConsumerService>();

    This BackgroundService polls an Azure Service Bus queue, deserializes an OrderMessage, processes it via a scoped IOrderProcessor, and acknowledges the message on success; failures log and trigger a retry delay.

    Key points to mention

    • Idempotency: Ensure processing the same message twice yields identical results—use unique message IDs as deduplication keys in your persistence layer.
    • Dead-Letter Queues: Route messages that exceed retry limits to a DLQ for manual remediation, preventing poison messages from blocking the main queue.
    • Graceful Shutdown: BackgroundService respects CancellationToken; await ongoing work and release resources cleanly to avoid message loss during deployment.
    • Scope Management: Create a new DI scope per message to avoid scoped service reuse across messages; use IServiceProvider.CreateScope().
    • Serialization: Always validate and version your message contracts; handle deserialization failures separately from business logic failures.

    Common follow-up questions

    • How do you ensure exactly-once delivery semantics? — Combine idempotent processing with distributed transactions or saga patterns; some brokers offer transactional guarantees, but application-level deduplication is safer.
    • What's the difference between BackgroundService and a raw while-loop in a hosted service? — BackgroundService provides lifecycle hooks (StartAsync, StopAsync) and integrates cleanly with health checks, whereas a raw loop requires manual cancellation token handling.
    • How would you scale this to multiple consumer instances? — Modern brokers partition queues and balance load; ensure your consumer group name is consistent across replicas so the broker distributes messages fairly.

    Related interview questions

    Further reading

    .NETCsharp
  201. 201What is the outbox pattern?

    Experienced

    The outbox pattern is a technique for ensuring reliable message publishing in distributed systems by writing both the business state change and the outgoing message to the same database transaction, then publishing from an outbox table asynchronously. This eliminates the risk of publishing messages without persisting the business change, or vice versa, solving the dual-write problem in event-driven architectures.

    How it works

    The outbox pattern works by treating message publishing as part of your transactional boundary rather than as a separate operation. When a business event occurs—such as an order being placed—you write two things atomically to your database:

    1. The primary business entity (e.g., the Order record)
    2. One or more rows in an Outbox table containing the message to be published

    Since both writes happen in the same database transaction, you get ACID guarantees: either both succeed or both roll back. No partial states exist.

    Separately, a background process (or polling mechanism) continuously reads unpublished rows from the Outbox table, publishes them to your message broker (RabbitMQ, Azure Service Bus, Kafka, etc.), and marks them as published. If publishing fails, the message remains in the outbox for retry. If the background process crashes, the message is never lost because it's already persisted.

    This decouples the speed and reliability of your business logic from the speed and reliability of your messaging infrastructure. Your transactional operation completes quickly (just a database write), while message delivery happens independently.

    Example

    csharp
    // Domain model and outbox entry
    public class Order
    {
        public int Id { get; set; }
        public string CustomerId { get; set; }
        public decimal TotalAmount { get; set; }
    }
    
    public class OutboxMessage
    {
        public int Id { get; set; }
        public string AggregateId { get; set; }
        public string MessageType { get; set; }
        public string Payload { get; set; }
        public DateTime CreatedAt { get; set; }
        public DateTime? PublishedAt { get; set; }
    }
    
    // Service that creates an order and writes to outbox
    public class OrderService
    {
        private readonly DbContext _dbContext;
    
        public async Task CreateOrderAsync(CreateOrderRequest request)
        {
            var order = new Order
            {
                CustomerId = request.CustomerId,
                TotalAmount = request.TotalAmount
            };
    
            _dbContext.Orders.Add(order);
    
            // Write outbox message in same transaction
            var outboxMessage = new OutboxMessage
            {
                AggregateId = order.Id.ToString(),
                MessageType = "OrderCreated",
                Payload = JsonSerializer.Serialize(new { order.Id, order.CustomerId, order.TotalAmount }),
                CreatedAt = DateTime.UtcNow,
                PublishedAt = null
            };
    
            _dbContext.OutboxMessages.Add(outboxMessage);
    
            // Single atomic transaction
            await _dbContext.SaveChangesAsync();
        }
    }
    
    // Background publisher that polls and publishes
    public class OutboxPublisher
    {
        private readonly DbContext _dbContext;
        private readonly IMessagePublisher _publisher;
    
        public async Task PublishPendingMessagesAsync()
        {
            var unpublishedMessages = await _dbContext.OutboxMessages
                .Where(m => m.PublishedAt == null)
                .ToListAsync();
    
            foreach (var message in unpublishedMessages)
            {
                try
                {
                    await _publisher.PublishAsync(message.MessageType, message.Payload);
                    message.PublishedAt = DateTime.UtcNow;
                    await _dbContext.SaveChangesAsync();
                }
                catch
                {
                    // Retry on next poll
                }
            }
        }
    }
    

    This example shows how an order and its corresponding outbox message are saved together, then published asynchronously by a background service that marks them as published only after successful delivery.

    Key points to mention

    • Atomicity: Business state and message are written in the same transaction, guaranteeing consistency; if one fails, both roll back.
    • Reliability: Messages are never lost; if publishing fails, they remain in the outbox for retry. The background publisher can crash and restart without losing data.
    • Decoupling: Your business logic doesn't block waiting for message broker acknowledgment; it completes as soon as the database write completes.
    • Idempotency: Works best when consumers implement idempotent handlers (see how to design idempotent message consumers) because messages may be delivered multiple times during retries.
    • Polling overhead: Requires a background process that polls the outbox table regularly, creating a small delay between when the business change occurs and when consumers see the message.

    Common follow-up questions

    • How do you handle outbox messages that fail to publish repeatedly? Implement a dead-letter table after a maximum retry count, log the failure, and alert operators so they can investigate and replay manually if needed.
    • What's the relationship between the outbox pattern and the Unit of Work pattern? Unit of Work manages the transaction boundary, while the outbox pattern ensures both the business change and the message are included within that boundary.
    • Can you use the outbox pattern with Channel-based background queue pattern instead of polling? You could, but polling is safer for production because it survives application restarts and naturally provides backpressure.

    Related interview questions

    Further reading

    .NETCsharpArchitecture
  202. 202What is idempotency and how do you implement it in APIs?

    Experienced

    Idempotency ensures that an operation produces the same result regardless of how many times it is executed. In APIs, you implement it by accepting an Idempotency-Key header from the client, storing the response associated with that key, and returning the cached response on subsequent requests with the same key. This prevents duplicate side effects (like double charges) when clients retry requests due to timeouts or network failures.

    How it works

    Idempotent APIs solve a real problem: when a client sends a request and doesn't receive a response (due to network failure, timeout, or server crash), it cannot know whether the operation succeeded. The safe approach is to retry, but without idempotency, the second request repeats the side effect. A bank transfer might charge twice, or an order might be placed twice.

    The idempotency pattern works by assigning each client request a unique key (typically a UUID). The server stores this key along with the response. When the same key arrives again, the server returns the stored response without re-executing the business logic. The key is usually passed in an Idempotency-Key header, and the storage backend is typically a cache (Redis) or database. Keys should expire after a reasonable period (e.g., 24 hours) to prevent unbounded storage growth.

    This approach is especially important for message-driven systems and event-driven architectures, where exactly-once semantics are critical.

    Example

    csharp
    
    public class IdempotencyMiddleware
    {
        private readonly RequestDelegate _next;
        private readonly IDistributedCache _cache;
    
        public IdempotencyMiddleware(RequestDelegate next, IDistributedCache cache)
        {
            _next = next;
            _cache = cache;
        }
    
        public async Task InvokeAsync(HttpContext context)
        {
            if (!context.Request.Headers.TryGetValue("Idempotency-Key", out var idempotencyKey))
            {
                await _next(context);
                return;
            }
    
            var cacheKey = $"idempotency:{idempotencyKey}";
            var cachedResponse = await _cache.GetStringAsync(cacheKey);
    
            if (cachedResponse != null)
            {
                context.Response.StatusCode = StatusCodes.Status200OK;
                context.Response.ContentType = "application/json";
                await context.Response.WriteAsync(cachedResponse);
                return;
            }
    
            var originalBody = context.Response.Body;
            using var memoryStream = new MemoryStream();
            context.Response.Body = memoryStream;
    
            await _next(context);
    
            memoryStream.Seek(0, SeekOrigin.Begin);
            var responseBody = await new StreamReader(memoryStream).ReadToEndAsync();
    
            if (context.Response.IsSuccessStatusCode())
            {
                await _cache.SetStringAsync(cacheKey, responseBody, 
                    new DistributedCacheEntryOptions 
                    { 
                        AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(24) 
                    });
            }
    
            memoryStream.Seek(0, SeekOrigin.Begin);
            await memoryStream.CopyToAsync(originalBody);
        }
    }
    

    This middleware intercepts requests with an Idempotency-Key header, checks a distributed cache for a prior response, returns it if found, and stores successful responses for future retries.

    Key points to mention

    • Unique key per request: Use UUID or a client-generated token; never reuse keys across different logical operations.
    • Storage backend: Redis or in-memory caching for high-performance lookups; database for durability and cross-service consistency.
    • Expiration strategy: Cache keys should expire (24 hours is common) to prevent unlimited memory growth and allow clients to reuse keys safely after the window.
    • Safe for POST, PUT, DELETE: Idempotency protects any mutation; GET requests are already idempotent by definition.
    • Status code considerations: Return the same HTTP status code (200, 201, etc.) on replayed responses to maintain client consistency.

    Common follow-up questions

    • What happens if the idempotency key expires but the client retries? Should you reject the retry or treat it as a new request?
    • How do you handle idempotency across microservices or distributed systems where the cache is not shared?
    • Can you combine idempotency with optimistic locking or version control to prevent race conditions?

    Related interview questions

    Further reading

    .NETCsharp
  203. 203How do you implement pagination efficiently?

    Experienced

    Efficient pagination depends on your data volume and access patterns. Offset pagination (SKIP/TAKE) is simple but becomes slow on deep pages because the database must scan and discard millions of rows. Keyset pagination (also called cursor or seek-based pagination) filters rows using an indexed column and scales linearly regardless of page depth, making it the preferred approach for large datasets.

    How it works

    Offset pagination uses SKIP(pageNumber * pageSize).TAKE(pageSize). The database retrieves and discards the first N rows every request, causing O(n) performance degradation as you move through pages. This is acceptable for small datasets or when users rarely navigate beyond page 2–3.

    Keyset pagination instead tracks the last row's identifier and queries WHERE id > lastId ORDER BY id LIMIT pageSize. Since the filter uses an indexed column, the database jumps directly to the continuation point—O(1) seek time. You return a cursor (typically the last row's ID) alongside results, and the client uses it to fetch the next page. This approach works best with natural, immutable sort keys (primary keys or timestamps).

    When to use each: Offset is fine for admin panels with small tables or when users need random access (page 47 of 100). Keyset suits public APIs, infinite scrolls, and datasets with millions of rows. Hybrid approaches exist: databases like PostgreSQL support LIMIT ... OFFSET ... FETCH optimizations, and some teams use offset for small pages with server-side caching.

    Example

    csharp
    // Keyset pagination with Entity Framework Core
    public async Task<(List<Product> items, int? nextCursor)> GetProductsAsync(
        int? afterId, 
        int pageSize = 20)
    {
        var query = _context.Products.AsQueryable();
        
        if (afterId.HasValue)
        {
            query = query.Where(p => p.Id > afterId.Value);
        }
        
        // Fetch one extra to determine if there's a next page
        var items = await query
            .OrderBy(p => p.Id)
            .Take(pageSize + 1)
            .ToListAsync();
        
        int? nextCursor = null;
        if (items.Count > pageSize)
        {
            items = items.Take(pageSize).ToList();
            nextCursor = items.Last().Id;
        }
        
        return (items, nextCursor);
    }
    
    // Usage: var (products, cursor) = await GetProductsAsync(afterId: 150);
    // Next request: var (nextBatch, nextCursor) = await GetProductsAsync(afterId: cursor);

    This example uses the product ID as a cursor, fetching one extra row to detect end-of-results, then returns the last ID for the next request. The WHERE id > lastId clause ensures O(1) performance regardless of dataset size.

    Key points to mention

    • Index requirement: Keyset pagination must filter on an indexed column; otherwise, performance degrades. Ensure your cursor column has a database index.
    • Sort stability: Always use immutable, unique columns (primary key or unique timestamp). Mutable data can cause rows to appear twice or be skipped across page transitions.
    • Backward navigation: Keyset pagination naturally supports forward-only navigation. Backward requires reversing the sort order and different client logic; offset is simpler if bidirectional paging is required.
    • Cursor encoding: In production, base64-encode cursors to hide implementation details and allow future schema changes without breaking clients.
    • Page size validation: Always cap pageSize server-side (e.g., max 100) to prevent DOS attacks or accidental large queries.

    Common follow-up questions

    • How do you handle deleted rows between requests? Keyset pagination may skip rows if a row with ID between the cursor and next page is deleted. Document this behavior or implement soft deletes; offset has the same issue at scale.
    • Can you paginate by multiple columns? Yes, extend the WHERE clause: WHERE (column1 > value1) OR (column1 = value1 AND column2 > value2). This requires careful cursor encoding.
    • What about total count? Keyset pagination doesn't easily provide total row count without a separate query. If you need it, run a cached COUNT(*) separately; never force COUNT(*) on every paginated request.

    Related interview questions

    Further reading

    .NETCsharp
  204. 204How do you handle file uploads in ASP.NET Core?

    Experienced

    File uploads in ASP.NET Core are handled using IFormFile for small files or streaming APIs for large files, with mandatory validation of file size, type, and content. Always sanitize filenames, store uploads in blob storage (never the web root for security), and implement rate limiting and virus scanning for production systems.

    How it works

    ASP.NET Core provides multiple approaches depending on file size and performance requirements. The IFormFile interface represents an uploaded file from the request, buffering the entire file in memory or disk. This works well for files under 100 MB but becomes problematic for larger uploads due to memory pressure.

    For large files, use IFormFileCollection with streaming or access the raw request body via HttpContext.Request.Body to process data incrementally. Streaming prevents memory exhaustion and enables progress tracking.

    Validation must occur at multiple layers: check file size against configured limits (set via FormOptions.MultipartBodyLengthLimit), verify MIME type by examining file extension and magic bytes (never trust the client's Content-Type header), and optionally scan for malware. Store files in blob storage services like Azure Blob Storage or AWS S3, never in the web application directory, to separate concerns and enable scaling.

    Sanitize and validate filenames because user input is untrusted—reject path traversal attempts like ../../../etc/passwd and generate safe identifiers. Implement quotas and rate limiting to prevent abuse.

    Example

    csharp
    [HttpPost("upload")]
    public async Task<IActionResult> UploadFile(IFormFile file)
    {
        if (file == null || file.Length == 0)
            return BadRequest("No file provided");
    
        var maxFileSize = 10 * 1024 * 1024; // 10 MB
        if (file.Length > maxFileSize)
            return BadRequest($"File exceeds {maxFileSize} bytes");
    
        var allowedExtensions = new[] { ".pdf", ".docx", ".txt" };
        var fileExtension = Path.GetExtension(file.FileName).ToLower();
        if (!allowedExtensions.Contains(fileExtension))
            return BadRequest("File type not allowed");
    
        var fileName = $"{Guid.NewGuid()}{fileExtension}";
        var filePath = Path.Combine("/uploads", fileName);
    
        using (var stream = new FileStream(filePath, FileMode.Create))
        {
            await file.CopyToAsync(stream);
        }
    
        return Ok(new { fileName, size = file.Length });
    }
    

    This controller action validates file presence and size, checks the extension against a whitelist, generates a safe filename using a GUID, and saves it to disk—in production, replace the file system with blob storage.

    Key points to mention

    • Never trust client filenames: Always sanitize and regenerate identifiers to prevent directory traversal and overwrite attacks.
    • Validate at multiple levels: Check size limits, file extension, MIME type, and file magic bytes to detect disguised executables.
    • Use blob storage, not web root: Store files in Azure Blob Storage, AWS S3, or similar services to decouple storage from your application and enable secure access control.
    • Stream large files: For uploads exceeding 100 MB, use streaming APIs instead of IFormFile to avoid memory exhaustion and timeout issues.
    • Implement rate limiting and quotas: Prevent abuse by limiting upload frequency per user and enforcing total storage quotas.

    Common follow-up questions

    • How do you handle file uploads securely? Combine size limits, whitelist-based type validation, virus scanning, storing files outside the web root, and generating randomized filenames. Consider how you store secrets safely if using external storage credentials.
    • How do you handle very large file uploads (gigabytes)? Use chunked uploads with resumable transfer protocols, stream directly to blob storage without buffering, and implement client-side hashing for integrity verification.
    • How do you download files securely? Serve files from blob storage with time-limited SAS tokens, validate user permissions before returning the file, and set appropriate cache headers and Content-Disposition to prevent caching of sensitive files.

    Related interview questions

    Further reading

    .NETCsharpaspnetcore
  205. 205How do you containerize an ASP.NET Core app?

    Experienced

    Containerize an ASP.NET Core app by creating a multi-stage Dockerfile that uses the official .NET SDK image for compilation and a smaller runtime or chiseled image for execution, or by using the built-in dotnet publish /t:PublishContainer command in .NET 8+. Both approaches produce lightweight, secure container images suitable for production deployment.

    How it works

    Containerization packages your ASP.NET Core application with its runtime dependencies into a standardized image that runs consistently across environments. A multi-stage Dockerfile separates the build phase from the runtime phase, dramatically reducing final image size by excluding the .NET SDK, which is only needed during compilation.

    In the first stage, you use the mcr.microsoft.com/dotnet/sdk image to restore NuGet packages and publish the application. In the second stage, you use a leaner image like mcr.microsoft.com/dotnet/aspnet or the new chiseled image (based on Ubuntu Chiseled, with minimal OS footprint) to only include the runtime. This two-stage approach typically reduces image size from 800+ MB to 200–400 MB.

    Alternatively, .NET 8 introduced the PublishContainer target, which automates Dockerfile generation and build internally using the dotnet CLI. You configure container settings in your project file, and a single command builds and (optionally) pushes the image.

    Example

    csharp
    # Dockerfile – Multi-stage approach
    FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
    WORKDIR /src
    COPY ["MyApp.csproj", "."]
    RUN dotnet restore "MyApp.csproj"
    COPY . .
    RUN dotnet publish "MyApp.csproj" -c Release -o /app/publish
    
    FROM mcr.microsoft.com/dotnet/aspnet:8.0-chiseled
    WORKDIR /app
    COPY --from=build /app/publish .
    EXPOSE 8080
    ENTRYPOINT ["dotnet", "MyApp.dll"]
    

    This Dockerfile builds your app in a heavy SDK container and then copies only the published binaries into a minimal chiseled runtime container, keeping the final image lean and secure.

    Alternatively, add this to your project file for automated containerization:

    csharp
    <PropertyGroup>
      <ContainerImageName>myapp</ContainerImageName>
      <ContainerImageTag>latest</ContainerImageTag>
      <ContainerRegistry>myregistry.azurecr.io</ContainerRegistry>
    </PropertyGroup>
    

    Then run dotnet publish /t:PublishContainer, and the CLI handles the build and image creation automatically.

    Key points to mention

    • Multi-stage builds – Separate compilation (SDK) from runtime (aspnet) to minimize final image size and attack surface.
    • Chiseled images – Use Ubuntu Chiseled base images for the smallest, most secure runtime footprint without a shell.
    • PublishContainer target – .NET 8+ simplifies containerization by generating Dockerfile automatically and supporting registry login integration.
    • Port exposure and health checks – Configure EXPOSE, HEALTHCHECK, and environment variables (e.g., ASPNETCORE_URLS) for orchestration compatibility.
    • Layer caching optimization – Order Dockerfile commands (restore before COPY source) to maximize Docker build cache hits during CI/CD.

    Common follow-up questions

    • How do you handle secrets in a containerized ASP.NET Core app? – Use Docker secrets, environment variables injected at runtime, or orchestration platform secrets (Kubernetes Secrets, Azure Key Vault) rather than baking them into the image.
    • What's the difference between the aspnet and chiseled images? – The chiseled image removes the OS shell and unnecessary utilities, reducing attack surface and image size by ~50%, at the cost of limited debugging capability.
    • How do you optimize Dockerfile layers for faster builds? – Order commands by change frequency (stable dependencies first, volatile source code last), use .dockerignore to exclude unnecessary files, and leverage multi-stage builds to discard build tools.

    Related interview questions

    Further reading

    .NETCsharpaspnetcoreDevOps
  206. 206What is the difference between framework-dependent and self-contained deployment?

    Experienced

    Framework-dependent deployment (FDD) publishes only your application code, assuming the .NET runtime is already installed on the target machine. Self-contained deployment (SCD) bundles the entire .NET runtime with your application, allowing it to run on systems without .NET pre-installed. The choice affects package size, deployment complexity, and portability.

    How it works

    In a framework-dependent deployment, the publish output contains only your compiled assemblies, configuration files, and dependencies. When the application runs, it requires the .NET runtime (matching or compatible version) to be present on the system. The operating system's PATH or global installation locations are searched for the runtime. This model is lighter and faster to publish, but creates a runtime dependency.

    In a self-contained deployment, the publish output includes your code and the complete .NET runtime tailored for the target platform (Windows x64, Linux ARM64, macOS, etc.). The application launches with its bundled runtime, requiring no external .NET installation. This guarantees consistency across environments but increases package size significantly—often 60–100+ MB depending on trimming and platform.

    Both deployment models are supported in modern .NET (6.0+). The choice is controlled in your project file via the <SelfContained> property and specified during dotnet publish.

    Example

    csharp
    // .csproj configuration
    
    // Framework-dependent (default for many scenarios)
    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net8.0</TargetFramework>
        <SelfContained>false</SelfContained>
      </PropertyGroup>
    </Project>
    
    // Self-contained (includes runtime for Linux x64)
    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net8.0</TargetFramework>
        <SelfContained>true</SelfContained>
        <RuntimeIdentifier>linux-x64</RuntimeIdentifier>
      </PropertyGroup>
    </Project>

    The first configuration publishes a smaller package that requires .NET 8 on the target machine; the second embeds the entire .NET runtime for Linux x64, enabling standalone execution.

    Comparison

    Aspect Framework-Dependent (FDD) Self-Contained (SCD)
    Package Size Small (5–50 MB typically) Large (60–200+ MB with runtime)
    Runtime Requirement Must be pre-installed Bundled; no pre-installation needed
    Platform Specificity Single build for compatible runtimes Requires separate build per target OS/architecture
    Deployment Speed Fast (smaller transfer) Slower (larger package to transfer)
    Runtime Updates Updated centrally on the machine Each app must be republished to update runtime
    Use Case Internal servers, containers with .NET base images Customer machines, isolated environments, offline deployment
    What is the difference between framework-dependent and self-contained deployment diagram

    Key points to mention

    • Framework-dependent is the default for many projects and assumes the target environment has .NET installed; ideal for controlled corporate environments and containerized deployments.
    • Self-contained guarantees portability but requires specifying a RuntimeIdentifier (e.g., win-x64, linux-arm64) and creates larger distributions.
    • Trimming and Ready-to-Run (R2R) compilation can reduce self-contained package size, though they introduce complexity and potential runtime behavior changes.
    • Security updates differ: framework-dependent apps benefit from centralized runtime patches; self-contained apps require redeployment for security fixes.
    • Containers typically use framework-dependent deployment layered on official .NET base images, keeping images lean and leveraging the host image's runtime.

    Common follow-up questions

    • How does trimming reduce self-contained package size, and what are the risks?
    • When would you choose framework-dependent over self-contained, and why?
    • How do runtime identifiers (RIDs) affect multi-platform self-contained deployment?

    Related interview questions

    What is the difference between framework-dependent and self-contained deployment?
    .NETCsharpDevOps
  207. 207What is trimming?

    Experienced

    Trimming is an IL-level optimization that removes unused code from self-contained .NET applications before deployment, significantly reducing binary size. It analyzes your application's entry point, follows all reachable code paths, and discards anything unreachable. This is especially valuable for Native AOT and serverless scenarios where startup time and deployment package size are critical.

    How it works

    Trimming performs static analysis of your IL code to build a dependency graph. Starting from your application's entry point (typically Main()), it marks every method, type, and field that can be reached through normal execution paths as "live." Everything else—unused NuGet packages, unreferenced classes, dead code branches—is removed during the build phase.

    The challenge arises with reflection. When code uses reflection to dynamically load types (e.g., Type.GetType("FullName") or deserializers), the trimmer cannot statically determine what will be accessed at runtime. By default, trimmed types are removed, causing null returns or runtime exceptions. To preserve them, you must add trim annotations using [DynamicallyAccessedMembers] attributes or TrimmerRootAssembly settings to tell the trimmer "keep this code—it's accessed dynamically."

    When publishing with <PublishTrimmed>true</PublishTrimmed>, the .NET build pipeline applies trimming to both your code and framework libraries. Warning level can be controlled with <TrimmerSingleWarn> and <EnableTrimAnalyzer> to surface issues during development rather than at runtime.

    Example

    csharp
    using System.Reflection;
    
    public class ConfigLoader
    {
        // Without trim annotations, this might fail after trimming
        public static object? LoadConfig(string typeName)
        {
            var type = Type.GetType(typeName);
            return type != null ? Activator.CreateInstance(type) : null;
        }
    }
    
    public class Program
    {
        static void Main()
        {
            // This works—direct reference keeps ConfigOptions alive
            var config = new ConfigOptions();
            
            // This might fail after trimming because typeName is dynamic
            var loaded = ConfigLoader.LoadConfig("MyApp.ConfigOptions");
        }
    }
    
    public class ConfigOptions { }
    

    Without trim annotations, ConfigOptions could be trimmed away because the trimmer cannot see that LoadConfig() uses it dynamically via string name.

    Key points to mention

    • Size reduction: Trimmed apps are dramatically smaller (often 50–80% reduction), improving deployment and cold-start performance.
    • Reflection-safe code requires annotations: Use [DynamicallyAccessedMembers], [RequiresUnreferencedCode], or TrimmerRootAssembly metadata to preserve reflection targets.
    • Framework-aware: Many .NET libraries are trim-ready; check trimming warnings early in development using <EnableTrimAnalyzer>true</EnableTrimAnalyzer>.
    • Incompatible with framework-dependent deployment: Trimming only applies to self-contained apps because the framework is already shared.
    • Testing is essential: Always test trimmed binaries end-to-end; reflection bugs often surface only after deployment.

    Common follow-up questions

    • Can I trim framework-dependent apps? No—trimming removes code permanently. Framework-dependent apps rely on the shared runtime, so it's not applicable.
    • How do I debug trimming issues? Enable <EnableTrimAnalyzer> in your project file to get compiler warnings during development, and review the trimmer's warning log.
    • Does trimming work with reflection-heavy frameworks like EF Core or Newtonsoft.Json? Only if the library is trim-ready (e.g., newer EF Core versions). Older or heavily reflection-based libraries may require source generators or explicit annotations.

    Related interview questions

    .NETCsharpDevOps
  208. 208What is the difference between Task.WhenAll and Parallel.ForEach?

    Experienced

    Task.WhenAll awaits multiple async operations concurrently and returns when all complete, while Parallel.ForEach runs synchronous, CPU-bound work across multiple threads in a loop. Task.WhenAll is for I/O-bound async scenarios; Parallel.ForEach is for CPU-bound work. For async loops, use Parallel.ForEachAsync (.NET 6+) to get the best of both worlds with bounded concurrency.

    How it works

    Task.WhenAll accepts a collection of tasks and returns a single task that completes when all input tasks finish. It doesn't spawn new threads—it coordinates already-running async operations. If one task faults, the returned task faults with an AggregateException. It's ideal for I/O-bound work (HTTP requests, database calls) where threads are not blocked.

    Parallel.ForEach is a thread-based parallelism construct that divides work across a thread pool. It blocks the calling thread until all iterations complete. The Parallel class uses work-stealing queues to balance load. It's designed for CPU-bound operations where you want true parallel execution on multiple cores.

    Parallel.ForEachAsync (introduced in .NET 6) bridges the gap: it runs async work in a loop with bounded concurrency, respecting a MaxDegreeOfParallelism limit. This prevents thread starvation and resource exhaustion when awaiting thousands of I/O operations.

    Example

    csharp
    // Task.WhenAll - for async I/O-bound operations
    async Task FetchMultipleUrls(string[] urls)
    {
        var httpClient = new HttpClient();
        var tasks = urls.Select(url => httpClient.GetAsync(url));
        var results = await Task.WhenAll(tasks);
        Console.WriteLine($"Fetched {results.Length} responses");
    }
    
    // Parallel.ForEach - for CPU-bound synchronous work
    void ProcessLargeDataset(int[] data)
    {
        Parallel.ForEach(data, item =>
        {
            var result = ExpensiveCalculation(item);
            Console.WriteLine(result);
        });
    }
    
    // Parallel.ForEachAsync - for async I/O in a loop with bounded concurrency
    async Task FetchWithBoundedConcurrency(string[] urls)
    {
        var httpClient = new HttpClient();
        await Parallel.ForEachAsync(urls, 
            new ParallelOptions { MaxDegreeOfParallelism = 5 },
            async (url, ct) =>
            {
                var response = await httpClient.GetAsync(url, ct);
                Console.WriteLine($"Fetched {url}: {response.StatusCode}");
            });
    }

    The first example fires many HTTP requests concurrently without blocking threads. The second uses all cores for intensive CPU calculations. The third limits concurrent HTTP requests to 5, preventing resource exhaustion.

    Comparison table

    Aspect Task.WhenAll Parallel.ForEach Parallel.ForEachAsync
    Use case Awaiting multiple async operations CPU-bound synchronous loops Async loops with bounded concurrency
    Threading No new threads; coordinates existing tasks Creates/reuses thread pool threads Uses async/await; limited degree of parallelism
    Blocks caller No (async method awaits) Yes (blocks until all iterations done) No (async method awaits)
    Concurrency limit No built-in limit (starts all tasks immediately) MaxDegreeOfParallelism controls thread count MaxDegreeOfParallelism controls concurrent operations
    Exception handling AggregateException on any task fault OperationCanceledException or AggregateException AggregateException if any operation fails
    Introduced .NET 4.0 .NET 4.0 .NET 6.0
    What is the difference between Task.WhenAll and Parallel.ForEach diagram

    Key points to mention

    • Task.WhenAll is not blocking; it's an async coordination mechanism. The calling thread is released to handle other work while tasks run.
    • Parallel.ForEach is thread-based parallelism for compute-intensive work; using it for I/O-bound operations wastes threads and reduces scalability.
    • Parallel.ForEachAsync respects CancellationToken and allows graceful shutdown. Use it when you have 100+ async operations and need backpressure.
    • Task.WhenAll starts all tasks eagerly, which can overwhelm servers; consider semaphore-based throttling or Parallel.ForEachAsync for large batches.
    • Parallel.ForEach uses Environment.ProcessorCount by default; Parallel.ForEachAsync defaults to MaxDegreeOfParallelism = -1 (unlimited, but with async fairness).

    Common follow-up questions

    • How do you handle exceptions from Task.WhenAll? – Wrap in try-catch; WhenAll throws AggregateException (or its inner exceptions in .NET 5+ with proper awaiting). Use Task.WhenAll(...).ConfigureAwait(false) in libraries to avoid deadlocks.
    • What is MaxDegreeOfParallelism = -1? – It means unlimited parallelism, but the scheduler respects async fairness; tasks don't actually run more concurrently than the thread pool allows. Set it to Environment.ProcessorCount for CPU-bound work or a smaller number (e.g., 5–10) for I/O-bound async to avoid thread starvation.
    • Should I use Task.Run with Task.WhenAll? – Only if you have synchronous CPU work that you want to parallelize; otherwise, you're wasting threads. Read more on Task.Run vs async/await patterns.

    Related interview questions

    What is the difference between Task.WhenAll and Parallel.ForEach?
    .NETCsharpasync
  209. 209What are IAsyncEnumerable and await foreach?

    Experienced

    IAsyncEnumerable<T> is an asynchronous counterpart to IEnumerable<T> that allows you to stream data asynchronously, one item at a time. await foreach is the syntax that consumes it, automatically awaiting each item as it arrives. This pattern is essential for scenarios where data arrives over time—such as reading database rows in batches, consuming API paginated responses, or processing real-time event streams—without blocking threads or loading everything into memory at once.

    How it works

    IAsyncEnumerable<T> defines a single method, GetAsyncEnumerator(), which returns an IAsyncEnumerator<T>. The enumerator has two key members:

    • MoveNextAsync() – returns a ValueTask<bool> that asynchronously advances to the next item and signals when the sequence ends
    • Current – a property returning the current item

    When you write await foreach (var item in source), the compiler generates code that calls MoveNextAsync() repeatedly, awaiting each call. Each iteration is asynchronous—work can happen between items, such as network I/O or database queries. This is fundamentally different from regular foreach, which blocks and fetches everything upfront.

    You typically create async enumerables using an async iterator method with the yield return keyword combined with await. When you yield return in an async method, the compiler transforms it into a state machine that produces an IAsyncEnumerable<T>.

    Example

    csharp
    // Async iterator method that yields items asynchronously
    async IAsyncEnumerable<int> FetchDataFromDatabase()
    {
        for (int pageNum = 1; pageNum <= 5; pageNum++)
        {
            await Task.Delay(500); // Simulate database query delay
            Console.WriteLine($"Fetching page {pageNum}...");
            yield return pageNum * 100;
        }
    }
    
    // Consume with await foreach
    async Task Main()
    {
        await foreach (var result in FetchDataFromDatabase())
        {
            Console.WriteLine($"Received: {result}");
        }
    }
    

    This example fetches data in pages asynchronously; each page arrives after a 500ms delay, and the consumer processes each one immediately rather than waiting for all pages to load first.

    Key points to mention

    • Memory efficient – only one item is held in memory at a time; ideal for large or unbounded sequences
    • Naturally composes with async/await – no manual Task wrapping or thread juggling needed
    • Supports cancellation – pass a CancellationToken via WithCancellation() to cleanly stop iteration
    • Implements IAsyncDisposable – the enumerator should dispose resources asynchronously when iteration completes or is canceled (use IAsyncDisposable for cleanup)
    • ValueTask optimization – MoveNextAsync() returns ValueTask<bool>, not Task<bool>, to avoid allocations when already complete (see ValueTask and when to use it)

    Common follow-up questions

    • How do you cancel an async enumerable? – Call WithCancellation(cancellationToken) on the enumerable, or implement CancellationToken as a parameter in your async iterator method and check token.ThrowIfCancellationRequested()
    • When should you use IAsyncEnumerable vs. Task<List<T>>? – Use IAsyncEnumerable for large datasets or event streams where you process items as they arrive; use Task<List<T>> when you need all data before processing
    • Can you use LINQ with async enumerables? – Yes, but only with System.Linq.Async NuGet package, since LINQ-to-Objects doesn't natively support async operations

    Related interview questions

    Further reading

    .NETCsharpasync
  210. 210What is pattern matching in C#?

    Experienced

    Pattern matching in C# is a feature that tests whether a value matches a specific shape or condition using declarative syntax. It enables you to extract data and branch logic concisely using is expressions and switch expressions, supporting type patterns, property patterns, relational patterns, list patterns, and logical combinations. This reduces boilerplate and makes intent clearer than traditional if-else chains.

    How it works

    Pattern matching allows you to destructure and test values against patterns in a single expression. When you use the is operator or a switch expression, the runtime evaluates whether the value conforms to the pattern. If it does, you can extract nested values or properties without additional casting or null checks.

    The key patterns you'll encounter are:

    • Type patterns: Test if a value is of a specific type (e.g., is string).
    • Property patterns: Test nested properties (e.g., is Person { Age: > 18 }).
    • Relational patterns: Use comparison operators like >, <, >=, ==.
    • List patterns: Match array or list elements (e.g., is [1, 2, ..]).
    • Logical patterns: Combine patterns with and, or, and not.

    Pattern matching works with both is expressions for boolean checks and switch expressions for multi-branch logic. Unlike traditional switch statements, switch expressions are expressions (they return a value) and support patterns directly.

    Example

    csharp
    public record Person(string Name, int Age);
    public record Product(string Name, decimal Price);
    
    public string GetDiscount(object obj) => obj switch
    {
        Person { Age: >= 65 } p => $"{p.Name} gets senior discount",
        Person { Age: < 18 } p => $"{p.Name} gets youth discount",
        Product { Price: > 1000 } pr => $"{pr.Name} gets bulk discount",
        Product { Price: >= 100 and < 1000 } pr => $"{pr.Name} gets standard discount",
        string s => $"String value: {s}",
        [] => "Empty collection",
        [1, 2, ..] => "List starts with 1, 2",
        null => "Value is null",
        _ => "No discount"
    };
    
    // Usage
    Console.WriteLine(GetDiscount(new Person("Alice", 70)));        // Alice gets senior discount
    Console.WriteLine(GetDiscount(new Product("Laptop", 1500m)));   // Laptop gets bulk discount
    Console.WriteLine(GetDiscount(new int[] { 1, 2, 3, 4 }));       // List starts with 1, 2
    

    This example demonstrates property patterns with relational operators, logical patterns combining conditions, list patterns with the range operator .., and null checking—all in a single, readable switch expression.

    Key points to mention

    • Pattern matching reduces null checks and casting boilerplate, improving code readability and safety.
    • Switch expressions (introduced in C# 8) are preferred over switch statements because they return values and support all pattern types.
    • Property patterns work seamlessly with records and properties, making destructuring natural and concise.
    • Patterns can be combined with logical operators (and, or, not) for complex conditions without nested if statements.
    • List patterns (C# 11+) enable powerful array and collection matching, including element extraction and range patterns.

    Common follow-up questions

    • What's the difference between switch expressions and switch statements? Switch expressions return values and support all pattern types directly; switch statements use case: labels and require break statements.
    • Can pattern matching be used with custom types? Yes, especially records and types with accessible properties. You can also implement custom patterns or use deconstruction.
    • How does pattern matching improve performance? It doesn't inherently improve runtime performance, but it reduces allocations from unnecessary boxing, casting, and intermediate variables, and makes intent clearer for compiler optimization.

    Related interview questions

    Further reading

    .NETCsharpArchitecture
  211. 211What are spans used for in everyday code?

    Experienced

    Span<T> and ReadOnlySpan<T> are lightweight stack-allocated types that provide views into contiguous memory—arrays, strings, or unmanaged buffers—without copying data. They enable zero-allocation slicing and substring operations, making them essential for high-performance parsing, formatting, and string processing where allocation overhead is unacceptable. Unlike traditional substring operations that allocate new strings, spans reference the original memory, keeping the garbage collector uninvolved.

    How it works

    A span is a value type (struct) that internally stores a pointer to memory and a length. It acts as a window or view into existing data rather than owning or copying that data. ReadOnlySpan<T> adds immutability—you cannot modify the underlying data through it. Both types are stack-allocated, so they have zero heap pressure.

    Spans work with arrays, managed strings (via unsafe pinning), managed object fields, and unmanaged memory. The key advantage is that operations like slicing, parsing, and substring extraction produce new spans pointing to the same memory—no allocations occur. This is transformative for hot paths: parsing CSV lines, extracting tokens from buffers, or processing telemetry data where allocation would cause GC pressure.

    Spans are also stack-safe: because they're value types living on the stack, they cannot be stored in fields or returned from async methods directly (in C# 11+, you can use ref struct return types with Span<T> in limited scenarios). This constraint protects against dangling pointers and memory safety.

    Example

    csharp
    
    // Everyday string parsing with spans—no allocations
    string data = "Alice,30|Bob,25|Carol,28";
    ReadOnlySpan<char> remaining = data.AsSpan();
    
    while (remaining.Length > 0)
    {
        int pipeIndex = remaining.IndexOf('|');
        ReadOnlySpan<char> line = pipeIndex == -1 
            ? remaining 
            : remaining[..pipeIndex];
        
        int commaIndex = line.IndexOf(',');
        ReadOnlySpan<char> name = line[..commaIndex];
        ReadOnlySpan<char> ageStr = line[(commaIndex + 1)..];
        
        if (int.TryParse(ageStr, out int age))
        {
            Console.WriteLine($"{new string(name)}: {age}");
        }
        
        remaining = pipeIndex == -1 
            ? ReadOnlySpan<char>.Empty 
            : remaining[(pipeIndex + 1)..];
    }
    

    This code parses CSV-like data by slicing the original string into spans for each record and field—zero string allocations, only stack memory.

    Key points to mention

    • Zero-allocation slicing: Creating a span slice (via indexer or Slice()) produces a new span viewing the same memory, not a copy.
    • Stack-only safety: Spans are value types that cannot escape the stack in most scenarios, preventing dangling pointer bugs.
    • Works with any contiguous memory: Arrays, strings (via AsSpan()), unmanaged pointers, and Span<T> fields in ref structs.
    • Parsing and formatting standard: Modern APIs like int.TryParse(ReadOnlySpan<char>) and string.Create(int, TState, SpanAction<char, TState>) accept spans to avoid temporary string allocation.
    • Not a replacement for collections: Spans view existing memory; they don't allocate or own it, so they're not suitable for storing data across async boundaries.

    Common follow-up questions

    • Why can't Span<T> be used in async methods or stored in fields? — Spans are managed by the compiler at compile-time; storing them across suspension points or in heap-allocated objects risks the span outliving the memory it references, causing memory safety violations.
    • What's the difference between Span<T> and Memory<T>? — Memory<T> is a heap-allocated wrapper that owns or manages a reference to memory and can be passed to async methods; Span<T> is stack-only and allocation-free but cannot cross await boundaries.
    • How do I use Span<T> in real parsing scenarios? — Use ReadOnlySpan<char> with IndexOf(), slicing, and APIs like int.TryParse(span) or Guid.TryParse(span) to extract and validate fields from input, keeping allocations off the hot path.

    Related interview questions

    Further reading

    .NETCsharpPerformance
  212. 212How do you design a good REST API in .NET?

    Experienced

    A good REST API in .NET follows resource-oriented design principles: use nouns for resources in URLs, apply correct HTTP verbs (GET, POST, PUT, DELETE), return appropriate status codes, and provide clear error responses using ProblemDetails. Additionally, implement pagination for large datasets, API versioning, OpenAPI/Swagger documentation, and maintain consistent naming conventions across all endpoints.

    How it works

    REST API design in .NET is built on several interconnected principles. First, resource-oriented URLs mean your endpoints represent nouns (resources) rather than verbs—/api/products instead of /api/getProducts. Second, HTTP verbs provide the action: GET retrieves, POST creates, PUT updates, DELETE removes, and PATCH partially updates. Third, status codes communicate outcomes: 200 OK, 201 Created, 204 No Content, 400 Bad Request, 401 Unauthorized, 404 Not Found, 500 Internal Server Error.

    Error handling should be standardized using ProblemDetails (RFC 7807), which returns structured error information instead of plain text. Pagination prevents overwhelming responses by implementing limit/offset or cursor-based pagination for collections. Versioning allows you to evolve your API without breaking clients—use URL paths (/api/v1/) or headers. Finally, documentation via OpenAPI/Swagger keeps your API discoverable and testable, and consistent naming (camelCase properties, resource pluralization) improves developer experience.

    Example

    csharp
    [ApiController]
    [Route("api/v1/[controller]")]
    public class ProductsController : ControllerBase
    {
        private readonly IProductService _service;
        
        public ProductsController(IProductService service) => _service = service;
        
        [HttpGet("{id}")]
        [ProduceResponseType(typeof(ProductDto), StatusCodes.Status200OK)]
        [ProduceResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
        public async Task> GetProduct(int id)
        {
            var product = await _service.GetProductAsync(id);
            if (product is null)
                return NotFound(new ProblemDetails 
                { 
                    Title = "Product not found",
                    Status = StatusCodes.Status404NotFound
                });
            return Ok(product);
        }
        
        [HttpPost]
        [ProduceResponseType(typeof(ProductDto), StatusCodes.Status201Created)]
        [ProduceResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
        public async Task> CreateProduct(CreateProductDto dto)
        {
            var product = await _service.CreateProductAsync(dto);
            return CreatedAtAction(nameof(GetProduct), new { id = product.Id }, product);
        }
        
        [HttpGet]
        public async Task>> ListProducts(
            [FromQuery] int page = 1, 
            [FromQuery] int pageSize = 10)
        {
            var result = await _service.ListProductsAsync(page, pageSize);
            return Ok(result);
        }
    }

    This example demonstrates resource-oriented URLs (/api/v1/products), correct HTTP verbs (GET for retrieval, POST for creation), appropriate status codes (200, 201, 404), ProblemDetails for errors, pagination parameters, and response type documentation for OpenAPI.

    Key points to mention

    • Resource-oriented design: Use nouns for URLs and let HTTP verbs define actions; avoid RPC-style endpoints like /api/getProduct
    • Standardized error responses: Always use ProblemDetails (RFC 7807) for consistency; include status, title, detail, and instance properties
    • Status codes matter: Return 201 Created for POST, 204 No Content when appropriate, and avoid generic 500 responses—log detailed errors server-side
    • Pagination and versioning: Implement pagination for collections and use URL-based versioning (/v1/, /v2/) to maintain backward compatibility
    • Documentation as a contract: Generate OpenAPI specs automatically; your API documentation should be a single source of truth and kept in sync with code

    Common follow-up questions

    • How do you handle REST API versioning—URL path vs. headers vs. query parameters, and why?
    • What's the difference between PUT and PATCH, and when should you use each?
    • How do you implement pagination efficiently with large datasets, and what are cursor-based pagination's advantages?

    Related interview questions

    Further reading

    .NETCsharp
  213. 213How do you secure a .NET API end to end?

    Experienced

    End-to-end API security requires a layered approach combining transport encryption (HTTPS), authentication (JWT or cookies), authorization policies, input validation, rate limiting, and secure secret management. Each layer protects against different attack vectors—no single mechanism is sufficient on its own.

    How it works

    Securing a .NET API involves multiple concentric layers. Transport security via HTTPS encrypts all data in motion using TLS certificates. Authentication verifies identity—JWT tokens are stateless and scalable, while cookies are convenient but require server state. Authorization enforces what authenticated users can do; policy-based authorization in .NET allows fine-grained control beyond simple role checks.

    Input validation prevents injection attacks by sanitizing and validating all user inputs on the server side. Rate limiting protects against brute-force and DDoS attacks by throttling requests per user or IP. Secure headers like Content-Security-Policy, X-Frame-Options, and Strict-Transport-Security mitigate browser-based exploits.

    Finally, secret management keeps API keys, connection strings, and certificates out of source code using Azure Key Vault or environment variables, and dependency scanning identifies vulnerable NuGet packages before deployment.

    Example

    csharp
    // Program.cs - Minimal .NET 8 API setup
    var builder = WebApplication.CreateBuilder(args);
    
    // Add authentication with JWT
    builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
        .AddJwtBearer(options =>
        {
            options.Authority = builder.Configuration["Auth:Authority"];
            options.Audience = builder.Configuration["Auth:Audience"];
            options.TokenValidationParameters = new()
            {
                ValidateIssuer = true,
                ValidateAudience = true,
                ValidateLifetime = true
            };
        });
    
    // Add authorization with policies
    builder.Services.AddAuthorization(options =>
    {
        options.AddPolicy("Admin", p => p.RequireRole("Admin"));
        options.AddPolicy("ValidUser", p => p.RequireAuthenticatedUser());
    });
    
    // Add rate limiting middleware
    builder.Services.AddRateLimiter(options =>
    {
        options.AddFixedWindowLimiter("fixed", config =>
        {
            config.PermitLimit = 100;
            config.Window = TimeSpan.FromMinutes(1);
        });
    });
    
    var app = builder.Build();
    
    // Apply middleware in correct order
    app.UseHttpsRedirection();
    app.UseRateLimiter();
    app.UseAuthentication();
    app.UseAuthorization();
    
    // Secure headers middleware
    app.Use(async (context, next) =>
    {
        context.Response.Headers.Add("X-Content-Type-Options", "nosniff");
        context.Response.Headers.Add("X-Frame-Options", "DENY");
        context.Response.Headers.Add("Strict-Transport-Security", "max-age=31536000");
        await next();
    });
    
    app.MapGet("/secure", () => "Protected data")
        .RequireAuthorization("ValidUser")
        .WithRateLimiting("fixed");
    
    app.Run();
    

    This example demonstrates HTTPS redirection, JWT authentication, role-based policies, rate limiting, and security headers all working together to protect an API endpoint.

    Key points to mention

    • HTTPS is mandatory: Never transmit credentials or sensitive data over HTTP; use TLS 1.2+ and valid certificates.
    • Defense in depth: No single mechanism is sufficient; layer authentication, authorization, validation, and rate limiting.
    • Secrets management: Use Azure Key Vault, AWS Secrets Manager, or environment variables—never hardcode secrets in source code.
    • Input validation on the server: Always validate and sanitize user input server-side to prevent injection attacks; client-side validation is for UX only.
    • Monitor and scan: Use OWASP ZAP, Snyk, or GitHub Dependabot to identify vulnerabilities in dependencies and configurations.

    Common follow-up questions

    • How do you handle token refresh with JWT? Implement refresh tokens with longer expiry stored securely (HttpOnly cookies), and issue short-lived access tokens; rotate refresh tokens to limit exposure.
    • What's the difference between authentication and authorization? Authentication verifies who you are (login); authorization determines what you can do (permissions)—always authenticate before authorizing.
    • How do you secure configuration and data in transit? See this question for detailed secret management and encryption strategies.

    Related interview questions

    Further reading

    .NETCsharpSecurity
  214. 214What is the difference between AddScoped DbContext and a factory in Blazor Server?

    Experienced

    In Blazor Server, AddScoped DbContext lives for the entire circuit lifetime, which can cause stale data, memory leaks, and connection pooling issues. Use IDbContextFactory instead to create short-lived contexts per operation, ensuring fresh data and proper resource cleanup.

    How it works

    Blazor Server maintains a persistent WebSocket connection called a "circuit" for each connected user. When you register DbContext with AddScoped, the scope is tied to the circuit's lifetime—typically 30 minutes or longer. This means a single DbContext instance serves all database operations for that entire session.

    The problem: DbContext maintains identity maps, change trackers, and connection state. A long-lived context accumulates stale entity instances, holds database connections longer than necessary, and can cause DbUpdateConcurrencyException when another user modifies the same data. Additionally, connection pooling becomes inefficient because connections aren't released promptly.

    IDbContextFactory solves this by creating a new DbContext instance on-demand for each operation. Each context is immediately disposed after use, releasing connections back to the pool and clearing all tracked state. This mirrors desktop application patterns where contexts are typically short-lived.

    Example

    csharp
    // Setup - AddScoped (problematic for Blazor Server)
    services.AddDbContext<AppDbContext>(options => 
        options.UseSqlServer(connectionString));
    
    // Setup - IDbContextFactory (recommended for Blazor Server)
    services.AddDbContextFactory<AppDbContext>(options => 
        options.UseSqlServer(connectionString));
    
    // Component usage with factory
    @inject IDbContextFactory<AppDbContext> ContextFactory
    
    @code {
        private List<Product> products = new();
    
        protected override async Task OnInitializedAsync()
        {
            // Create context, use it, dispose it immediately
            using var context = await ContextFactory.CreateDbContextAsync();
            products = await context.Products.ToListAsync();
        }
    
        private async Task UpdateProduct(int id, string name)
        {
            // Fresh context for each operation
            using var context = await ContextFactory.CreateDbContextAsync();
            var product = await context.Products.FindAsync(id);
            if (product != null)
            {
                product.Name = name;
                await context.SaveChangesAsync();
            }
        }
    }
    

    This example shows how the factory creates a fresh context for page initialization and again for the update operation, with automatic disposal ensuring connections return to the pool.

    Key points to mention

    • Circuit lifetime vs. request lifetime: Blazor Server circuits persist across multiple user interactions, unlike HTTP request cycles. AddScoped binds to circuit scope, not request scope.
    • Connection pooling impact: Long-lived contexts hold connections unnecessarily. Factories ensure connections are returned promptly, improving throughput for other users.
    • Stale data risk: A retained DbContext caches entities in memory. If another user modifies data, your context won't see those changes without explicit refresh or AsNoTracking.
    • Async-friendly: CreateDbContextAsync() is available in EF Core 5+, allowing proper async initialization before use.
    • Consider DbContext pooling: Factories work well with pooling to reuse context instances efficiently without maintaining long-lived scopes.

    Comparison: AddScoped vs. IDbContextFactory in Blazor Server

    Aspect AddScoped DbContext IDbContextFactory
    Lifetime Circuit lifetime (30+ minutes) Single operation (milliseconds)
    Connection pooling Holds connections; reduces pool availability Releases connections immediately; better throughput
    Stale data High risk; cached entities not refreshed Low risk; fresh context per operation
    Memory usage Grows over time; retains all tracked entities Minimal; disposed after use
    Dependency injection Inject DbContext directly Inject IDbContextFactory<DbContext>
    Concurrency conflicts More common due to stale state Reduced; each operation starts fresh
    What is the difference between AddScoped DbContext and a factory in Blazor Server diagram

    Common follow-up questions

    • Can I use AddScoped in Blazor Server at all? – Yes, for stateless services or caching layers, but not for DbContext. The circuit scope is too long.
    • How does DbContext lifetime management differ in Blazor WebAssembly? – WebAssembly runs client-side and doesn't connect directly to databases, so it uses APIs instead. This question is Blazor Server-specific.
    • What about DbContext pooling? Can I use that with a factory? – Yes, AddDbContextFactory works with pooling via UseDbContextPool(), combining the benefits of short-lived contexts with instance reuse.

    Related interview questions

    Further reading

    What is the difference between AddScoped DbContext and a factory in Blazor Server?
    .NETCsharpaspnetcoreefcoredependency-injection
  215. 215How do you profile a slow ASP.NET Core endpoint?

    Experienced

    Profiling a slow ASP.NET Core endpoint involves a layered approach: start with instrumentation (logs, structured tracing, and application metrics), identify I/O bottlenecks (especially database queries), then use runtime profilers like dotnet-trace or Visual Studio to isolate CPU, memory allocation, or concurrency issues. The key is narrowing down whether the problem is application code, external services, or resource contention before running expensive profiling tools.

    How it works

    Profiling follows a cost-benefit hierarchy. Begin with lightweight instrumentation—add timestamps and structured logging around key operations using ILogger or diagnostic events. Check if the endpoint calls a database; if so, analyze EF Core queries in production by enabling query logging or capturing EF Core diagnostic events to spot N+1 queries or missing indexes.

    Once you've ruled out obvious I/O waits, deploy dotnet-counters on production (or staging) to observe CPU%, GC heap size, thread pool queue depth, and HTTP request rates in real time. If CPU is spiking, use dotnet-trace to collect a CPU profile at application runtime—this captures call stacks and identifies which methods consume the most time. For memory-related slowness, collect allocation traces to spot excessive object creation. In development, Visual Studio's profiler offers GUI-based CPU and memory analysis with flamegraph visualization, though production profiling via command-line tools is preferred for minimal overhead.

    Example

    csharp
    // Middleware to measure endpoint latency and identify slow routes
    public class ProfilingMiddleware
    {
        private readonly RequestDelegate _next;
        private readonly ILogger<ProfilingMiddleware> _logger;
    
        public ProfilingMiddleware(RequestDelegate next, ILogger<ProfilingMiddleware> logger)
        {
            _next = next;
            _logger = logger;
        }
    
        public async Task InvokeAsync(HttpContext context)
        {
            var stopwatch = System.Diagnostics.Stopwatch.StartNew();
            
            try
            {
                await _next(context);
            }
            finally
            {
                stopwatch.Stop();
                var elapsedMs = stopwatch.ElapsedMilliseconds;
                
                // Log slow requests (>500ms)
                if (elapsedMs > 500)
                {
                    _logger.LogWarning(
                        "Slow endpoint: {Method} {Path} took {ElapsedMs}ms | StatusCode: {StatusCode}",
                        context.Request.Method,
                        context.Request.Path,
                        elapsedMs,
                        context.Response.StatusCode);
                }
            }
        }
    }
    
    // In Program.cs
    app.UseMiddleware<ProfilingMiddleware>();
    

    This middleware logs slow endpoints to structured logs, helping you quickly identify which routes need profiling without instrumenting every handler.

    Key points to mention

    • Start lightweight: Logs and dotnet-counters cost little and reveal the root category (CPU, memory, I/O) before deeper profiling.
    • Check database first: Most ASP.NET Core slowness is external—database queries, APIs, or file I/O. Verify with query logging or EF Core diagnostics.
    • Use dotnet-trace for CPU: Collect traces in production with minimal overhead (dotnet-trace collect --process-id <pid>) and analyze call stacks to find hot methods.
    • Isolate memory allocations: If GC pressure is high, analyze allocations in the hot path using dotnet-trace --profile gc-collect or Visual Studio's memory profiler.
    • Consider concurrency: Investigate high CPU in production may reveal thread pool starvation or lock contention—check with dotnet-counters threadpool.

    Common follow-up questions

    • What's the difference between dotnet-trace and Visual Studio Profiler? dotnet-trace is CLI-based and works remotely on production; Visual Studio Profiler is GUI-driven and best for local debugging. Both collect similar data, but dotnet-trace has lower overhead.
    • How do you avoid profiler overhead masking the real issue? Keep sampling rates low, profile off-peak, and compare before/after metrics. Use event tracing (ETW) rather than per-instruction instrumentation in production.
    • Can you profile async code effectively? Yes; dotnet-trace and Visual Studio both understand async/await. Look for Task.Wait() calls, ConfigureAwait(false) misuse, or thread-pool starvation indicated by queued work items.

    Related interview questions

    .NETCsharpaspnetcore
  216. 216What is the difference between AddTransient services that hold state and stateless services?

    Experienced

    Transient services that hold state create a new instance every time they are resolved, so each consumer gets its own isolated state—no sharing occurs. Stateless transient services do the same but avoid maintaining mutable fields, making them safe and predictable. The key difference is whether the service maintains instance-level data; stateless is preferred because it eliminates accidental state leaks and makes services thread-safe by design.

    How it works

    Transient services are instantiated fresh on every dependency injection resolution. If a transient service holds state (mutable fields or properties), each caller receives a new instance with its own copy of that state—no sharing between callers. However, this isolation does not protect you from other problems:

    • If the transient holds a reference to a disposable resource (like a database connection or HttpClient) resolved from the root service provider, that resource may not be disposed properly because transient instances are not tracked by the container.
    • State held in transient services can still cause confusion and bugs if the service is expected to be stateless by convention.
    • Stateless transient services are inherently thread-safe and easier to reason about, making them the recommended pattern in most scenarios.

    For proper state management in ASP.NET Core, use scoped services that hold state (one per HTTP request) or explicit state objects. Reserve transient for lightweight, stateless utilities.

    Example

    csharp
    // Stateful transient (problematic pattern)
    public class StatefulLogger
    {
        private List<string> _logs = new();
        public void Log(string message) => _logs.Add(message);
        public int LogCount => _logs.Count;
    }
    
    // Stateless transient (preferred)
    public class StatelessLogger
    {
        public void Log(string message) => Console.WriteLine(message);
    }
    
    // Usage
    var services = new ServiceCollection();
    services.AddTransient<StatefulLogger>();
    services.AddTransient<StatelessLogger>();
    var provider = services.BuildServiceProvider();
    
    var logger1 = provider.GetRequiredService<StatefulLogger>();
    var logger2 = provider.GetRequiredService<StatefulLogger>();
    logger1.Log("First");
    logger2.Log("Second");
    Console.WriteLine(logger1.LogCount); // Outputs 1 (isolated state)
    Console.WriteLine(logger2.LogCount); // Outputs 1 (isolated state)
    
    var stateless1 = provider.GetRequiredService<StatelessLogger>();
    var stateless2 = provider.GetRequiredService<StatelessLogger>();
    // Both write to console; no state held anywhere

    Each transient call gets a new instance, so the stateful logger's counts are never shared between logger1 and logger2. The stateless logger avoids the confusion entirely.

    Key points to mention

    • Isolation by design: Transient state is never shared between consumers because each gets a new instance, but this does not make stateful transients a good practice.
    • Disposal risk: Transient instances holding disposable resources (connections, clients) may leak if not explicitly disposed by the caller.
    • Stateless is safer: Stateless transient services are thread-safe, predictable, and free from accidental state pollution—always prefer this pattern.
    • State belongs in scoped services: If you need request-scoped state, use AddScoped instead; the container tracks and disposes scoped instances properly.
    • Clarity over cleverness: A stateless transient service clearly communicates its intent and makes your dependency graph easier to understand and maintain.

    Comparison table

    Aspect Stateful Transient Stateless Transient Scoped (with state)
    Instance per resolution New instance every time New instance every time One per scope (e.g., HTTP request)
    State isolation Each caller has own state; no sharing No state to share Shared within scope; isolated across scopes
    Thread safety Safe only if state not accessed concurrently Always thread-safe Safe within single-threaded request context
    Disposal handling Not tracked; caller must dispose No resources to dispose Automatically disposed by container
    Best use case Rare; avoid if possible Utilities, helpers, loggers Request-specific data, entity tracking
    Recommended ❌ No ✅ Yes ✅ Yes (when state needed)
    What is the difference between AddTransient services that hold state and stateless services diagram

    Common follow-up questions

    • What happens if I inject a scoped service into a singleton? — See this question for a detailed explanation of the captive dependency problem.
    • How do I ensure a transient service is disposed properly? — Have the immediate consumer own and dispose it, or consider using a scoped lifetime if the resource needs container-managed cleanup.
    • Can I use a transient service to hold per-request state in ASP.NET Core? — No; use a scoped service instead, which is properly tracked and disposed by the container at the end of each HTTP request.

    Related interview questions

    Further reading

    What is the difference between AddTransient services that hold state and stateless services?
    .NETCsharpdependency-injection
  217. 217How do you use the Channel-based background queue pattern?

    Experienced

    The Channel-based background queue pattern uses System.Threading.Channels to create a fast, bounded in-process queue for background work. You register a bounded Channel as a singleton, enqueue work items from HTTP endpoints, and process them in a BackgroundService that reads from the channel and creates a DI scope per item. This gives you asynchronous task processing with natural backpressure when the queue is full.

    How it works

    Channels are a lightweight, high-performance queuing primitive in .NET that sit between in-memory collections and message brokers. A bounded channel has a maximum capacity; when full, writers are suspended (backpressure) until space opens. This prevents unbounded memory growth during traffic spikes.

    The pattern works in three steps:

    1. Register the channel: Create a bounded ChannelReader<T> and ChannelWriter<T> as singletons in DI.
    2. Enqueue from endpoints: Inject the ChannelWriter and write items asynchronously using TryWrite (non-blocking) or WriteAsync (async-waitable).
    3. Process in BackgroundService: A long-running service reads from the ChannelReader, creates a new DI scope per item, and executes handler logic. This isolates scoped services like DbContext.

    Because channels are in-process and thread-safe, you get microsecond latency with no serialization overhead, making this ideal for high-throughput scenarios like email notifications, logging, or event processing within a single application.

    Example

    csharp
    // Register in Program.cs
    var channel = Channel.CreateBounded<QueueItem>(new BoundedChannelOptions(1000)
    {
        FullMode = BoundedChannelFullMode.Wait
    });
    builder.Services.AddSingleton(channel.Reader);
    builder.Services.AddSingleton(channel.Writer);
    builder.Services.AddHostedService<QueueProcessor>();
    
    // Queue item
    public record QueueItem(int Id, string Data);
    
    // Endpoint
    app.MapPost("/queue", async (ChannelWriter<QueueItem> writer, int id, string data) =>
    {
        var item = new QueueItem(id, data);
        await writer.WriteAsync(item);
        return Results.Accepted();
    });
    
    // BackgroundService processor
    public class QueueProcessor : BackgroundService
    {
        private readonly ChannelReader<QueueItem> _reader;
        private readonly IServiceProvider _serviceProvider;
    
        public QueueProcessor(ChannelReader<QueueItem> reader, IServiceProvider serviceProvider)
        {
            _reader = reader;
            _serviceProvider = serviceProvider;
        }
    
        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
        {
            await foreach (var item in _reader.ReadAllAsync(stoppingToken))
            {
                using var scope = _serviceProvider.CreateScope();
                var handler = scope.ServiceProvider.GetRequiredService<IQueueHandler>();
                try
                {
                    await handler.HandleAsync(item);
                }
                catch (Exception ex)
                {
                    // Log and continue
                }
            }
        }
    }
    

    This example creates a bounded channel with 1000 capacity, enqueues items from an endpoint without blocking the request, and processes them serially in a BackgroundService that creates a new DI scope per item to safely use scoped services like DbContext.

    Key points to mention

    • Backpressure: Bounded channels apply natural backpressure—when full, WriteAsync waits for space, preventing memory exhaustion during traffic spikes.
    • Scope per item: Always create a new IServiceScope in the BackgroundService for each work item to ensure scoped services (DbContext, unit-of-work) are properly isolated.
    • Non-blocking fast path: Use TryWrite in hot paths for fire-and-forget scenarios; use WriteAsync when you need to wait for queue capacity.
    • Channel disposal: Complete the channel writer with writer.TryComplete() on shutdown so the reader's ReadAllAsync loop exits gracefully.
    • Error handling: Wrap item processing in try-catch so one failed item doesn't crash the entire background loop.

    Common follow-up questions

    • "What's the difference between TryWrite and WriteAsync?" — TryWrite returns bool immediately (fails if full); WriteAsync awaits until space is available. Use TryWrite for fire-and-forget, WriteAsync for guaranteed delivery with backpressure.
    • "How do you handle errors in the background processor?" — Wrap the handler call in try-catch, log the exception, and continue the loop so one failure doesn't block the entire queue.
    • "When should I use Channels instead of a message broker like RabbitMQ?" — Channels are best for in-process, single-machine queuing with tight latency requirements. Use brokers when you need persistence, multiple services, or scalability across machines.

    Related interview questions

    Further reading

    .NETCsharpasyncArchitecture
  218. 218How do you handle partial failures when calling several services?

    Experienced

    Handle partial failures by running service calls concurrently with timeouts, gracefully degrading non-critical responses, and returning composite results that distinguish between essential and optional data. This prevents a single slow or failing dependency from blocking the entire request, improves user experience, and maintains system resilience through strategic fallbacks and comprehensive logging with correlation IDs for debugging distributed issues.

    How it works

    When an application depends on multiple external services, any one can fail, timeout, or respond slowly. Rather than failing the entire request, you classify dependencies as critical or non-critical. Critical services must succeed; non-critical services can degrade. You execute calls concurrently to reduce latency, enforce timeouts to prevent indefinite blocking, and catch exceptions at the individual service level. For failed non-critical services, you return default or cached data. For critical services, you retry with exponential backoff or fail fast. All failures are logged with a correlation ID so you can trace the entire request flow across services. The response to the client includes both successful data and status indicators for degraded sections, allowing the UI to adapt.

    This approach, sometimes called the bulkhead pattern combined with graceful degradation, prevents cascading failures and maintains partial availability. You might also use circuit breakers to stop hammering a failing service and caching to serve stale data when services are down.

    Example

    csharp
    public class CompositeDataService
    {
        private readonly HttpClient _httpClient;
        private readonly ILogger<CompositeDataService> _logger;
        private readonly string _correlationId;
    
        public CompositeDataService(HttpClient httpClient, ILogger<CompositeDataService> logger)
        {
            _httpClient = httpClient;
            _logger = logger;
            _correlationId = Guid.NewGuid().ToString();
        }
    
        public async Task<CompositeResponse> GetAggregatedDataAsync()
        {
            var response = new CompositeResponse { CorrelationId = _correlationId };
            var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
    
            // Critical service – must succeed
            try
            {
                response.UserProfile = await FetchWithRetryAsync(
                    "https://users-api/profile", 
                    maxRetries: 3, 
                    cts.Token);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Critical service failed. CorrelationId: {Id}", _correlationId);
                return response; // Early return for critical failure
            }
    
            // Non-critical services – run concurrently, tolerate failures
            var recommendationsTask = FetchWithTimeoutAsync(
                "https://recommendations-api/items", 
                defaultValue: new List<string>(), 
                cts.Token);
            
            var analyticsTask = FetchWithTimeoutAsync(
                "https://analytics-api/stats", 
                defaultValue: new Analytics(), 
                cts.Token);
    
            try
            {
                await Task.WhenAll(recommendationsTask, analyticsTask);
                response.Recommendations = recommendationsTask.Result;
                response.Analytics = analyticsTask.Result;
            }
            catch (OperationCanceledException ex)
            {
                _logger.LogWarning(ex, "Non-critical services timed out. CorrelationId: {Id}", _correlationId);
                response.Recommendations ??= new List<string>();
                response.Analytics ??= new Analytics();
            }
    
            return response;
        }
    
        private async Task<T> FetchWithRetryAsync<T>(string url, int maxRetries, CancellationToken ct)
        {
            for (int i = 0; i < maxRetries; i++)
            {
                try
                {
                    var result = await _httpClient.GetAsync(url, ct);
                    result.EnsureSuccessStatusCode();
                    return JsonSerializer.Deserialize<T>(await result.Content.ReadAsStringAsync(ct))!;
                }
                catch (Exception ex) when (i < maxRetries - 1)
                {
                    _logger.LogWarning(ex, "Retry {Attempt} for {Url}. CorrelationId: {Id}", i + 1, url, _correlationId);
                    await Task.Delay(TimeSpan.FromMilliseconds(Math.Pow(2, i) * 100), ct);
                }
            }
            throw new InvalidOperationException($"Failed after {maxRetries} retries: {url}");
        }
    
        private async Task<T> FetchWithTimeoutAsync<T>(string url, T defaultValue, CancellationToken ct)
        {
            try
            {
                var result = await _httpClient.GetAsync(url, ct);
                result.EnsureSuccessStatusCode();
                return JsonSerializer.Deserialize<T>(await result.Content.ReadAsStringAsync(ct))!;
            }
            catch (Exception ex)
            {
                _logger.LogWarning(ex, "Non-critical service failed, returning default. Url: {Url}, CorrelationId: {Id}", url, _correlationId);
                return defaultValue;
            }
        }
    }
    
    public class CompositeResponse
    {
        public string CorrelationId { get; set; }
        public UserProfile UserProfile { get; set; }
        public List<string> Recommendations { get; set; }
        public Analytics Analytics { get; set; }
    }
    

    This example separates critical (UserProfile) and non-critical (Recommendations, Analytics) calls, retries critical ones, tolerates timeouts on optional ones, and logs all failures with a correlation ID for tracing across the distributed system.

    Key points to mention

    • Classify dependencies: Distinguish between critical services (fail the request if they fail) and non-critical ones (degrade gracefully).
    • Concurrency and timeouts: Run independent calls in parallel with aggressive timeouts to avoid blocking the main thread indefinitely.
    • Correlation IDs: Attach a unique ID to every request so you can trace partial failures across multiple services in logs.
    • Retry strategies: Use exponential backoff for critical services; avoid retrying non-critical ones if they time out.
    • Caching and defaults: Return cached data or sensible defaults for non-critical failures rather than empty responses, improving perceived reliability.

    Common follow-up questions

    • How do you implement a circuit breaker to prevent calling a failing service? – Use Polly or similar libraries to track failure rates and stop requests after a threshold is exceeded, allowing the service time to recover.
    • How does distributed tracing help with partial failures? – Distributed tracing captures request flows across all services, making it easy to see which dependency failed and when, correlating logs and metrics.
    • What's the difference between timeout and cancellation? – A timeout is a duration-based limit; cancellation is a signal you send via CancellationToken to stop work immediately, which is more graceful and allows cleanup.

    Related interview questions

    Further reading

    .NETCsharpasync
  219. 219What is the difference between Results.Problem and throwing exceptions?

    Experienced

    Results.Problem returns a ProblemDetails response directly, making expected failures explicit and performant, while exceptions should be reserved for unexpected errors handled globally via middleware. Using Results.Problem is the modern, idiomatic approach in ASP.NET Core for handling validation and business logic failures as part of normal control flow, whereas throwing exceptions incurs stack unwinding overhead and obscures intent.

    How it works

    Results.Problem (introduced in .NET 6+) is a typed endpoint result that directly constructs and returns a standardized ProblemDetails response. It's part of the Result pattern family and represents expected, handled failures. When you return Results.Problem from an endpoint, ASP.NET Core immediately serializes it to JSON and sends it with the appropriate HTTP status code—no exceptions are thrown.

    Exceptions, by contrast, are thrown when an unexpected error occurs. An exception unwinds the call stack, performs stack trace generation, and must be caught by global exception handling middleware (like UseExceptionHandler) to produce a response. This process is orders of magnitude slower than returning a result directly.

    The key distinction is intent: Results.Problem signals "this is an expected outcome I'm handling"; exceptions signal "something went wrong that I didn't anticipate." Mixing them confuses maintainers and creates performance issues.

    Example

    csharp
    // ❌ Anti-pattern: using exceptions for expected failures
    app.MapPost("/orders/{id}", (int id, OrderService service) =>
    {
        if (id <= 0)
            throw new ArgumentException("Invalid order ID"); // Stack unwinding overhead
        
        var order = service.GetOrder(id);
        if (order == null)
            throw new KeyNotFoundException("Order not found"); // More overhead
        
        return Results.Ok(order);
    });
    
    // ✅ Best practice: using Results.Problem for expected failures
    app.MapPost("/orders/{id}", (int id, OrderService service) =>
    {
        if (id <= 0)
            return Results.Problem(
                detail: "Order ID must be greater than zero",
                statusCode: StatusCodes.Status400BadRequest,
                title: "Invalid Input"
            );
        
        var order = service.GetOrder(id);
        if (order == null)
            return Results.Problem(
                detail: $"Order {id} not found",
                statusCode: StatusCodes.Status404NotFound,
                title: "Not Found"
            );
        
        return Results.Ok(order);
    });
    
    // Global exception handler for truly unexpected errors
    app.UseExceptionHandler(errorApp =>
    {
        errorApp.Run(async context =>
        {
            var exceptionHandlerPathFeature =
                context.Features.Get();
            
            context.Response.StatusCode = StatusCodes.Status500InternalServerError;
            await context.Response.WriteAsJsonAsync(new ProblemDetails
            {
                Title = "Internal Server Error",
                Detail = "An unexpected error occurred",
                Status = StatusCodes.Status500InternalServerError
            });
        });
    });

    The first endpoint throws exceptions for predictable validation failures, which is inefficient and obscures the happy path. The second returns Results.Problem for validation and business logic failures, then relies on global exception handling only for truly unexpected errors like null reference exceptions or database connection failures.

    Key points to mention

    • Performance: Results.Problem avoids stack unwinding overhead; exceptions are 10–100× slower because they capture the entire call stack.
    • Intent clarity: Results.Problem makes it explicit that a failure is expected and handled; exceptions suggest something went wrong unexpectedly.
    • Standard compliance: Results.Problem produces standardized ProblemDetails (RFC 7807) responses consistently across your API.
    • Separation of concerns: Expected failures (validation, not found, conflict) return Results.Problem; unexpected failures (bugs, null refs, database crashes) throw exceptions caught by middleware.
    • Testability: Code that returns Results.Problem is easier to test—you check the result directly rather than catching exceptions.

    Comparison table

    Aspect Results.Problem Throwing Exceptions
    Use case Expected failures (validation, not found, conflict) Unexpected errors (bugs, null refs, system failures)
    Performance Fast—no stack unwinding Slow—captures full call stack
    Intent Clear: "I handled this" Unclear: "Something went wrong"
    Response format Consistent ProblemDetails (RFC 7807) Depends on global error handler
    Handling Returned directly from endpoint Caught by global middleware
    Testability Easy—assert result type and status Requires exception setup/assertions
    What is the difference between Results.Problem and throwing exceptions diagram

    Common follow-up questions

    • Should I ever throw exceptions in endpoints? No—throw only in service/domain logic for unexpected failures. Let middleware handle them globally to return a 500 ProblemDetails response.
    • What's the relationship between Results.Problem and validation filters? Validation filters often return Results.Problem before your endpoint runs; use them together to keep endpoints clean and consistent.
    • Can I customize ProblemDetails responses? Yes—use ProblemDetailsOptions in services.AddProblemDetails() to configure default titles, status codes, and custom mappers for exception types.

    Related interview questions

    Further reading

    What is the difference between Results.Problem and throwing exceptions?
    .NETCsharp
  220. 220How do you test authorization rules in integration tests?

    Experienced

    To test authorization rules in integration tests, replace the default authentication scheme with a test authentication handler that issues claims based on test parameters, then make HTTP requests and assert the expected status codes (401 for unauthenticated, 403 for forbidden, 200 for authorized). This approach avoids token generation complexity while giving you full control over identity and claims per test scenario.

    How it works

    Integration tests need to verify that your [Authorize] attributes and policy-based authorization work correctly across different user roles and ownership scenarios. Rather than mocking the authentication layer, you configure a test authentication handler that runs within the test server pipeline. This handler intercepts authentication requests and issues a ClaimsPrincipal populated with whatever claims your test specifies—user ID, roles, permissions, or custom claims. Your authorization policies then evaluate these claims exactly as they would in production, but under your complete control.

    The key advantage is that you're testing the real authorization middleware and policies, not a mock. Each test can establish different claims (admin vs. regular user, resource owner vs. stranger), make an HTTP request to your protected endpoint, and assert the response status code. This validates both authentication (401 Unauthorized) and authorization (403 Forbidden) boundaries.

    Example

    csharp
    public class AuthorizationIntegrationTests : IAsyncLifetime
    {
        private WebApplicationFactory<Program> _factory;
        private HttpClient _client;
    
        public async Task InitializeAsync()
        {
            _factory = new WebApplicationFactory<Program>()
                .WithWebHostBuilder(builder =>
                {
                    builder.ConfigureTestServices(services =>
                    {
                        services.AddAuthentication("TestScheme")
                            .AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
                                "TestScheme", _ => { });
                    });
                });
    
            _client = _factory.CreateClient();
        }
    
        [Fact]
        public async Task Get_ProtectedEndpoint_WithoutAuth_Returns401()
        {
            var response = await _client.GetAsync("/api/protected");
            Assert.Equal(System.Net.HttpStatusCode.Unauthorized, response.StatusCode);
        }
    
        [Fact]
        public async Task Get_ProtectedEndpoint_WithAdminRole_Returns200()
        {
            _client.DefaultRequestHeaders.Add("X-Test-User-Id", "123");
            _client.DefaultRequestHeaders.Add("X-Test-Role", "Admin");
    
            var response = await _client.GetAsync("/api/protected");
            Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode);
        }
    
        [Fact]
        public async Task Get_AdminOnlyEndpoint_WithUserRole_Returns403()
        {
            _client.DefaultRequestHeaders.Add("X-Test-User-Id", "456");
            _client.DefaultRequestHeaders.Add("X-Test-Role", "User");
    
            var response = await _client.GetAsync("/api/admin-only");
            Assert.Equal(System.Net.HttpStatusCode.Forbidden, response.StatusCode);
        }
    
        public async Task DisposeAsync() => await _factory.DisposeAsync();
    }
    
    public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
    {
        public TestAuthHandler(IOptionsMonitor<AuthenticationSchemeOptions> options,
            ILoggerFactory logger, UrlEncoder encoder)
            : base(options, logger, encoder) { }
    
        protected override Task<AuthenticateResult> HandleAuthenticateAsync()
        {
            if (!Request.Headers.TryGetValue("X-Test-User-Id", out var userId))
                return Task.FromResult(AuthenticateResult.NoResult());
    
            var claims = new List<Claim>
            {
                new Claim(ClaimTypes.NameIdentifier, userId.ToString()),
            };
    
            if (Request.Headers.TryGetValue("X-Test-Role", out var role))
                claims.Add(new Claim(ClaimTypes.Role, role.ToString()));
    
            var identity = new ClaimsIdentity(claims, "TestScheme");
            var principal = new ClaimsPrincipal(identity);
            var ticket = new AuthenticationTicket(principal, "TestScheme");
    
            return Task.FromResult(AuthenticateResult.Success(ticket));
        }
    }
    

    This code registers a test authentication handler that reads claim information from request headers, constructs a ClaimsPrincipal, and returns it to the authorization middleware, allowing tests to control identity and roles on a per-request basis.

    Key points to mention

    • WebApplicationFactory with ConfigureTestServices lets you register a test auth handler that runs within the actual request pipeline, ensuring real policy evaluation.
    • No token generation needed—pass claims via headers or request context; the handler builds the principal for you.
    • Test three scenarios per endpoint: unauthenticated (no headers), authorized (correct role/claims), and forbidden (wrong role/claims) to cover authentication and authorization boundaries.
    • Use custom claims to test resource ownership rules (e.g., claiming a UserId that does not own the requested resource should return 403).
    • This approach is faster than spinning up a real identity provider and keeps tests focused on your application's authorization logic, not external services.

    Common follow-up questions

    • How do you test resource-level authorization (ownership)? — Add a claim for the resource owner ID and modify your policy to compare it against the requested resource; your test then sends a request for a resource owned by a different user and asserts 403.
    • Can you test policy-based authorization this way? — Yes; the test handler issues whatever claims your AuthorizationPolicy evaluates. If your policy checks a "CanEdit" claim, have the handler include or exclude it based on headers.
    • How does this relate to unit testing policies separately? — Unit tests validate the policy logic in isolation; integration tests verify the policy runs in the full pipeline with real middleware ordering and endpoint routing. See integration tests for ASP.NET Core for more context.

    Related interview questions

    Further reading

    .NETCsharpSecurityTesting
  221. 221How does the .NET garbage collector decide when to run?

    Expert

    The .NET garbage collector automatically runs when a generation's allocation budget is exceeded, when the system reports low memory pressure, or when GC.Collect() is explicitly called. The runtime maintains dynamic budgets per generation that adjust based on survival rates—generations that retain more objects get larger budgets to reduce collection frequency, while generations with high death rates trigger more often to reclaim space efficiently.

    How it works

    The .NET GC uses a generational model with three generations (0, 1, 2) plus a large object heap (LOH). Each generation has an allocation budget—a threshold of bytes that can be allocated before a collection is triggered. When allocations in a generation exceed its budget, the GC pauses managed threads and performs a collection.

    The runtime tracks survival rates: the percentage of objects that survive each collection. If Gen 0 has a high survival rate, the runtime increases its budget for the next cycle, reducing collection frequency. Conversely, if most Gen 0 objects die quickly, the budget stays small, triggering collections more often since they're cheap to run. This adaptive tuning balances throughput and latency.

    Memory pressure from the operating system also triggers collections. When the system signals low available memory, the runtime may force a full GC (Gen 0, 1, and 2) to reclaim as much heap space as possible, even if budgets haven't been exceeded.

    In .NET 6+, the GC heap commit model and segment management further optimize decisions. The runtime can also trigger collections based on heap fragmentation thresholds and, in server GC mode, heap-per-core strategies influence when and how collections proceed.

    Example

    csharp
    using System;
    using System.Diagnostics;
    
    class GCTriggerExample
    {
        static void Main()
        {
            var gcStats = GC.GetGCMemoryInfo();
            Console.WriteLine($"Initial heap: {gcStats.HeapSizeBytes / 1024} KB");
    
            // Allocate objects aggressively to exceed Gen 0 budget
            var objects = new object[1_000_000];
            for (int i = 0; i < 1_000_000; i++)
            {
                objects[i] = new byte[100];
            }
    
            var statsAfter = GC.GetGCMemoryInfo();
            Console.WriteLine($"After allocation: {statsAfter.HeapSizeBytes / 1024} KB");
            Console.WriteLine($"Collections triggered: Gen0={GC.GetTotalCollectionCount(0)}");
    
            // Clear to free memory and allow Gen 2 collection
            Array.Clear(objects);
            GC.Collect(2, GCCollectionMode.Optimized);
            Console.WriteLine($"After explicit collect: Gen0={GC.GetTotalCollectionCount(0)}");
        }
    }
    

    This example shows how rapid allocation of small objects triggers Gen 0 collections automatically, and demonstrates calling GC.Collect() to force a Gen 2 collection when needed.

    Key points to mention

    • Allocation budgets per generation: Each generation has a threshold; exceeding it triggers a collection of that generation and lower ones.
    • Adaptive tuning: Survival rates determine future budgets—high survival means larger budget, reducing GC frequency for stable workloads.
    • Memory pressure signals: OS-level memory pressure can force full collections regardless of budget state.
    • Explicit control: GC.Collect() forces immediate collection, but is rarely recommended for production code.
    • Generational hypothesis: Most objects die young (Gen 0), so collecting Gen 0 frequently is cheap; older objects rarely need collection, reducing pause times.

    Common follow-up questions

    • What's the difference between GC.Collect() and letting the GC run naturally? — GC.Collect() forces an immediate, blocking collection; natural GC runs based on budgets and triggers, offering better throughput and latency. Explicit collection hides tuning problems and often hurts performance.
    • How does the heap structure influence GC decisions? — The runtime segments the managed heap into small object heap (SOH) and large object heap (LOH) with separate budgets. LOH collections are more expensive, so they're triggered less frequently but can cause longer pauses.
    • Can I tune GC behavior for my workload? — Yes, via GCHeapCount, GCHeapHardLimit, and GCLatencyMode (Batch, Interactive, SustainedLowLatency), allowing optimization for throughput or low-latency scenarios.

    Related interview questions

    .NETCsharpPerformance
  222. 222What is the difference between workstation and server GC?

    Expert

    Workstation GC and Server GC are two distinct garbage collection modes in .NET. Workstation GC uses a single heap and is optimized for low-latency client applications, performing collections on the same thread that triggered allocation. Server GC creates one heap and dedicated GC thread per logical processor core, maximizing throughput for server workloads at the cost of higher memory consumption and latency variability.

    How it works

    In Workstation GC, there is one managed heap shared across all threads. When a collection is triggered, the application thread performing the allocation blocks until the GC completes, then resumes. This simplicity makes pause times predictable and memory overhead minimal—ideal for desktop and client apps where responsiveness matters more than raw throughput.

    In Server GC, .NET allocates one heap per logical processor and assigns one GC thread per heap. Each thread allocates from its own heap without lock contention. When a collection is needed, dedicated GC threads work in parallel on their respective heaps, allowing application threads to continue running (mostly unblocked). This parallelism significantly increases throughput but introduces:

    • Higher memory footprint (more heaps = more metadata and reserved space)
    • Longer maximum GC pause times (coordinating across cores takes time)
    • Increased CPU usage during collections

    You select the mode via the project file or runtime configuration. By default, .NET Framework uses Workstation GC, while .NET Core uses Server GC on multi-core systems. You can override with <ServerGarbageCollection>true</ServerGarbageCollection> in your .csproj.

    Example

    csharp
    // Check which GC mode is active at runtime
    using System;
    using System.Runtime;
    
    class Program
    {
        static void Main()
        {
            bool isServerGC = GCSettings.IsServerGC;
            Console.WriteLine($"Server GC enabled: {isServerGC}");
            Console.WriteLine($"GC Latency Mode: {GCSettings.LatencyMode}");
            Console.WriteLine($"Processor count: {Environment.ProcessorCount}");
            
            // Trigger a collection and observe behavior
            GC.Collect();
            GC.WaitForPendingFinalizers();
        }
    }
    // Output on a 4-core machine with Server GC:
    // Server GC enabled: True
    // GC Latency Mode: Interactive
    // Processor count: 4

    This demonstrates how to detect which GC mode is active and check system configuration—essential for diagnosing performance issues in production.

    Comparison Table

    Aspect Workstation GC Server GC
    Heap Count One shared heap One heap per logical core
    GC Threads Concurrent with application thread Dedicated parallel threads (one per core)
    Latency Lower, more predictable pauses Higher peak pause times, variable
    Throughput Lower (single-threaded collections) Higher (parallel collections across cores)
    Memory Usage Minimal overhead Higher (multiple heaps & metadata)
    Best For Client apps, UI, single/few cores Server workloads, high-throughput services
    What is the difference between workstation and server GC diagram

    Key points to mention

    • Trade-off: Workstation prioritizes latency; Server prioritizes throughput. Choose based on your workload's sensitivity to pauses.
    • Default behavior: .NET Core automatically selects Server GC on multi-core machines unless explicitly disabled—you should verify this in production.
    • Allocation affinity: Server GC reduces lock contention by having threads allocate from their own heap, improving concurrent performance.
    • Memory overhead: Server GC typically consumes 20–40% more memory than Workstation GC due to per-core heap duplication and alignment.
    • Monitoring: Use GC metrics like Gen 2 collection frequency and pause time to validate your choice in production.

    Common follow-up questions

    • "Can you mix Workstation and Server GC?" No—it is a process-wide setting. However, you can use object pooling and region-based allocation to reduce GC pressure regardless of mode.
    • "How does Server GC interact with CPU affinity?" Each GC thread is typically affinitized to a specific core for better cache locality, which is why it scales well on NUMA systems.
    • "What happens on a single-core machine with Server GC enabled?" Server GC will still create the infrastructure but will behave similarly to Workstation GC since there is only one core.

    Related interview questions

    Further reading

    What is the difference between workstation and server GC?
    .NETCsharpPerformance
  223. 223What is DATAS in .NET 8 and 9?

    Expert

    DATAS (Dynamic Adaptation To Application Sizes) is a Server GC feature introduced in .NET 8 and enabled by default in .NET 9 that automatically grows and shrinks the number of garbage collection heaps based on application load, significantly reducing memory consumption in containerized environments with variable traffic patterns.

    How it works

    DATAS operates by monitoring CPU usage and heap fragmentation during garbage collection. When an application experiences sustained high CPU load during GC, the Server GC adds additional heaps to distribute the GC work across more threads, reducing pause times and improving throughput. Conversely, when load decreases and CPU usage during GC drops, DATAS removes unused heaps, allowing the runtime to reclaim memory.

    This is particularly valuable in cloud-native scenarios where containers face unpredictable traffic patterns. A service might experience a traffic spike requiring 8 GC heaps, then drop back to baseline requiring only 4. Traditional Server GC would maintain all 8 heaps permanently; DATAS adapts dynamically. The feature works within the bounds set by GCHeapCount (maximum heaps) and respects the heap memory budget to prevent over-allocation.

    The adaptation algorithm runs during GC collection events and considers historical metrics. It does not add or remove heaps on every collection—changes are deliberate and measured to avoid thrashing. This makes it safe for production workloads while delivering real memory savings for containerized applications where resource allocation is metered and cost-sensitive.

    Example

    csharp
    // Program.cs - .NET 9 Console app
    using System;
    using System.Collections.Generic;
    
    Console.WriteLine($"Processor count: {Environment.ProcessorCount}");
    Console.WriteLine($"GC Heap Count: {GC.GetTotalMemory(false) / 1024 / 1024} MB");
    
    // Simulate variable load patterns
    var tasks = new List();
    for (int i = 0; i < 5; i++)
    {
        tasks.Add(Task.Run(() =>
        {
            var data = new List();
            for (int j = 0; j < 1000; j++)
            {
                data.Add(new byte[10240]); // 10 KB allocations
                if (j % 100 == 0) GC.Collect(2, GCCollectionMode.Optimized);
            }
            // Simulate work, then release memory—DATAS shrinks heaps when load drops
        }));
    }
    await Task.WhenAll(tasks);
    
    Console.WriteLine("Tasks completed. DATAS has adapted heap count to lower load.");
    

    This example shows a workload that peaks under concurrent load then subsides; DATAS will add heaps during the load spike and remove them afterward, keeping memory usage minimal during idle or low-traffic periods.

    Key points to mention

    • Enabled by default in .NET 9—opt-in via DOTNET_gcDynamic=1 in .NET 8; no configuration needed in .NET 9.
    • Container-optimized—solves the problem of fixed heap allocation in Kubernetes pods; see how to size memory for .NET in Kubernetes for related guidance.
    • Works only with Server GC—workstation GC uses a single heap and does not participate in DATAS adaptation.
    • Reduces memory fragmentation—fewer active heaps during low-load periods means less wasted memory; complements GC decision logic.
    • Respects GCHeapCount limits—will not exceed the configured maximum heap count; balances adaptation with resource safety.

    Common follow-up questions

    • Can DATAS be disabled? Yes—set DOTNET_gcDynamic=0 to revert to fixed heap allocation, useful for performance-sensitive workloads where predictability matters more than memory efficiency.
    • What metrics trigger heap adjustment? CPU usage during GC collection, heap fragmentation, and historical allocation patterns; the runtime uses adaptive heuristics rather than fixed thresholds.
    • Does DATAS work with Native AOT? DATAS is a runtime feature; see trade-offs of Native AOT for GC differences in ahead-of-time compiled applications.

    Related interview questions

    .NETCsharpPerformanceDevOps
  224. 224What is the Large Object Heap?

    Expert

    The Large Object Heap (LOH) is a separate memory region in the .NET garbage collector dedicated to objects 85,000 bytes or larger. Unlike the Gen 0 and Gen 1 heaps, LOH is collected together with Gen 2 and is not compacted by default, meaning freed memory fragments over time and can lead to allocation failures and expensive full GC collections.

    How it works

    The .NET garbage collector maintains separate heaps for different object sizes. Objects under 85,000 bytes go to the standard generational heap (Gen 0, Gen 1, Gen 2). Objects 85,000 bytes and larger are automatically allocated on the LOH. This threshold exists because compacting very large objects is computationally expensive—moving megabytes of memory around during collection creates significant pause times.

    When LOH fills up, the garbage collector performs a Gen 2 collection, which scans the entire LOH and reclaims unreferenced objects. However, it does not move the remaining objects together by default, leaving gaps (fragmentation) between live objects. Over many allocations and deallocations, the LOH becomes increasingly fragmented. Eventually, even though total free memory exists, there may not be a contiguous block large enough for a new allocation, triggering an OutOfMemoryException or forcing a full compacting collection.

    .NET 4.5.1+ introduced GCSettings.IsServerGC and LOH compaction options, and .NET 5+ added the ability to explicitly compact LOH using GCCollectionMode.Optimized. In .NET 6+, you can enable automatic LOH compaction via runtime configuration.

    Example

    csharp
    // Demonstrating LOH allocation
    class LargeDataBuffer
    {
        public byte[] Data { get; set; }
        
        public LargeDataBuffer(int sizeInBytes)
        {
            // Allocate ~100 KB (above 85 KB threshold → goes to LOH)
            Data = new byte[sizeInBytes];
        }
    }
    
    class Program
    {
        static void Main()
        {
            var buffers = new List<LargeDataBuffer>();
            
            // Allocate and deallocate large objects repeatedly
            for (int i = 0; i < 1000; i++)
            {
                var buffer = new LargeDataBuffer(100_000);
                buffers.Add(buffer);
                
                if (i % 100 == 0)
                {
                    buffers.Clear(); // Deallocate → fragments LOH
                    GC.Collect(2, GCCollectionMode.Optimized);
                }
            }
        }
    }

    This example repeatedly allocates and deallocates 100 KB buffers on the LOH. Without compaction, freed gaps accumulate, and GC.Collect(2, GCCollectionMode.Optimized) is used to trigger explicit LOH compaction (available in .NET 5+).

    Key points to mention

    • 85,000 byte threshold: Objects 85,000 bytes or larger allocate directly to LOH, bypassing generational collection.
    • No automatic compaction: LOH is collected with Gen 2 but not compacted by default, leading to fragmentation over time.
    • Performance impact: Fragmentation forces expensive full compacting collections, causing long GC pauses in latency-sensitive applications.
    • Compaction options: .NET 5+ allows explicit compaction via GCCollectionMode.Optimized, and .NET 6+ can enable automatic LOH compaction via configuration.
    • Monitoring and design: Use tools like PerfView to detect excessive LOH allocation; consider object pooling, ArrayPool<T>, or redesigning to avoid large allocations.

    Common follow-up questions

    • How do you detect LOH fragmentation? Use ETW events (Event Tracing for Windows), GC.GetTotalMemory(), or profilers like dotTrace to observe GC pause times and heap structure.
    • When should you use ArrayPool<T> instead of allocating large arrays directly? ArrayPool reuses pre-allocated buffers, avoiding repeated LOH allocations and reducing GC pressure—ideal for temporary large buffers in high-throughput scenarios.
    • How does reducing GC pauses in latency-sensitive services relate to LOH management? Avoiding large allocations, pooling buffers, and enabling LOH compaction all minimize Gen 2 collection frequency and duration.

    Related interview questions

    Further reading

    .NETCsharpPerformance
  225. 225What is the Pinned Object Heap?

    Expert

    The Pinned Object Heap (POH) is a dedicated memory region introduced in .NET 5 to store objects that are pinned in memory, preventing GC fragmentation. When you pin an object, the garbage collector cannot move it, which previously fragmented the regular managed heap; POH solves this by isolating pinned objects in their own heap. You allocate to POH using GC.AllocateArray<T>(length, pinned: true).

    How it works

    Normally, when you call GCHandle.Alloc(obj, GCHandleType.Pinned), the garbage collector marks that object as pinned. During compaction, the GC cannot move pinned objects, leaving holes in the heap that fragment memory over time. This fragmentation increases allocation latency and worsens memory usage.

    The Pinned Object Heap works around this by maintaining a separate, dedicated heap space specifically for objects you know will be pinned. Objects allocated with GC.AllocateArray<T>(length, pinned: true) go directly into POH, bypassing the standard Gen0/Gen1/Gen2 structure. The POH is never compacted—objects remain at fixed addresses throughout their lifetime. This means:

    • No fragmentation from pinned objects contaminating the regular heap
    • Pinned objects can be accessed via fixed pointers without GCHandle overhead
    • The regular heap stays more compact and cache-friendly
    • POH objects are collected when they become unreachable, but the POH itself is not compacted

    Example

    csharp
    // Before .NET 5: pinning fragmented the regular heap
    byte[] buffer1 = new byte[1024];
    GCHandle handle1 = GCHandle.Alloc(buffer1, GCHandleType.Pinned);
    IntPtr ptr1 = handle1.AddrOfPinnedObject();
    
    // .NET 5+: allocate directly to Pinned Object Heap
    byte[] buffer2 = GC.AllocateArray<byte>(1024, pinned: true);
    GCHandle handle2 = GCHandle.Alloc(buffer2, GCHandleType.Pinned);
    IntPtr ptr2 = handle2.AddrOfPinnedObject();
    
    // For interop scenarios (P/Invoke, COM), this is much cleaner
    // Buffer2 won't fragment the regular heap
    Console.WriteLine($"Buffer1 pinned via GCHandle (regular heap)");
    Console.WriteLine($"Buffer2 pinned via POH (dedicated heap)");
    
    handle1.Free();
    handle2.Free();

    This example demonstrates allocating an array directly to the Pinned Object Heap, ensuring it never moves and avoiding fragmentation of the regular managed heap.

    Key points to mention

    • No compaction: POH objects stay at their allocated address forever; they are not relocated during GC collection cycles.
    • Interop scenario: Particularly useful for P/Invoke and COM scenarios where you need a stable pointer to pass to unmanaged code.
    • Allocation only: You can only allocate arrays to POH via GC.AllocateArray<T>(); regular objects cannot be directly allocated there.
    • Gen2-like collection: POH objects are collected as Gen2, meaning they survive many collection cycles before being reclaimed.
    • Memory trade-off: POH reduces fragmentation of the regular heap but uses additional memory; use it only when you actually need pinned objects.

    Common follow-up questions

    • How does the Pinned Object Heap differ from the Large Object Heap? (LOH holds objects >85KB and is collected less frequently; POH is specifically for pinned objects regardless of size.)
    • What is the performance impact of pinning objects before POH existed? (Pinned objects block GC compaction, causing the heap to fragment and increasing allocation time for all objects.)
    • Can you manually deallocate POH memory? (No; POH objects are released only when they become unreachable and the GC collects them.)

    Related interview questions

    Further reading

    .NETCsharpPerformance
  226. 226What are GC regions?

    Expert

    GC regions are a memory management model introduced in .NET 7 where the garbage collector organizes heap memory into small, variable-sized regions instead of fixed-size segments. This allows generations to grow dynamically, improves memory fragmentation handling, and enables more efficient memory return to the operating system.

    How it works

    Prior to .NET 7, the GC managed memory in fixed segments: generation 0, generation 1, and generation 2 each had predetermined sizes. When a generation filled up, the GC would promote objects to the next generation or allocate a new segment. This approach was inflexible—segments couldn't shrink easily, and memory often remained committed to the process even when no longer needed.

    With GC regions, the heap is divided into many small, uniform regions (typically 4 MB each in 64-bit processes). Objects are allocated into regions based on their generation. When a garbage collection cycle runs, entire regions can be reclaimed if they contain only dead objects, or compacted if they're partially occupied. This granular approach means:

    • Generations can grow or shrink by adding or removing individual regions rather than allocating massive fixed segments
    • Memory fragmentation is reduced because the GC can reclaim a complete region rather than leaving gaps within a large segment
    • Unused regions are more easily returned to the OS, reducing the process's overall memory footprint
    • The GC can be more selective about which regions to collect, improving GC efficiency

    Regions also enable features like heap count scaling, where multiple independent heaps (one per logical processor) can manage their own regions, reducing contention in multi-threaded scenarios.

    Example

    csharp
    using System;
    using System.Diagnostics;
    
    class RegionGCDemo
    {
        static void Main()
        {
            Console.WriteLine($"GC is server mode: {GCSettings.IsServerGC}");
            Console.WriteLine($"Heap count: {GC.GetTotalMemory(false)}");
    
            // Allocate objects that will span multiple regions
            var largeObjects = new byte[10_000_000][];
            for (int i = 0; i < largeObjects.Length; i++)
            {
                largeObjects[i] = new byte[100];
            }
    
            Console.WriteLine($"After allocation: {GC.GetTotalMemory(false) / 1_000_000} MB");
    
            // Clear references - regions containing these objects can be returned
            Array.Clear(largeObjects, 0, largeObjects.Length);
            largeObjects = null;
    
            GC.Collect();
            GC.WaitForPendingFinalizers();
            GC.Collect();
    
            Console.WriteLine($"After GC: {GC.GetTotalMemory(false) / 1_000_000} MB");
        }
    }
    

    This example demonstrates how under the region-based GC, when large allocations are released and collected, entire regions are returned to the OS more efficiently than in the old segment model.

    Key points to mention

    • Region size is fixed per platform (e.g., 4 MB for 64-bit) but is transparent to the developer—the GC manages regions automatically
    • Memory efficiency is improved because regions allow finer-grained control over what memory is committed to the process
    • Backward compatibility is maintained; existing .NET code runs without modification, and the region model is a transparent implementation detail
    • Performance implications can be positive in scenarios with high memory churn or large heaps, though the impact varies by workload
    • Configurability is limited; you cannot directly tune region size, but GC modes (workstation vs. server) still apply

    Common follow-up questions

    • How does the region model affect GC pause times? — Regions enable the GC to target smaller portions of the heap, potentially reducing pause times, especially with background GC enabled.
    • Can I disable regions and use the old segment model? — No direct opt-out exists; regions are the default in .NET 7+. You can tune GC behavior via environment variables or GCSettings, but not revert to segments.
    • How do regions interact with GC triggers? — The GC may be triggered when regions fill up, similar to old segments, but the finer granularity allows more responsive collection decisions.

    Related interview questions

    .NETCsharpPerformance
  227. 227How do you diagnose a memory leak in .NET?

    Expert

    A memory leak in .NET occurs when objects remain reachable from the garbage collector's roots even after they're no longer needed by your application, preventing them from being collected. Diagnosis involves monitoring heap growth with dotnet-counters, capturing heap snapshots using dotnet-gcdump or dotnet-dump, comparing snapshots over time to identify which objects are accumulating, and tracing their reference chains to find what's holding them alive—typically static caches, event subscriptions, or timer callbacks.

    How it works

    .NET memory leak diagnosis is a multi-step process. First, establish a baseline by monitoring garbage collection metrics in real time. dotnet-counters shows heap size, collection counts, and allocation rates; sustained heap growth despite garbage collections indicates leaked memory. Second, capture heap dumps at different points in time—typically at application start and after sustained operation—using dotnet-gcdump for managed-only snapshots or dotnet-dump collect for full memory captures. Third, compare the two snapshots using tools like Visual Studio's memory analyzer, WinDbg, or dotMemory to identify which object types have grown. Finally, use the heap analyzer's roots viewer to walk the reference chain backward from accumulated objects to their GC roots, which reveals the chain of references keeping them alive.

    Common culprits are static collections that grow without bounds, event handlers not unsubscribed when subscribers are disposed, objects held by timers or background tasks, and circular references involving finalizers. The key insight is that managed memory leaks are almost always due to strong references, not circular references—the GC handles cycles fine as long as no root references them.

    Example

    csharp
    public class LeakDemo
    {
        // Static cache that grows without bound—classic leak
        private static List<byte[]> _cache = new();
        
        public void AddToCache(int size)
        {
            _cache.Add(new byte[size]);
        }
        
        // Event handler not unsubscribed—prevents subscriber cleanup
        public event EventHandler? MyEvent;
        
        public void SubscriberLeak()
        {
            var subscriber = new object();
            MyEvent += (s, e) => Console.WriteLine(subscriber);
            // subscriber is captured in delegate but never unsubscribed
            // even if subscriber is no longer needed elsewhere
        }
    }
    
    // Diagnosis workflow:
    // 1. Run: dotnet-counters monitor -n YourApp
    // 2. Observe Gen2 heap size growing over time
    // 3. Run: dotnet-dump collect -n YourApp -o dump1.dmp
    // 4. After sustained operation: dotnet-dump collect -n YourApp -o dump2.dmp
    // 5. Compare dumps using Visual Studio → Debug → Open Dump File → Type/Instance view
    // 6. Sort by count; _cache and MyEvent subscribers will show massive growth
    

    This example demonstrates two classic leak patterns: an unbounded static cache and captured references in event handlers that prevent garbage collection.

    Key points to mention

    • Use dotnet-counters for real-time monitoring and dotnet-gcdump/dotnet-dump for snapshot capture; compare heaps across time, not just absolute size
    • Always check for static collections, cached objects, unsubscribed events, and GC.KeepAlive calls that extend object lifetimes artificially
    • Use the heap analyzer's retention path or roots view to trace why an object is kept alive; the path from root to object reveals the leak chain
    • Test under realistic load; memory leaks often only become obvious after hours of operation when objects accumulate; run automated load tests with memory monitoring
    • Watch for finalizers holding references or pinned object handles; these prevent even unused objects from being collected

    Common follow-up questions

    • Can you give examples of common memory leak patterns in .NET? (Static caches, event handler subscriptions not cleaned up, timers holding delegates, circular event handlers)
    • What causes memory leaks in managed code?
    • How would you prevent memory leaks during code review or design? (Implement IDisposable, use weak events, bound caches with eviction, unsubscribe in finalizer/dispose)

    Related interview questions

    .NETCsharpPerformance
  228. 228What causes memory leaks in managed code?

    Expert

    Memory leaks in managed code occur when objects are no longer needed but remain reachable to the garbage collector due to lingering references. Unlike unmanaged code, .NET's garbage collector cannot free memory if an object is still referenced—even indirectly—which means developer mistakes in reference management are the primary cause. Common culprits include unsubscribed event handlers, static collections that grow unbounded, captured closures, and long-lived objects holding references to short-lived ones.

    How it works

    The garbage collector in .NET reclaims memory by determining which objects are reachable from root references (stack frames, static fields, GC handles). If an object has any incoming reference, it remains alive. Memory leaks happen when a chain of references keeps objects in memory longer than their intended lifetime.

    The most common pattern is event handler subscriptions without unsubscription. When you subscribe to an event on a long-lived object from a short-lived object, the long-lived object holds a reference to the short-lived one. If you never unsubscribe, the short-lived object cannot be garbage collected. Static fields and caches that grow indefinitely create similar problems—they accumulate references indefinitely. Captured closures in lambdas and delegates can extend the lifetime of local variables beyond their scope. Finally, garbage collection cannot help if references remain reachable.

    Example

    csharp
    public class LongLivedService
    {
        public event EventHandler DataChanged;
        
        public void TriggerChange() => DataChanged?.Invoke(this, EventArgs.Empty);
    }
    
    public class ShortLivedHandler
    {
        private byte[] _largeBuffer = new byte[1024 * 1024]; // 1 MB
        
        public ShortLivedHandler(LongLivedService service)
        {
            // Memory leak: service holds reference to this handler
            service.DataChanged += (s, e) => ProcessData();
        }
        
        private void ProcessData() => Console.WriteLine("Processing...");
    }
    
    // Usage
    var longLived = new LongLivedService();
    for (int i = 0; i < 1000; i++)
    {
        var handler = new ShortLivedHandler(longLived);
        // handler goes out of scope, but longLived still holds a reference via the event
    }
    // longLived still holds 1000 handlers, preventing garbage collection of each 1 MB buffer

    This code demonstrates a classic event handler leak: 1000 short-lived handlers with 1 MB buffers remain in memory because the long-lived service maintains event subscriptions even after the handlers are no longer referenced elsewhere.

    Key points to mention

    • Event handlers are the most common culprit—always unsubscribe when done, or use weak event patterns.
    • Static fields and caches live for the application lifetime; unbounded growth causes memory leaks even without explicit references.
    • Captured variables in closures extend the lifetime of objects; be cautious with lambdas capturing `this` or local variables in event handlers.
    • Timers and background tasks that hold references to short-lived objects must be properly disposed to break reference chains.
    • Dependency injection containers and long-lived service collections can inadvertently keep short-lived dependencies alive if not configured correctly.

    Common follow-up questions

    • How do you diagnose a memory leak in .NET? (See related question)
    • What patterns or libraries help prevent event handler leaks (e.g., WeakEventManager, IDisposable)?
    • Can you distinguish between a memory leak and simply high memory consumption in a healthy application?

    Related interview questions

    Further reading

    .NETCsharpPerformance
  229. 229What is a finalizer and why avoid it?

    Expert

    A finalizer is a destructor-like method that runs on the finalizer thread before an object's memory is reclaimed by the garbage collector. Finalizers should be avoided in most cases because they promote objects to later GC generations, delay collection, create thread contention, and can introduce subtle bugs. Instead, use IDisposable with deterministic cleanup or SafeHandle for unmanaged resources.

    How it works

    When you define a finalizer (using the ~ClassName() syntax), the CLR does not immediately free the object's memory when it becomes unreachable. Instead:

    • The GC marks the object as unreachable and adds it to the finalization queue.
    • A dedicated finalizer thread processes queued objects asynchronously, calling their finalizer methods.
    • Only after the finalizer completes can the object actually be collected in the next GC pass.
    • This promotion to a later generation increases memory pressure and delays reclamation.

    Finalizers were designed for cleanup of unmanaged resources (native handles, file pointers, COM objects). However, the indirection through the finalizer thread creates unpredictable latency and GC pauses, especially in high-throughput or latency-sensitive applications (see reducing GC pauses).

    Example

    csharp
    // ❌ Avoid: Finalizer causes GC delays
    public class BadFileReader
    {
        private IntPtr _nativeHandle;
    
        public BadFileReader(string path)
        {
            _nativeHandle = NativeFileOpen(path);
        }
    
        ~BadFileReader()  // Finalizer runs on finalizer thread—unpredictable
        {
            NativeFileClose(_nativeHandle);
        }
    
        private static extern IntPtr NativeFileOpen(string path);
        private static extern void NativeFileClose(IntPtr handle);
    }
    
    // ✅ Prefer: IDisposable + SafeHandle
    public class GoodFileReader : IDisposable
    {
        private SafeFileHandle _handle;
    
        public GoodFileReader(string path)
        {
            _handle = File.OpenHandle(path);
        }
    
        public void Dispose()
        {
            _handle?.Dispose();
            GC.SuppressFinalize(this);  // Tell GC finalizer is not needed
        }
    }
    

    The first example forces the finalizer thread to run cleanup, delaying GC and potentially blocking other finalization work. The second deterministically closes the handle when Dispose() is called, and GC.SuppressFinalize() tells the GC the object needs no finalizer processing.

    Key points to mention

    • Finalizer thread bottleneck: All finalizers run on a single thread; if one is slow, it backs up the entire queue.
    • Generation promotion: Objects with finalizers survive Gen0 collections, promoting to Gen1/Gen2, increasing memory fragmentation.
    • Non-deterministic timing: You cannot predict when a finalizer runs; it depends on GC cycles, making it unsuitable for critical cleanup.
    • IDisposable is deterministic: Calling Dispose() immediately frees resources; pair it with using statements or using declarations for automatic cleanup.
    • SafeHandle advantage: Provides a finalizer internally, but the CLR optimizes its cleanup path, making it safer than custom finalizers.

    Common follow-up questions

    • When should you use a finalizer? Rarely. Only as a safety net for SafeHandle-derived classes or when you absolutely must clean up unmanaged resources and cannot use IDisposable. Modern .NET guidance strongly discourages custom finalizers.
    • What is the difference between ~MyClass() and Dispose()? A finalizer is non-deterministic and runs on the finalizer thread; Dispose() is deterministic and runs on the caller's thread. Always prefer Dispose().
    • How do you prevent a finalizer from running? Call GC.SuppressFinalize(this) after cleanup is complete, signaling the GC that no further finalization is needed.

    Related interview questions

    .NETCsharpasyncPerformance
  230. 230What is the full Dispose pattern?

    Expert

    The full Dispose pattern is a structured way to clean up both managed and unmanaged resources safely. It involves implementing IDisposable with a Dispose(bool disposing) method that conditionally releases resources, calling GC.SuppressFinalize(this) in Dispose() to prevent the finalizer from running unnecessarily, and adding a finalizer only if your class directly holds unmanaged resources. This pattern ensures deterministic cleanup and prevents resource leaks.

    How it works

    The Dispose pattern uses two code paths: one for explicit cleanup via Dispose(), and one for finalizer-based cleanup when the garbage collector runs. The protected Dispose(bool disposing) method is the heart of the pattern:

    • When disposing is true, the method was called explicitly (via Dispose()), so you can safely access other managed objects and release both managed and unmanaged resources.
    • When disposing is false, the method was called from a finalizer, so you release only unmanaged resources because managed objects may already be finalized.
    • The public Dispose() method calls Dispose(true) and then GC.SuppressFinalize(this) to prevent the finalizer from running, avoiding unnecessary overhead.
    • A finalizer (destructor) acts as a safety net for unmanaged resources if Dispose() is never called, but it should only be present if you directly own unmanaged handles.

    This approach prevents the common pitfall of finalizers running on objects that were properly disposed, and ensures cleanup happens at the right time without double-disposal errors.

    Example

    csharp
    public class FileHandler : IDisposable
    {
        private IntPtr unmanagedHandle;
        private StreamWriter managedStream;
        private bool disposed = false;
    
        public FileHandler(string path)
        {
            managedStream = new StreamWriter(path);
            unmanagedHandle = Marshal.AllocHGlobal(1024); // Unmanaged resource
        }
    
        public void WriteData(string data) => managedStream?.Write(data);
    
        public void Dispose()
        {
            Dispose(true);
            GC.SuppressFinalize(this);
        }
    
        protected virtual void Dispose(bool disposing)
        {
            if (disposed)
                return;
    
            if (disposing)
            {
                // Release managed resources
                managedStream?.Dispose();
            }
    
            // Release unmanaged resources (always)
            if (unmanagedHandle != IntPtr.Zero)
            {
                Marshal.FreeHGlobal(unmanagedHandle);
                unmanagedHandle = IntPtr.Zero;
            }
    
            disposed = true;
        }
    
        ~FileHandler()
        {
            Dispose(false);
        }
    }

    This example shows a class holding both a managed StreamWriter and unmanaged memory; Dispose(true) releases both, while the finalizer only cleans up unmanaged resources.

    Key points to mention

    • Dispose(bool disposing) is the core method; when disposing is true, release managed and unmanaged resources; when false, release only unmanaged resources.
    • Always call GC.SuppressFinalize(this) in public Dispose() to prevent the finalizer from running unnecessarily and improve garbage collection performance.
    • Include a finalizer only if your class directly owns unmanaged resources (file handles, database connections, unmanaged memory); if you only use other IDisposable objects, skip the finalizer.
    • Use a disposed flag to prevent double disposal and throw ObjectDisposedException if methods are called after disposal.
    • In modern C#, consider using IAsyncDisposable for async cleanup scenarios and the using declaration for automatic disposal.

    Common follow-up questions

    • Why call GC.SuppressFinalize in Dispose? Because finalizers have overhead and require a second garbage collection pass; suppressing it after explicit cleanup improves performance.
    • When should I use a finalizer? Only when your class directly owns unmanaged resources like Win32 handles or unmanaged memory; if you only compose IDisposable objects, you don't need one.
    • Should I throw in Dispose? Avoid throwing exceptions in Dispose()` or finalizers; log errors instead, since disposal often happens in cleanup code paths where exceptions are unexpected.

    Related interview questions

    Further reading

    .NETCsharpArchitecture
  231. 231What is IAsyncDisposable?

    Expert

    IAsyncDisposable is an interface that defines a DisposeAsync() method for gracefully releasing unmanaged or managed resources that require asynchronous operations. It mirrors IDisposable but is specifically designed for cleanup logic that must run asynchronously—such as flushing buffered data, completing pending I/O operations, or closing network connections. You consume it using the await using statement.

    How it works

    IAsyncDisposable was introduced in .NET Core 3.0 to handle scenarios where synchronous disposal is insufficient. Unlike IDisposable.Dispose(), which blocks the calling thread, DisposeAsync() returns a ValueTask that can be awaited, allowing the runtime to perform other work while cleanup completes.

    The await using statement (C# 8.0+) automatically invokes DisposeAsync() when the code exits the block, even if an exception occurs. This ensures that resources are cleaned up asynchronously without deadlocks. If a class implements both IDisposable and IAsyncDisposable, the async version takes precedence in an await using block.

    The method must handle the case where it is called multiple times, and it should suppress finalization if cleanup occurs successfully. For long-running or I/O-bound cleanup operations, DisposeAsync() prevents thread pool starvation by not blocking synchronously.

    Example

    csharp
    public class AsyncDatabaseConnection : IAsyncDisposable
    {
        private readonly SqlConnection _connection;
        private bool _disposed = false;
    
        public AsyncDatabaseConnection(string connectionString)
        {
            _connection = new SqlConnection(connectionString);
        }
    
        public async Task OpenAsync()
        {
            await _connection.OpenAsync();
        }
    
        public async ValueTask DisposeAsync()
        {
            if (_disposed)
                return;
    
            try
            {
                // Asynchronous cleanup: flush pending commands, close connection gracefully
                await _connection.CloseAsync();
            }
            finally
            {
                _connection?.Dispose();
                _disposed = true;
            }
        }
    }
    
    // Usage
    public async Task Demo()
    {
        await using var db = new AsyncDatabaseConnection("Server=localhost");
        await db.OpenAsync();
        // Use connection
    } // DisposeAsync() is awaited automatically here
    

    This example shows a database connection that uses await using to ensure CloseAsync() completes before exiting the scope, preventing connection leaks.

    Key points to mention

    • DisposeAsync() returns a ValueTask, not Task, to minimize allocations for synchronous completions.
    • Use await using (not using) to properly invoke and await DisposeAsync().
    • Implement idempotency: calling DisposeAsync() multiple times should be safe and not throw exceptions.
    • Classes can implement both IDisposable and IAsyncDisposable; prioritize async disposal in async contexts.
    • Common scenarios include closing network sockets, flushing streams, committing database transactions, and releasing cloud service connections.

    Common follow-up questions

    • Should I always implement both IDisposable and IAsyncDisposable? When is each appropriate?
    • How does await using differ from using, and what happens if you forget the await keyword?
    • What happens if DisposeAsync() throws an exception, and how should error handling work?

    Related interview questions

    .NETCsharpasync
  232. 232How does the thread pool work?

    Expert

    The .NET thread pool is a managed collection of worker threads that execute queued work items on demand. It uses a hill-climbing algorithm to automatically adjust the number of active threads based on throughput, minimizing context switching while maximizing parallelism. When work items block (e.g., via synchronous I/O or lock contention), the thread pool injects new threads slowly, which can lead to starvation if many threads remain blocked.

    How does the thread pool work diagram

    How it works

    The thread pool maintains separate queues for work items and uses a sophisticated algorithm to decide when to create or retire threads. Here's the process:

    1. Queue submission: When you call ThreadPool.QueueUserWorkItem() or use Task.Run(), the work item enters the global or local queue.
    2. Thread availability check: If idle worker threads exist, one is woken to dequeue and execute the work.
    3. Hill-climbing adjustment: The thread pool measures throughput (completed work items per unit time). If throughput increases when adding a thread, it injects a new thread; if it decreases, it retires one.
    4. Blocking detection: If a thread blocks (synchronously waiting), the thread pool cannot assume it will return soon. Instead of waiting, it injects a new thread to keep the queue serviced.
    5. Slow injection rate: New threads are injected gradually (roughly one every 500ms), so if many threads block simultaneously, starvation occurs—queued work waits while the pool slowly adds capacity.

    Example

    csharp
    using System;
    using System.Threading;
    using System.Threading.Tasks;
    
    class ThreadPoolDemo
    {
        static void Main()
        {
            // Get initial pool size
            ThreadPool.GetMinThreads(out int workerThreads, out int completionPortThreads);
            Console.WriteLine($"Initial: {workerThreads} worker threads");
    
            // Queue multiple async-friendly tasks
            for (int i = 0; i < 10; i++)
            {
                int taskId = i;
                Task.Run(async () =>
                {
                    Console.WriteLine($"Task {taskId} started on thread {Thread.CurrentThread.ManagedThreadId}");
                    await Task.Delay(100); // Non-blocking async wait
                    Console.WriteLine($"Task {taskId} completed");
                });
            }
    
            // Blocking example (DANGEROUS - can cause starvation)
            // for (int i = 0; i < 10; i++)
            // {
            //     int taskId = i;
            //     Task.Run(() =>
            //     {
            //         Thread.Sleep(1000); // BLOCKS thread - pool injects slowly!
            //         Console.WriteLine($"Task {taskId} done");
            //     });
            // }
    
            Thread.Sleep(2000);
            ThreadPool.GetStats(out long completedWorkItems, out long currentQueueDepth);
            Console.WriteLine($"Completed: {completedWorkItems}, Queued: {currentQueueDepth}");
        }
    }
    

    This example shows how async-friendly work scales efficiently—the thread pool can handle many concurrent operations without creating many threads. The commented blocking code illustrates the danger: Thread.Sleep blocks the thread, forcing the pool to inject new ones slowly, wasting resources.

    Key points to mention

    • Hill-climbing algorithm: The pool adapts thread count by measuring throughput, not by a fixed ratio or count.
    • Slow thread injection: New threads are added gradually (around one per 500ms), so synchronous blocking calls can cause thread pool starvation.
    • Async is preferred: Using async/await instead of blocking keeps threads available for other work and avoids starvation.
    • Minimum vs. maximum threads: The pool maintains a minimum threshold of threads and can grow up to the processor count times a heuristic factor, with a configurable maximum.
    • Sync over async is dangerous: Wrapping async operations with .Result or .Wait() blocks the thread and can deadlock in high-contention scenarios.

    Common follow-up questions

    • How do you configure thread pool size? Use ThreadPool.GetMinThreads() and ThreadPool.SetMinThreads(), or environment variables like DOTNET_ThreadPool_MinThreads. Setting too high wastes memory; too low causes starvation.
    • What is the difference between Task and ThreadPool.QueueUserWorkItem? Task is higher-level and integrates with async/await and continuation support; QueueUserWorkItem is lower-level and returns void, making it harder to track or chain operations.
    • How do you detect if the thread pool is saturated? Monitor queue depth using ThreadPool.GetStats(), use performance counters, or enable ETW tracing to observe thread creation rates and work-item completion times.

    Related interview questions

    Further reading

    How does the thread pool work?
    .NETCsharpasyncPerformance
  233. 233What is thread pool starvation and how do you detect it?

    Expert

    Thread pool starvation occurs when worker threads in the thread pool become blocked on long-running or I/O operations, causing queued work items to wait indefinitely for execution. The symptom is high latency and low CPU utilization—your application appears hung because available threads are exhausted. Detection involves monitoring queue depth, thread counts, and latency metrics via dotnet-counters or ETW events.

    How it works

    The .NET thread pool maintains a fixed number of worker threads and a queue of work items. When you call ThreadPool.QueueUserWorkItem() or await on I/O via async/await, the work is either executed immediately by an available thread or queued. If all threads become blocked—for instance, on synchronous I/O, locks, or CPU-intensive calculations—new work sits in the queue while the pool struggles to create enough threads to handle it.

    The thread pool has injection logic that slowly adds new threads if work remains queued, but this injection is conservative by design to avoid resource explosion. On a server with 100 logical cores, you might start with 100 worker threads; if all are blocked, the pool may add only a few new threads per second. Meanwhile, requests pile up in the queue, and latency skyrockets despite available CPU.

    Common causes include blocking operations inside async code (like Task.Wait() or .Result), synchronous I/O calls, expensive lock contention, or running CPU-bound work on thread pool threads without yielding. Unlike async/await patterns that free threads during I/O, these patterns hold thread resources hostage.

    Example

    csharp
    using System.Diagnostics;
    
    // STARVATION SCENARIO
    var sw = Stopwatch.StartNew();
    var tasks = new List<Task>();
    
    // Queue 1000 work items that block on I/O
    for (int i = 0; i < 1000; i++)
    {
        tasks.Add(Task.Run(() =>
        {
            // BLOCKING CALL - holds thread during I/O
            Thread.Sleep(500); // Simulates sync I/O
        }));
    }
    
    // Meanwhile, try to queue high-priority work
    var priority = Task.Run(() =>
    {
        Console.WriteLine("I'm waiting...");
    });
    
    Task.WaitAll(tasks.Concat(new[] { priority }).ToArray());
    sw.Stop();
    
    Console.WriteLine($"Total time: {sw.ElapsedMilliseconds}ms");
    // With starvation, this takes much longer than expected
    // because priority task waits for threads to free up

    This code queues 1000 blocking operations. Each holds a thread for 500ms, so with ~100 threads available, only ~100 tasks run concurrently; the rest queue up. The priority task starves waiting for a free thread, and total time balloons to several seconds instead of 500ms.

    Key points to mention

    • Root cause: Blocking operations inside thread pool work prevent thread reuse; async/await frees threads during I/O, so prefer it over Task.Run() wrapping synchronous calls.
    • Detection tools: Use dotnet-counters monitor (watch ThreadPool Work Item Queue Length), ETW tracing, or custom diagnostics to capture thread counts and queue depth over time.
    • Slow injection: The thread pool's conservative thread injection policy means starvation recovery is delayed; immediate symptom is latency spike with CPU underutilization.
    • Prevention: Avoid Task.Wait(), .Result, and synchronous I/O inside async contexts; use pure async paths and respect thread pool budgets.

    Common follow-up questions

    • How do you fix starvation if you inherit synchronous code? Wrap sync calls with Task.Run(() => syncMethod()) to offload them to a background thread, or use libraries like Nito.Async to bridge the gap. Never block the async call chain itself.
    • What is a safe queue depth threshold? It depends on your SLA, but generally a growing queue with stable thread count is a red flag. Monitor latency percentiles and queue depth together; alert if queue depth exceeds 10× the thread count or latency p99 exceeds your target.
    • Can CompletionSource help? TaskCompletionSource doesn't consume a thread, so it's useful for bridging callbacks to async code—but it doesn't fix blocking operations already on the thread pool.

    Related interview questions

    Further reading

    .NETCsharpasyncPerformance
  234. 234How does async/await compile under the hood?

    Expert

    When you write an async method, the C# compiler doesn't execute it as written. Instead, it rewrites the entire method into a state machine struct that implements IAsyncStateMachine. Each await point becomes a state transition; when an awaited task completes, the runtime calls MoveNext() on the state machine to resume execution from where it left off.

    How it works

    The compiler performs several transformations on an async method:

    1. State machine creation: A private struct is generated inside the declaring type, implementing IAsyncStateMachine. Local variables become fields on this struct so they persist across state transitions.
    2. State field: The struct contains a state field (typically an int) tracking which await point we're at. State 0 might be "start," state 1 "after first await," state 2 "after second await," and so on.
    3. MoveNext method: This is the core of the state machine. It contains a switch statement on the state field, executing different blocks of code for each state. After each await, it updates the state and either awaits the next task or returns.
    4. SetStateMachine: This method is called by the runtime to inform the state machine of its own location (needed for proper disposal).
    5. Continuation wiring: The state machine sets itself as the continuation on each awaited task using UnsafeOnCompleted or OnCompleted. When the task completes, the runtime invokes MoveNext() again.
    6. Return value wrapping: If the async method returns Task<T>, an AsyncTaskMethodBuilder<T> is used to construct and return the task that the caller awaits.

    This approach allows the method to be paused and resumed without blocking threads, enabling efficient concurrent I/O operations.

    Example

    csharp
    // Original async method
    public async Task<string> FetchDataAsync(string url)
    {
        var response = await httpClient.GetAsync(url);
        var content = await response.Content.ReadAsStringAsync();
        return content;
    }
    
    // Simplified compiled version (conceptual)
    public struct FetchDataAsync_StateMachine : IAsyncStateMachine
    {
        private int state = 0;
        private TaskAwaiter<HttpResponseMessage> awaiter1;
        private TaskAwaiter<string> awaiter2;
        private HttpResponseMessage response;
        private AsyncTaskMethodBuilder<string> builder;
    
        public void MoveNext()
        {
            try
            {
                if (state == 0)
                {
                    awaiter1 = httpClient.GetAsync(url).GetAwaiter();
                    if (!awaiter1.IsCompleted)
                    {
                        state = 1;
                        builder.AwaitUnsafeOnCompleted(ref awaiter1, ref this);
                        return;
                    }
                }
                if (state == 1)
                {
                    response = awaiter1.GetResult();
                    awaiter2 = response.Content.ReadAsStringAsync().GetAwaiter();
                    if (!awaiter2.IsCompleted)
                    {
                        state = 2;
                        builder.AwaitUnsafeOnCompleted(ref awaiter2, ref this);
                        return;
                    }
                }
                if (state == 2)
                {
                    var result = awaiter2.GetResult();
                    builder.SetResult(result);
                }
            }
            catch (Exception ex)
            {
                builder.SetException(ex);
            }
        }
    
        public void SetStateMachine(IAsyncStateMachine stateMachine) { }
    }

    This demonstrates how the compiler unrolls two sequential await points into a state machine that can pause execution at each await, allowing threads to be freed while operations complete asynchronously.

    Key points to mention

    • Each await becomes a state in an auto-generated struct; local variables become struct fields to survive across state transitions.
    • The MoveNext() method contains a switch on the state field, executing different code paths depending on which await point has just completed.
    • AsyncTaskMethodBuilder manages the Task object returned to the caller and coordinates result/exception propagation.
    • The state machine registers itself as a continuation on awaited tasks; when those tasks complete, the runtime calls MoveNext() again to resume.
    • This design enables efficient thread-pooling and prevents blocking—threads are freed while awaiting I/O, then reused when results arrive.

    Common follow-up questions

    • What is the performance cost of async/await? — Minimal at runtime; the state machine is typically stack-allocated. The cost is at compile time (larger IL). See What is async and await? for more context.
    • Why can async methods deadlock? — If you block on an async method with .Result or .Wait() on a thread with a synchronization context, the continuation may not find a free thread, causing a deadlock.
    • How does ConfigureAwait affect the state machine? — It changes which continuation callback is used (whether the synchronization context is captured), but the overall state machine structure remains the same.

    Related interview questions

    Further reading

    .NETCsharpasync
  235. 235What is SynchronizationContext?

    Expert

    SynchronizationContext is a framework abstraction that defines which thread (or execution environment) continuations of asynchronous operations will run on. It captures the "current context" and allows code to post work back to that context via Post or Send methods. UI frameworks (WinForms, WPF) use it to marshal callbacks back to the UI thread; ASP.NET Core has null context, so continuations run on any available thread pool thread.

    How it works

    SynchronizationContext is a thread-local or async-local value that represents the execution environment's scheduling rules. When you await a Task in a context-aware framework, the continuation (the code after await) is posted back to that context rather than running on whatever thread completes the Task.

    The mechanism works through SynchronizationContext.Current, which you can read at any point. During async/await operations, if Current is non-null, the async state machine calls context.Post(continuation, state) to schedule the continuation back in that context. For UI frameworks, Post enqueues work on the UI thread's message pump. For ASP.NET Core, there is no context, so continuations run on a thread pool thread immediately.

    This creates a natural "stickiness" to the originating thread or context without explicit thread marshaling code. You write await SomeAsync(), and the code after it automatically returns to the UI thread if you started on one.

    Example

    csharp
    // WinForms example - SynchronizationContext captures UI thread
    public partial class Form1 : Form
    {
        public Form1()
        {
            InitializeComponent();
        }
    
        private async void Button_Click(object sender, EventArgs e)
        {
            // SynchronizationContext.Current is the UI context here
            var context = SynchronizationContext.Current;
            Console.WriteLine($"Before: {Thread.CurrentThread.ManagedThreadId}");
    
            // This runs on a thread pool thread
            await Task.Delay(1000);
    
            // Continuation automatically returns to UI thread (same as "Before")
            Console.WriteLine($"After: {Thread.CurrentThread.ManagedThreadId}");
            MessageBox.Show("Back on UI thread!");
        }
    }
    
    // ASP.NET Core example - no SynchronizationContext
    public class HomeController : Controller
    {
        public async Task Index()
        {
            // SynchronizationContext.Current is null in ASP.NET Core
            Console.WriteLine($"Before: {Thread.CurrentThread.ManagedThreadId}");
    
            await Task.Delay(1000);
    
            // Continuation runs on any thread pool thread (not a specific "home" thread)
            Console.WriteLine($"After: {Thread.CurrentThread.ManagedThreadId}"); // Different thread
            return Ok("Done");
        }
    }

    In the WinForms example, both "Before" and "After" print the same thread ID because the context marshals the continuation back. In ASP.NET Core, the thread IDs will likely differ because there is no context to return to.

    Key points to mention

    • SynchronizationContext.Current is captured by the async state machine; it is not passed through method parameters—it is implicit and context-aware.
    • UI frameworks (WinForms, WPF, UWP) install a SynchronizationContext that schedules work back onto the UI thread; ASP.NET Core deliberately has none to avoid thread stickiness and improve scalability.
    • The danger is calling .Result or .Wait() on an async method while holding the context—it deadlocks if the continuation needs the same context and that thread is blocked.
    • You can disable context capture by calling ConfigureAwait(false), which tells the state machine to run the continuation on any thread, improving performance in libraries.
    • ExecutionContext is separate from SynchronizationContext; it flows logical call context (like AsyncLocal<T>), while SynchronizationContext defines where code runs.

    Common follow-up questions

    • Why should I use ConfigureAwait(false) in library code? — It tells the state machine not to post back to the captured context, avoiding unnecessary context switching and allowing the continuation to run on any thread pool thread, which is faster and prevents accidental UI dependencies.
    • Can I create a custom SynchronizationContext? — Yes; you subclass it and override Post and Send to implement your own scheduling logic. This is used in testing frameworks and custom execution environments.
    • How does this relate to the thread pool? — When a Task completes on a thread pool thread, the async state machine checks the SynchronizationContext; if non-null, it posts the continuation back via Post; otherwise, it runs inline or queues to the thread pool.

    Related interview questions

    .NETCsharpaspnetcoreasync
  236. 236What is ExecutionContext?

    Expert

    ExecutionContext is a .NET runtime object that flows ambient data—such as AsyncLocal<T> values, culture settings, and security context—across thread boundaries and asynchronous continuations. It is automatically captured and restored whenever you await, switch threads, or queue work to the thread pool, ensuring that contextual information remains consistent throughout the logical flow of execution.

    How it works

    When you enter an async method or queue a work item to the thread pool, the runtime captures the current ExecutionContext at that point. This context is then restored before the continuation executes on a potentially different thread. This process is called context flow.

    The context includes:

    • AsyncLocal<T> values — logical thread-local storage that flows across awaits.
    • Culture and UI culture — CultureInfo settings for formatting and parsing.
    • Security principal — Thread.CurrentPrincipal for authorization checks.
    • Correlation IDs and tracing context — diagnostic and logging information.

    By default, context flow is automatic and transparent. However, in performance-critical, low-level library code, you can suppress context flow using ExecutionContext.SuppressFlow() to avoid the overhead of capturing and restoring context on every operation. This is a micro-optimization rarely needed in application code.

    Example

    csharp
    using System;
    using System.Threading.Tasks;
    
    var requestId = new AsyncLocal<string> { Value = "REQ-12345" };
    
    Console.WriteLine($"Main: {requestId.Value}"); // REQ-12345
    
    await Task.Run(async () =>
    {
        // Context flows: requestId is still "REQ-12345"
        Console.WriteLine($"Task 1: {requestId.Value}"); // REQ-12345
        
        await Task.Delay(10);
        Console.WriteLine($"After await: {requestId.Value}"); // REQ-12345
    });
    
    // Suppressing context flow (rare optimization)
    using (ExecutionContext.SuppressFlow())
    {
        await Task.Run(() =>
        {
            // Context does NOT flow; requestId is null
            Console.WriteLine($"Suppressed: {requestId.Value}"); // (null)
        });
    }
    

    This example shows how AsyncLocal values automatically flow across awaits and thread switches, and how explicit suppression prevents that flow for performance reasons.

    Key points to mention

    • Automatic by default: You do not need to manually manage context flow; the runtime handles it transparently during awaits and thread pool callbacks.
    • Logical vs. physical threads: ExecutionContext maintains "logical" thread identity across multiple physical threads, making async code feel like a single thread from a context perspective.
    • Suppression is rare: Only suppress context flow in library code that must minimize overhead per operation (e.g., high-throughput logging, event dispatch). Application code should never suppress.
    • Captures at await points: Context is captured when you await or queue a callback; restoration happens before the continuation runs, potentially on a different thread.
    • Immutable and thread-safe: ExecutionContext snapshots are immutable and can be safely run on any thread via ExecutionContext.Run().

    Common follow-up questions

    • How does ExecutionContext differ from ThreadLocal<T>? — ThreadLocal is tied to a physical thread and is NOT inherited; ExecutionContext flows logically across awaits and includes AsyncLocal values. ThreadLocal breaks with async.
    • Why would you ever suppress context flow? — In extreme micro-optimization scenarios (e.g., millions of tiny tasks per second), capturing and restoring context adds overhead. Suppression is used in internal BCL code for performance, but application code almost never needs it.
    • Can you manually create and run an ExecutionContext? — Yes, via ExecutionContext.Capture() and ExecutionContext.Run(). This is advanced and rarely needed except in framework or diagnostic code.

    Related interview questions

    Further reading

    .NETCsharpasyncPerformance
  237. 237How does AsyncLocal<T> work?

    Expert

    AsyncLocal<T> stores values in the ExecutionContext so they automatically flow to child async operations, but changes made by child tasks do not propagate back to the parent. This makes it ideal for maintaining request-scoped state like correlation IDs, tenant information, or user context across async call chains without explicit parameter passing.

    How it works

    AsyncLocal<T> is a container that holds a value per logical async execution flow. When you set a value on an AsyncLocal<T>, it is stored in the current ExecutionContext. When an async operation (via await) or a task continuation runs, the runtime captures and flows the ExecutionContext to the child operation, making the AsyncLocal value accessible there.

    The key isolation mechanism is that each async task gets its own copy of the ExecutionContext. Changes to AsyncLocal values in child tasks do not affect the parent's value—this prevents child operations from corrupting shared state. This is different from thread-local storage, which is thread-specific and does not flow across await boundaries or thread pool threads.

    AsyncLocal uses a value accessor function pattern: you can optionally provide a ValueChangedHandler delegate that fires whenever the value changes within that execution context, enabling logging or cleanup logic.

    Example

    csharp
    var correlationId = new AsyncLocal<string>();
    
    async Task ProcessRequest(string requestId)
    {
        correlationId.Value = requestId;
        Console.WriteLine($"Parent: {correlationId.Value}");
        
        // Child task inherits the correlation ID
        await Task.Run(async () =>
        {
            Console.WriteLine($"Child 1: {correlationId.Value}");
            correlationId.Value = "modified-in-child";
        });
        
        // Parent still has original value
        Console.WriteLine($"Parent after child: {correlationId.Value}");
        
        // Another child gets the original value
        await Task.Delay(10);
        Console.WriteLine($"Child 2 (after delay): {correlationId.Value}");
    }
    
    await ProcessRequest("req-12345");
    // Output:
    // Parent: req-12345
    // Child 1: req-12345
    // Parent after child: req-12345
    // Child 2 (after delay): req-12345
    

    This example demonstrates how the correlation ID flows to child operations automatically, and how modifications in one child task do not affect the parent or sibling tasks.

    Key points to mention

    • ExecutionContext flow: AsyncLocal rides on ExecutionContext, which is automatically captured and restored across await points and task continuations.
    • Copy-on-write semantics: Child async operations receive a snapshot of the parent's context; changes in children are isolated and do not leak back.
    • No thread affinity: Unlike ThreadLocal<T>, AsyncLocal works correctly across thread pool switches and async/await boundaries, making it suitable for async-heavy applications.
    • Value changed callbacks: You can register a ValueChangedHandler to react to value changes, useful for diagnostics or side effects (e.g., logging).
    • Performance consideration: Excessive ExecutionContext flow can have overhead; use AsyncLocal for truly ambient values, not high-frequency state.

    Common follow-up questions

    • How does Task.Run vs. async/await affect ExecutionContext flow, and when does AsyncLocal become inaccessible?
    • Can you use AsyncLocal inside Task.WhenAll or Parallel.ForEach, and what are the gotchas?
    • How do you suppress ExecutionContext flow for performance, and what are the security implications?

    Related interview questions

    Further reading

    .NETCsharpasync
  238. 238What are IValueTaskSource and pooled ValueTasks?

    Expert

    IValueTaskSource is a low-level interface that allows reusable objects to back ValueTask instances, eliminating heap allocations in hot async paths. Pooled ValueTasks are created by implementing IValueTaskSource on objects retrieved from object pools, enabling frameworks like sockets and channels to handle millions of concurrent operations without garbage collection pressure. This is advanced infrastructure code rarely needed in application code.

    How it works

    ValueTask<T> is a discriminated union that wraps either a T result, an exception, or an IValueTaskSource<T> reference. When you return a ValueTask from an async method, it may allocate a state machine on the heap. However, if you explicitly construct a ValueTask from an IValueTaskSource<T>, the runtime stores only the interface reference—no additional allocation occurs.

    The pattern works like this: a pooled object implements IValueTaskSource<T>, stores completion state, and is returned to a pool when done. When a new operation begins, the object is retrieved from the pool, reinitialized, and handed to the caller as a ValueTask. The caller awaits it without knowing it's pooled. Once the operation completes, the object must be explicitly returned to the pool—there is no automatic cleanup.

    This approach trades complexity for allocation elimination. It's only worthwhile in extreme scenarios: millions of short-lived async operations per second, where even tiny allocations cause measurable GC pressure. Socket I/O and channel operations are prime examples because they process request/response pairs at machine scale.

    Example

    csharp
    public class PooledValueTaskSource : IValueTaskSource<int>
    {
        private int _result;
        private Exception _exception;
        private ManualResetValueTaskSourceCore<int> _core;
    
        public ValueTask<int> GetValueTask() => new ValueTask<int>(this, _core.Version);
    
        public int GetResult(short token)
        {
            if (_exception != null)
                throw _exception;
            return _result;
        }
    
        public ValueTaskSourceStatus GetStatus(short token) => _core.GetStatus(token);
    
        public void OnCompleted(Action<object> continuation, object state, short token, 
            ValueTaskSourceOnCompletedFlags flags) =>
            _core.OnCompleted(continuation, state, token, flags);
    
        public void SetResult(int result) 
        {
            _result = result;
            _core.SetResult(result);
        }
    
        public void SetException(Exception ex) 
        {
            _exception = ex;
            _core.SetException(ex);
        }
    
        public void Reset() 
        {
            _exception = null;
            _core.Reset();
        }
    }
    

    This pooled source wraps ManualResetValueTaskSourceCore<T>, which handles the state machine legwork. Callers await the returned ValueTask while the source is reset and reused from a pool.

    Key points to mention

    • IValueTaskSource<T> avoids heap allocation of the state machine; only the interface reference is stored in the ValueTask struct.
    • Pooled ValueTasks require manual lifecycle management—you must return the object to the pool after completion; the runtime provides no automatic cleanup.
    • ManualResetValueTaskSourceCore<T> provides most of the boilerplate; you implement GetResult() and manage your custom state.
    • Misuse is common: forgetting to reset state, double-awaiting, or pool corruption lead to subtle, hard-to-debug bugs.
    • This pattern appears in Kestrel, sockets, and channels; application code almost never needs it.

    Common follow-up questions

    • Why not use Task<T> pooling instead? Tasks don't implement IValueTaskSource; only ValueTask<T> can be backed by a pooled source. Tasks always allocate.
    • How do you debug pooled ValueTask corruption? Use versioning tokens to detect misuse, log lifecycle events, and use memory profilers to detect unexpected heap growth.
    • When should I use this in my application? Almost never. Measure GC impact first. If GC is truly a bottleneck after profiling, consider using libraries that already pool (e.g., System.IO.Pipelines) rather than implementing your own.

    Related interview questions

    Further reading

    .NETCsharpasyncPerformance
  239. 239How do you write a custom awaiter?

    Expert

    To write a custom awaiter, implement a public GetAwaiter() method on your type that returns an object implementing the awaiter pattern: a type with IsCompleted property, OnCompleted(Action) or UnsafeOnCompleted(Action) method, and GetResult() method. This allows you to await custom primitives and async workflows that don't inherit from Task or ValueTask.

    How it works

    When you write await x, the C# compiler looks for a GetAwaiter() method on type x. This method returns an awaiter object that controls the await semantics. The awaiter pattern is a compile-time protocol—not an interface—so you define the methods directly on your type.

    The compiler checks four things on the awaiter:

    1. IsCompleted property: A bool indicating whether the operation finished synchronously. If true, GetResult() is called immediately without suspending.
    2. OnCompleted or UnsafeOnCompleted: A method accepting an Action callback. Called if IsCompleted is false; the callback resumes the async method when the operation completes.
    3. GetResult(): Returns the result (or void) and propagates exceptions. Called after completion.

    This protocol decouples the await keyword from Task, enabling awaiting any custom computation pattern—timers, IO completions, manual state machines, or game loop frames.

    Example

    csharp
    public class DelayAwaiter
    {
        private readonly int _milliseconds;
        private bool _isCompleted;
    
        public DelayAwaiter(int milliseconds) => _milliseconds = milliseconds;
    
        public DelayAwaiter GetAwaiter() => this;
    
        public bool IsCompleted
        {
            get => _isCompleted;
            private set => _isCompleted = value;
        }
    
        public void OnCompleted(Action continuation)
        {
            var timer = new System.Timers.Timer(_milliseconds);
            timer.Elapsed += (_, _) =>
            {
                IsCompleted = true;
                continuation();
            };
            timer.Start();
        }
    
        public void GetResult() { }
    }
    
    // Usage
    async Task Example()
    {
        var delayer = new DelayAwaiter(1000);
        await delayer;
        Console.WriteLine("Done!");
    }
    

    This custom awaiter wraps a timer and allows awaiting its completion without using Task or Thread.Sleep.

    Key points to mention

    • Protocol, not interface: The awaiter pattern is structural (duck-typing); you don't implement an interface. This allows inline methods and struct awaiters for zero-allocation patterns.
    • UnsafeOnCompleted is preferable in libraries: It skips ExecutionContext flow for better performance, but OnCompleted is safer for user code. Both must be supported.
    • Return type flexibility: GetResult() can return any type—void, T, or a custom struct—allowing await to produce values without Task<T>.
    • Rarely needed in application code: Custom awaiters are mostly used in frameworks (async libraries, game engines, UI systems) and low-level performance optimization. Most apps use Task or ValueTask.
    • ConfigureAwait affects continuation: The compiler recognizes ConfigureAwait(bool) calls on awaiters; implement it to control ExecutionContext flow.

    Common follow-up questions

    • How does ValueTask differ from a custom awaiter? ValueTask<T> is a struct wrapper over Task<T> or a custom IValueTaskSource<T> that reduces allocations. It's the standard way to optimize Task overhead without writing raw awaiters.
    • Why not just use Task.Run or Task.Delay? Custom awaiters enable zero-allocation paths (struct awaiters), integration with non-Task async primitives (OS handles, event loops), and fine-grained control over continuation behavior—critical for high-performance libraries.
    • How do you handle exceptions in GetResult? Simply throw the exception from GetResult(); the await machinery propagates it to the async method's try-catch handlers automatically.

    Related interview questions

    Further reading

    .NETCsharpasync
  240. 240What are the memory model guarantees in .NET?

    Expert

    The .NET memory model guarantees that volatile reads/writes and locks establish acquire-release semantics, preventing the JIT and CPU from reordering memory operations across these barriers. Without synchronization, both the compiler and processor may reorder reads and writes to shared state, leading to visibility and ordering bugs in multi-threaded code.

    How it works

    The .NET memory model is built on two key concepts: visibility and ordering. Visibility ensures that changes made by one thread become visible to other threads; ordering ensures that operations happen in a predictable sequence relative to synchronization points.

    By default, the JIT compiler and modern CPUs use aggressive optimizations that reorder operations for performance. For example, a write to a non-volatile field might be delayed, or a read might be speculated before an earlier write completes. This is legal within a single thread (respecting data dependencies), but it breaks correctness in multi-threaded scenarios.

    The CLR provides two primary synchronization mechanisms:

    1. Volatile reads and writes: The volatile keyword creates an acquire barrier on reads and a release barrier on writes. A volatile read acts as an acquire fence (preventing subsequent operations from moving before it), and a volatile write acts as a release fence (preventing prior operations from moving after it).

    2. Locks (Monitor.Enter/Exit): Entering a lock acts as an acquire fence, and exiting acts as a release fence. This provides mutual exclusion plus memory ordering guarantees.

    The Interlocked class provides atomic operations with implicit memory barriers. Thread.MemoryBarrier() and Volatile.Read/Write offer explicit fine-grained control. The memory model guarantees that these operations will emit the appropriate CPU instructions (memory fences) to prevent harmful reordering.

    Example

    csharp
    public class MemoryModelDemo
    {
        private int _x = 0;
        private int _y = 0;
        private volatile bool _ready = false;
    
        public void Writer()
        {
            _x = 1;
            _y = 2;
            _ready = true;  // volatile write = release barrier
        }
    
        public void Reader()
        {
            if (_ready)  // volatile read = acquire barrier
            {
                // Guaranteed to see _x == 1 and _y == 2
                Console.WriteLine($"x={_x}, y={_y}");
            }
        }
    }
    

    The volatile write to _ready creates a release barrier, ensuring writes to _x and _y happen before the flag is set. The volatile read creates an acquire barrier, ensuring the check of _ready happens before reading _x and _y, guaranteeing visibility.

    Key points to mention

    • Volatility creates acquire-release semantics, not full barriers; subsequent operations can still reorder before a volatile read, and prior operations can reorder after a volatile write, as long as they don't violate data dependencies.
    • Locks provide the strongest guarantee: entering/exiting a lock is both an acquire and release operation, preventing all reordering across the critical section.
    • The memory model is platform-agnostic; it abstracts away CPU differences (x86, ARM, etc.) so code works correctly on any architecture.
    • Interlocked operations are faster than locks for simple state changes and provide full barriers by default.
    • Without synchronization, race conditions and memory visibility issues are undefined behavior; the JIT can make arbitrary assumptions.

    Common follow-up questions

    • What's the difference between a volatile read and a volatile write? A volatile read is an acquire operation (no subsequent ops reorder before it); a volatile write is a release operation (no prior ops reorder after it).
    • Why not just use volatile everywhere? Volatility has CPU overhead (memory fences). Use it only where needed; locks are clearer for protecting critical sections.
    • Does lock guarantee atomicity? No; lock prevents concurrent access, but operations inside are still individual. Combine with Interlocked for atomic operations like compare-and-swap.

    Related interview questions

    Further reading

    .NETCsharpPerformance
  241. 241When would you use Interlocked over lock?

    Expert

    Use Interlocked for lock-free atomic operations on single variables like counters or flags; use lock when you need to protect multiple fields that must change together as a logical unit. Interlocked avoids blocking and context switches, making it faster for simple operations, while lock provides mutual exclusion for complex critical sections.

    How it works

    Interlocked operations are atomic at the CPU level—they complete without interruption and don't require acquiring a monitor lock. The CPU's atomic instructions ensure that the operation is indivisible, and all threads see a consistent view of the value immediately after the operation completes. This eliminates the overhead of lock acquisition, waiter queues, and context switching.

    Lock, by contrast, serializes access to a code block by acquiring a monitor. When a thread holds a lock, others must wait in a queue, potentially triggering expensive context switches. Lock is necessary when your critical section involves multiple state changes that must appear atomic as a group—for example, updating both a balance and a transaction count must happen together, or neither at all.

    Interlocked methods work only on single reference or numeric types (int, long, double, object references). You cannot atomically update two fields with a single Interlocked call, which is why lock remains essential for complex multi-field invariants.

    Example

    csharp
    // Interlocked for simple counter – no lock needed
    public class RequestCounter
    {
        private long _count = 0;
    
        public void IncrementRequest()
        {
            Interlocked.Increment(ref _count);
        }
    
        public long GetCount() => Interlocked.Read(ref _count);
    }
    
    // Lock for multi-field invariant
    public class BankAccount
    {
        private decimal _balance = 1000m;
        private int _transactionCount = 0;
        private readonly object _syncLock = new();
    
        public void Transfer(decimal amount)
        {
            lock (_syncLock)
            {
                // Both fields must change together
                _balance -= amount;
                _transactionCount++;
            }
        }
    }
    

    The first example uses Interlocked.Increment on a single counter—fast and safe without lock overhead. The second requires lock because decrementing the balance and incrementing the transaction count must be atomic together; if only one succeeded, the account state would be corrupt.

    Key points to mention

    • Performance: Interlocked is significantly faster for simple operations because it avoids lock acquisition, waiter queue management, and context switches.
    • Scope: Interlocked works only on single variables (int, long, double, object refs); lock works on arbitrary critical sections with multiple fields.
    • Lock-free programming: Interlocked is part of a lock-free concurrency strategy, useful in high-contention scenarios where many threads compete for updates.
    • Use lock for invariants: When multiple fields must change atomically to maintain a valid state, lock is the right tool; Interlocked cannot enforce multi-field atomicity.
    • CPU atomicity: Interlocked leverages CPU-level atomic instructions (Compare-and-Swap, memory barriers); lock is a managed construct relying on the OS monitor.

    Common follow-up questions

    • Can Interlocked guarantee visibility across all threads? Yes; Interlocked operations include implicit memory barriers (acquire/release semantics) to ensure all threads see changes consistently.
    • What about Interlocked.CompareExchange for more complex updates? CompareExchange enables optimistic concurrency—retry logic—for single-field updates, but it still cannot atomically update multiple fields; for multi-field invariants, use lock or Monitor.
    • When would I use Interlocked over ReaderWriterLockSlim? Interlocked is for very simple, frequent updates; ReaderWriterLockSlim is for read-heavy workloads with occasional writes. If you only need a single counter, Interlocked is always faster.

    Related interview questions

    Further reading

    .NETCsharpasync
  242. 242What is false sharing?

    Expert

    False sharing occurs when multiple threads write to different variables that reside on the same CPU cache line, causing repeated cache invalidation and unnecessary coherency traffic. Even though the threads are working on logically independent data, the hardware cache system treats the entire line as a single unit, forcing expensive synchronization. This dramatically reduces performance despite zero logical contention.

    How it works

    Modern CPUs organize memory into cache lines (typically 64 bytes on x86-64). When a thread modifies a value, the entire cache line containing that value is invalidated across all other CPU caches. If two threads on different cores write to different variables within the same cache line, each write triggers a cache miss on the other core, forcing it to reload the line from L3 or main memory. This creates a false dependency that serializes access even though the data is unrelated.

    The effect is insidious: the code looks fine, has no locks or explicit synchronization, but exhibits pathological performance degradation under concurrent load. As you add more threads, performance often gets worse, not better.

    Example

    csharp
    // False sharing: two fields fit in one cache line
    public class BadCounter
    {
        public long Count1 = 0;
        public long Count2 = 0;
    }
    
    // Good: padding ensures each field is on its own cache line
    public class GoodCounter
    {
        public long Count1 = 0;
        [System.Runtime.InteropServices.FieldOffset(64)]
        public long Count2 = 0;
    }
    
    // Modern .NET approach: Interlocked or atomic operations
    public class ModernCounter
    {
        private long _count1;
        private long _count2;
        
        public void IncrementCount1() => Interlocked.Increment(ref _count1);
        public void IncrementCount2() => Interlocked.Increment(ref _count2);
    }
    
    // Benchmark
    var bad = new BadCounter();
    var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
    var t1 = Task.Run(() => 
    {
        while (!cts.Token.IsCancellationRequested)
            bad.Count1++;
    });
    var t2 = Task.Run(() =>
    {
        while (!cts.Token.IsCancellationRequested)
            bad.Count2++;
    });
    Task.WaitAll(t1, t2);
    // Results: millions of increments (false sharing kills perf)
    

    This example shows how two independent long fields can sit on the same 64-byte cache line. When thread 1 increments Count1 and thread 2 increments Count2, each write invalidates the other's cached copy, even though they never touch the same logical data.

    Key points to mention

    • Cache line size matters: x86-64 typically uses 64-byte lines; ARM may differ. Know your target platform.
    • Padding solutions: Add dummy fields or use FieldOffset to separate hot data, though this wastes memory.
    • Modern alternatives: Use Interlocked operations, lock, or lock-free data structures that account for cache layout (e.g., System.Collections.Concurrent).
    • Profiler detection: Cache-miss counters (perf, VTune, ETW) reveal false sharing; raw performance cliffs are often the first sign.
    • Trade-off: Padding wastes memory; synchronization adds latency. Choose based on contention level and workload.

    Common follow-up questions

    • How do you detect false sharing in production? Use performance counters (cache misses, L3 misses), profile with ETW or perf, and observe if throughput drops as thread count increases despite low lock contention.
    • What's the relationship between false sharing and lock contention? False sharing is not lock contention—there are no locks. It's a hardware artifact. However, both degrade concurrent performance, and thread pool starvation can mask the real cause in some scenarios.
    • Should I always pad my fields? Only if profiling shows cache-line contention. Padding wastes memory; modern workloads often benefit more from better algorithms or reducing lock time.

    Related interview questions

    Further reading

    .NETCsharpasync
  243. 243What is Span<T> and why can't it be stored on the heap?

    Expert

    Span<T> is a ref struct that provides a type-safe view over a contiguous block of memory—whether on the stack, heap, or unmanaged memory. It cannot be stored on the heap because it may reference stack memory, and allowing a heap reference to point to stack memory would violate memory safety: the stack frame could be deallocated while the heap object still holds a reference to it.

    How it works

    Span<T> is declared as a ref struct, a C# language feature that restricts it to stack-only storage. When you declare a Span<T> variable, it lives on the stack and holds two pieces of data: a reference (pointer) to the first element and a length. This allows it to represent memory ranges without allocating additional objects on the heap.

    The compiler enforces strict rules: a ref struct cannot be a field of a normal class, cannot be boxed, cannot be used as a generic type argument, and cannot be captured by lambdas or async methods. These restrictions exist because the .NET runtime cannot track when a ref struct's referenced memory becomes invalid. If you stored a Span<T> on the heap as a field, the heap object would outlive the stack frame containing the memory the Span points to, creating a use-after-free vulnerability.

    Consider the difference between stack and heap memory: stack memory is automatically freed when a method returns, but heap objects persist until garbage collection. A heap-resident Span<T> referencing stack memory could be accessed after that stack memory is reused or deallocated, leading to memory corruption.

    Example

    csharp
    public class Demo
    {
        // ❌ Compiler error: ref struct cannot be a field
        // private Span<int> _data;
    
        // ✅ Valid: Span used in a method (stack-only)
        public void ProcessData()
        {
            Span<int> buffer = stackalloc int[10];
            buffer[0] = 42;
            Console.WriteLine(buffer[0]); // 42
        }
    
        // ❌ Compiler error: ref struct cannot be returned
        // public Span<int> GetSpan()
        // {
        //     Span<int> temp = stackalloc int[5];
        //     return temp; // temp memory is freed when method returns
        // }
    
        // ✅ Valid: pass Span by reference
        public void FillBuffer(Span<int> buffer)
        {
            for (int i = 0; i < buffer.Length; i++)
            {
                buffer[i] = i * 2;
            }
        }
    }
    

    This code demonstrates that Span<T> works perfectly within method scope but cannot escape to the heap—the compiler prevents you from returning it or storing it as a class field.

    Key points to mention

    • ref struct restriction: Span<T> is declared ref struct, which means it can only live on the stack, never on the heap.
    • Memory safety: Stack memory is freed when its frame exits; if a heap object held a reference to it, the reference would dangle and cause crashes or security vulnerabilities.
    • Compiler enforcement: The C# compiler prevents Span<T> from being a class field, being boxed, or being captured in closures.
    • Performance benefit: Since Span<T> avoids heap allocation and GC pressure, it is ideal for performance-critical code like parsers and serializers.
    • Heap alternative: If you need a heap-resident memory view, use Memory<T> instead, which wraps heap memory safely.

    Common follow-up questions

    • Why use Span<T> instead of arrays or List<T>? Span<T> avoids allocation overhead, reduces garbage collection pressure, and works uniformly over stack memory, heap memory, and unmanaged buffers.
    • Can Span<T> reference heap memory? Yes, Span<T> can wrap array or managed heap data, but it still cannot itself be stored on the heap—it remains stack-only.
    • What about stackalloc? stackalloc allocates memory on the stack and returns a Span<T> pointing to it; this is safe only within the method scope because the stack memory is freed on method exit.

    Related interview questions

    .NETCsharpPerformance
  244. 244What is the difference between Span<T> and Memory<T>?

    Expert

    Memory<T> is a heap-allocated, reference-counted wrapper around contiguous memory that can be used across async/await boundaries and method returns, whereas Span<T> is a stack-only value type that provides zero-allocation access to contiguous memory but cannot cross await points or be stored in fields. In practice, Memory<T> produces a Span<T> via .Span property when you need the performance benefits within a synchronous context.

    How it works

    Span<T> is a ref struct—a stack-only type that holds a pointer and length. Because it lives only on the stack and cannot be boxed or stored in heap objects, it enables the JIT to make aggressive inlining and bounds-check elimination optimizations. This makes Span incredibly fast for synchronous scenarios like parsing or array slicing.

    Memory<T> is a regular struct that wraps an MemoryManager<T> (or ArrayPool-backed array) on the heap. Because it's heap-allocated, it can be stored in fields, passed across async boundaries, and used in collections. When you need the speed of Span within a synchronous block, you call memory.Span to get a Span view of that memory.

    Think of it this way: Memory<T> is the owner of memory (with a defined lifetime), and Span<T> is a temporary view into that memory. You can create many Spans from one Memory, but only within the scope where the Memory is alive.

    Example

    csharp
    // Memory<T> can be stored and used across async boundaries
    async Task ProcessDataAsync(Memory<byte> buffer)
    {
        // Within a synchronous context, convert to Span for performance
        Span<byte> span = buffer.Span;
        
        // Use span for fast, zero-allocation operations
        for (int i = 0; i < span.Length; i++)
        {
            span[i] = (byte)(span[i] * 2);
        }
        
        // Can await without Span (Memory is safe across await)
        await Task.Delay(100);
        
        // After await, can get a new Span view if needed
        span = buffer.Span;
    }
    
    // Span<T> cannot be stored in fields or used across await
    // This does NOT compile:
    // class BadDesign { Span<byte> field; }
    // async Task BadAsync(Span<byte> span) { await Task.Delay(100); }

    This example shows how ProcessDataAsync accepts a Memory<byte> (safe to pass to async), extracts a Span<byte> for efficient in-place operations, then awaits without the Span, and re-obtains a Span afterward if needed.

    Aspect Span<T> Memory<T>
    Storage Stack only (ref struct) Heap-allocated, reference counted
    Can cross async/await No—compiler error Yes—safe across awaits
    Can store in fields No—ref struct restriction Yes—regular struct
    Performance Highest—aggressive JIT optimizations Good—one extra indirection to Span
    Typical use Synchronous parsing, slicing, tight loops API boundaries, async methods, collections
    Access pattern Direct pointer + length Call .Span to get Span view
    What is the difference between Span<T> and Memory<T> diagram

    Key points to mention

    • ref struct restriction: Span cannot be boxed, stored in object fields, or captured by closures. This is by design to enable JIT optimizations.
    • Memory is the bridge: Use Memory<T> at API boundaries and async methods; convert to Span internally for performance.
    • Zero allocation: Span itself is zero-cost (just a pointer + length on the stack), but Memory may allocate or use pooled arrays underneath.
    • Lifetime safety: Memory has a well-defined lifetime; Span is a temporary view that must not outlive the underlying buffer.
    • ArrayPool integration: Memory<T> works seamlessly with MemoryPool<T> and ArrayPool for efficient buffer reuse.

    Common follow-up questions

    • Can you pass Span<T> to an async method? Why or why not?
    • How do you safely use Memory<T> with ArrayPool<T> to avoid buffer lifetime issues?
    • When would you choose Memory<T> over a simple array return type in an API?

    Related interview questions

    What is the difference between Span<T> and Memory<T>?
    .NETCsharpPerformance
  245. 245What is ArrayPool<T>?

    Expert

    ArrayPool<T> is a memory pooling mechanism in .NET that allows you to rent and return temporary arrays from a shared pool instead of allocating new arrays on the heap. This reduces garbage collection pressure and improves performance in scenarios where you frequently allocate short-lived arrays, particularly in high-throughput or latency-sensitive workloads.

    How it works

    ArrayPool<T> maintains a collection of pre-allocated arrays organized by size. When you call Rent(int minimumLength), the pool returns an array that is at least as large as requested—it may be larger. The pool reuses these arrays across multiple operations, avoiding repeated heap allocations. When you finish using an array, you call Return() to release it back to the pool for future use.

    Internally, ArrayPool uses a bucketing strategy where arrays are grouped by size ranges. If no suitable array exists in the pool, a new one is allocated and added. The pool is thread-safe and uses lock-free or lightweight synchronization to minimize contention. Importantly, returned arrays are not automatically cleared—the pool may retain them with their previous contents to avoid the cost of zeroing memory. This is a critical constraint for security-sensitive scenarios.

    There are two static instances: ArrayPool<T>.Shared (the default global pool) and custom pools created via Create(). The Shared pool is optimized for general-purpose use and is the recommended choice for most scenarios.

    Example

    csharp
    using System;
    using System.Buffers;
    
    class ArrayPoolDemo
    {
        static void Main()
        {
            // Rent an array from the shared pool
            byte[] buffer = ArrayPool<byte>.Shared.Rent(256);
            
            try
            {
                // Use the buffer
                Console.WriteLine($"Rented array length: {buffer.Length}");
                
                // Process data (e.g., read from a network stream)
                Array.Clear(buffer, 0, Math.Min(256, buffer.Length));
                ProcessData(buffer);
            }
            finally
            {
                // Always return to the pool, even if an exception occurs
                ArrayPool<byte>.Shared.Return(buffer, clearBuffer: true);
            }
        }
        
        static void ProcessData(byte[] data)
        {
            // Simulate work with the buffer
        }
    }
    

    This example rents a byte array from the shared pool, uses it for temporary work, and returns it in a try-finally block to ensure cleanup. The clearBuffer: true parameter overwrites the contents before returning to reduce information leakage.

    Key points to mention

    • Always return rented arrays: Use try-finally to guarantee Return() is called, or employ using statements with MemoryPool<T> for automatic cleanup.
    • Rented arrays may be larger than requested: Do not assume exact length; track the original length separately if needed.
    • Contents are not guaranteed to be cleared: Initialize or clear the buffer yourself before use if you require clean data, or pass clearBuffer: true on return for sensitive data.
    • Thread-safe but not array-thread-safe: The pool itself is thread-safe, but individual rented arrays must not be used by multiple threads simultaneously without synchronization.
    • Reduces GC pressure: Particularly valuable in latency-sensitive services where frequent allocations trigger full garbage collections.

    Common follow-up questions

    • When should I clear the buffer before returning? Always clear if the array contains sensitive data (passwords, encryption keys, PII). Use clearBuffer: true on Return() or manually call Array.Clear() beforehand.
    • How does ArrayPool differ from MemoryPool<T>? MemoryPool<T> is more abstraction-focused and returns Memory<T> handles that support automatic cleanup via IDisposable. ArrayPool is lower-level and more direct but requires manual lifetime management.
    • What is the performance overhead? Pooling is nearly free for the happy path (rent and return); the cost is proportional to how often pools must allocate new arrays and the size variance in your workload.

    Related interview questions

    .NETCsharpPerformance
  246. 246What is System.IO.Pipelines?

    Expert

    System.IO.Pipelines is a high-performance, low-allocation API for buffered I/O that manages memory pooling, backpressure, and graceful coordination between producers and consumers of data. It is the foundation that powers Kestrel's request pipeline and enables minimal-copy, efficient network stream parsing in ASP.NET Core.

    How it works

    System.IO.Pipelines abstracts the complexity of coordinating I/O producers and consumers through two primary types: PipeWriter and PipeReader. The writer produces data (e.g., from a socket), and the reader consumes it. Between them sits a Pipe—an internal ring buffer that automatically grows and shrinks, pooling memory from ArrayPool<byte> to eliminate allocation overhead.

    The key innovation is backpressure handling: if a consumer is slow, the pipe buffer fills up and signals the producer to pause. Similarly, a reader can request data in chunks and tell the pipe how much it has consumed, allowing the buffer to recycle memory. This coordinated flow prevents unbounded memory growth and allows both sides to work at their own pace without blocking or busy-waiting.

    Internally, the pipe uses a linked list of memory segments. When you call GetMemory(), you receive a slice of pooled memory; when you advance, that memory is marked as filled. The reader calls ReadAsync(), which waits until data is available, then provides access to filled segments without copying them. After reading, AdvanceTo() marks those bytes as consumed, enabling the pool to recycle the segment.

    Example

    csharp
    using System.IO.Pipelines;
    using System.Text;
    
    var pipe = new Pipe();
    
    // Producer: write data to the pipe
    _ = Task.Run(async () =>
    {
        var writer = pipe.Writer;
        var data = "Hello, Pipelines!"u8;
        await writer.WriteAsync(data);
        await writer.CompleteAsync();
    });
    
    // Consumer: read data from the pipe
    var reader = pipe.Reader;
    while (true)
    {
        var result = await reader.ReadAsync();
        var buffer = result.Buffer;
    
        if (buffer.Length > 0)
        {
            var text = Encoding.UTF8.GetString(buffer);
            Console.WriteLine(text);
        }
    
        reader.AdvanceTo(buffer.End);
    
        if (result.IsCompleted)
            break;
    }
    

    This example demonstrates a producer writing a string to a pipe and a consumer reading it without allocating intermediate buffers—the data moves directly from writer to reader via pooled memory segments.

    Key points to mention

    • Zero-copy efficiency: Data flows from producer to consumer without intermediate buffering or allocation; the reader directly accesses the writer's memory.
    • Backpressure built-in: The pipe naturally throttles producers when buffers fill, preventing runaway memory consumption in high-throughput scenarios.
    • ArrayPool integration: Memory is rented from and returned to ArrayPool<byte>, dramatically reducing GC pressure in long-running I/O workloads.
    • Async-friendly: Both reader and writer are ValueTask-based, allowing efficient awaiting without heap allocations.
    • Used by Kestrel: ASP.NET Core's HTTP server uses Pipelines for parsing HTTP headers and bodies, enabling thousands of concurrent connections.

    Common follow-up questions

    • How do you implement custom parsing logic with PipeReader (e.g., parsing HTTP headers frame-by-frame)?
    • What is the difference between Pipelines and Channels for producer-consumer coordination?
    • Why is AdvanceTo() critical, and what happens if you forget to call it?

    Related interview questions

    .NETCsharpaspnetcorePerformance
  247. 247What are ref returns and ref locals?

    Expert

    Ref returns and ref locals are language features that allow you to return or store a reference to an existing variable instead of copying its value. This enables direct modification of the original storage location, which is especially valuable for performance-critical code working with large structs or array elements. A ref return declares that a method returns a reference; a ref local is a variable that holds that reference.

    How it works

    Normally, when you return a value from a method or assign a variable, the CLR copies the data. For small types this is fine, but for large value types, copying becomes expensive. Ref returns and ref locals bypass this copy by providing direct access to the original memory location.

    A method declares a ref return by using the ref keyword before the return type. The caller then uses ref when receiving the return value to create a ref local. The ref local acts as an alias to the original storage—any modification through the alias modifies the actual variable.

    This works with fields, array elements, and local variables, but the compiler enforces safety: you cannot return a ref to a local variable from a method (it would outlive the variable), and you cannot return a ref to a temporary or expression result. Scoping rules ensure the referenced storage outlives the ref local.

    Example

    csharp
    public class RefExample
    {
        public struct Point { public int X; public int Y; }
        
        private Point[] points = new[] { new Point { X = 1, Y = 2 } };
        
        // Returns a ref to an array element
        public ref Point GetPointRef(int index)
        {
            return ref points[index];
        }
        
        public static void Main()
        {
            var example = new RefExample();
            
            // Create a ref local that aliases the array element
            ref Point p = ref example.GetPointRef(0);
            
            // Modify through the ref—modifies the original array element
            p.X = 100;
            p.Y = 200;
            
            // Verify the original was changed
            Console.WriteLine($"Array[0]: X={example.points[0].X}, Y={example.points[0].Y}");
            // Output: Array[0]: X=100, Y=200
        }
    }
    

    This code returns a reference to an array element and uses a ref local to modify it directly, avoiding the cost of copying a struct.

    Key points to mention

    • Performance benefit: Eliminates copying of large structs; critical in high-throughput scenarios like graphics, game engines, or numerical computing.
    • Scoping safety: The compiler prevents returning refs to locals or temporaries; the referenced storage must outlive the ref local.
    • Works with arrays and fields: You can return refs to array elements, struct fields, or instance fields—not just local variables.
    • Mutability: The ref itself is immutable (you cannot rebind it once assigned), but the storage it points to is mutable unless marked readonly or in a readonly struct.
    • Added in C# 7.0: Ref returns and locals require explicit opt-in and are less commonly used than regular returns, so code clarity is important.

    Common follow-up questions

    • Can you return a ref to a local variable? No. The compiler prevents it because the local is destroyed when the method returns, making the ref invalid (dangling reference).
    • What is the difference between ref and out? Both pass references, but out must be assigned before the method returns, while ref requires initialization by the caller and may or may not be reassigned.
    • Can you use ref with properties? Not directly in the traditional sense, but modern C# allows returning refs from property getters using expression-bodied members or ref-returning indexers.

    Related interview questions

    .NETCsharpPerformance
  248. 248What is stackalloc?

    Expert

    stackalloc is a C# keyword that allocates a fixed-size buffer directly on the stack rather than the heap, typically used with Span<T> to create temporary, high-performance data structures without triggering garbage collection. It's ideal for small, short-lived buffers in performance-critical code paths.

    How it works

    stackalloc reserves memory on the call stack at compile time for a fixed number of elements. Because the stack is contiguous, linear memory with automatic cleanup when the method returns, allocation is nearly free—just a pointer increment. This eliminates heap pressure and GC overhead entirely.

    The allocated memory is only valid within the current stack frame. Once the method exits, the memory is reclaimed automatically. This makes stackalloc unsuitable for data that must outlive the current function. The buffer size must be known at compile time (a constant), and in practice should remain very small—typically under 1 KB—to avoid stack overflow.

    stackalloc works seamlessly with Span<T>, which is a stack-safe wrapper around the allocated memory. The span can be passed to methods without boxing or additional allocation, maintaining the zero-heap-copy performance advantage throughout a call chain.

    Example

    csharp
    public void ProcessNumbers()
    {
        // Allocate space for 10 integers on the stack
        Span<int> buffer = stackalloc int[10];
        
        // Fill the buffer
        for (int i = 0; i < buffer.Length; i++)
        {
            buffer[i] = i * 2;
        }
        
        // Use the buffer—no heap allocation
        int sum = buffer.Sum();
        Console.WriteLine(sum); // Output: 90
    }
    
    // Example with a loop-local buffer for temporary work
    public string[] ParseData(string[] lines)
    {
        // Small scratch space for parsing
        Span<char> scratch = stackalloc char[256];
        
        var results = new List<string>();
        foreach (var line in lines)
        {
            // Parse using the stack buffer
            ProcessLine(line, scratch);
            results.Add(line.ToUpper());
        }
        
        return results.ToArray();
    }

    This example demonstrates allocating a 10-element integer buffer on the stack and using it directly without any heap allocation. The span wraps the stack memory safely and can be passed to LINQ methods or other functions.

    Key points to mention

    • Stack-only: Memory is freed automatically when the method returns; you cannot return a span pointing to stackalloc memory.
    • Fixed size: The array size must be a compile-time constant; you cannot use variables or dynamic sizes.
    • Zero allocation overhead: No GC pressure, extremely fast for temporary buffers in hot code paths.
    • Size discipline required: Stack space is limited (typically 1 MB per thread). Allocating too much causes a StackOverflowException.
    • Best paired with Span<T>: Using stackalloc directly with arrays is allowed but less idiomatic; spans are safer and more flexible.

    Common follow-up questions

    • Can I return a Span<T> pointing to stackalloc memory? — No, this is a compiler error. The span would reference freed stack memory (use Memory<T> if you need heap persistence).
    • How do I know if my stackalloc size is safe? — Keep it under 1 KB as a rule of thumb; use sizeof(T) * count to calculate bytes. Profile in your environment or use RuntimeHelpers.IsKnownConstant() for conditional logic.
    • What's the difference between stackalloc int[10] and new int[10]? — stackalloc allocates on the stack (no GC, automatic cleanup); new allocates on the heap (managed by GC, requires cleanup).

    Related interview questions

    .NETCsharpPerformance
  249. 249What is the in parameter modifier?

    Expert

    The in parameter modifier passes arguments by readonly reference, avoiding unnecessary copying of large structs while guaranteeing immutability within the method. It combines the performance benefits of passing by reference with safety guarantees, making it essential for high-performance code working with value types.

    How it works

    When you declare a parameter with the in modifier, the argument is passed by reference rather than by value. For value types, this eliminates the stack allocation and copying overhead that occurs with large structs. The key distinction from ref is that in enforces readonly semantics—you cannot modify the parameter inside the method, and the compiler prevents any defensive copies.

    The compiler treats in parameters as readonly from the caller's perspective and the method's perspective. This allows aggressive optimization because both sides know the value won't change. When combined with readonly structs, the runtime can pass the reference directly without creating defensive copies when accessing fields or calling methods.

    Without in, passing a large struct creates a copy on the stack. With in, only a reference is passed, reducing memory pressure and improving cache locality in performance-critical paths.

    Example

    csharp
    // Large readonly struct
    public readonly struct Vector3
    {
        public readonly double X, Y, Z;
        
        public Vector3(double x, double y, double z)
        {
            X = x;
            Y = y;
            Z = z;
        }
        
        public double Magnitude() => Math.Sqrt(X * X + Y * Y + Z * Z);
    }
    
    // Using in parameter
    public class Calculator
    {
        // Without in: struct copied to method stack
        public double LengthSlow(Vector3 v)
        {
            return v.Magnitude();
        }
        
        // With in: only reference passed, no copy
        public double LengthFast(in Vector3 v)
        {
            return v.Magnitude(); // No defensive copy
        }
    }
    
    // Usage
    var vec = new Vector3(3, 4, 0);
    var calc = new Calculator();
    var result = calc.LengthFast(in vec); // Explicitly pass by reference

    The in keyword prevents the 24-byte Vector3 struct from being copied onto the stack. The method receives only an 8-byte reference, improving performance significantly when called millions of times.

    Key points to mention

    • Performance optimization: in eliminates struct copying for value types, reducing memory allocation and GC pressure in tight loops.
    • Readonly guarantee: The compiler prevents modifications inside the method and at the call site, enabling aggressive inlining and optimization.
    • Defensive copy prevention: Used with readonly structs, in guarantees the runtime won't create hidden copies when accessing fields or calling methods.
    • Explicit at call site: Callers can use the in keyword explicitly at the call site (though it's often optional), making intent clear.
    • Interop limitations: in parameters require special marshaling support and may not work seamlessly with some P/Invoke or COM scenarios.

    Common follow-up questions

    • What's the difference between in and ref? in is readonly and prevents reassignment; ref allows modification. Use in for performance-critical reads; use ref when the method must modify the argument.
    • Should I always use in for large structs? Use in only if the struct is large enough that copying costs more than dereferencing (typically 16+ bytes), and pair it with readonly structs to avoid defensive copies.
    • Does in work with nullable reference types? in only applies to value types and ref structs; reference types are already passed by reference, making in redundant.

    Related interview questions

    .NETCsharpArchitecture
  250. 250What are readonly structs and why do they matter?

    Expert

    Readonly structs are value types declared with the readonly keyword, guaranteeing that all fields are immutable after construction. This allows the compiler to eliminate defensive copies when passing instances through in parameters or storing them in readonly fields, improving performance and reducing memory pressure.

    How it works

    When you declare a struct as readonly, the compiler enforces that every field is immutable. This is stronger than simply marking individual fields as readonly—the entire struct becomes immutable by contract.

    The performance benefit comes from how structs interact with in parameters. Normally, when you pass a struct by in, the runtime must create a defensive copy if the method might modify it or if the struct is passed to another method expecting a non-readonly reference. With a readonly struct, the compiler knows the struct cannot be modified, so it can pass the reference directly without the defensive copy overhead. This is especially valuable for large structs or hot paths called millions of times.

    Readonly structs also integrate with readonly fields and properties declared with init accessors, enabling functional programming patterns and thread-safe value semantics without synchronization.

    Example

    csharp
    public readonly struct Point
    {
        public int X { get; }
        public int Y { get; }
    
        public Point(int x, int y)
        {
            X = x;
            Y = y;
        }
    
        public readonly double DistanceFromOrigin()
            => Math.Sqrt(X * X + Y * Y);
    }
    
    // Usage
    Point p = new(10, 20);
    Console.WriteLine(DistanceHelper(in p)); // No defensive copy
    
    static double DistanceHelper(in Point pt)
        => pt.DistanceFromOrigin();
    

    The Point struct is declared readonly, so the compiler passes it by reference through the in parameter without creating a copy, even though the method returns a computed value.

    Key points to mention

    • Compiler-enforced immutability: All fields and auto-properties must be readonly; you cannot have mutable fields in a readonly struct.
    • Eliminates defensive copies: Combined with in parameters, readonly structs avoid the hidden copies the runtime inserts for mutable structs, reducing allocation and GC pressure.
    • Thread-safe by design: Immutability means instances are inherently thread-safe; no synchronization is needed.
    • Performance impact: The benefit is most pronounced for large structs (larger than a few pointer-sized values) passed frequently through in parameters or stored in readonly fields.
    • Requires discipline: You must design the struct to be meaningfully immutable; a readonly struct containing a mutable reference (like a List) is not truly safe.

    Common follow-up questions

    • What's the difference between readonly struct and a struct with all readonly fields? A readonly struct also forces methods to be implicitly readonly (cannot modify this), and it signals intent more clearly. A struct with all readonly fields can still have mutable methods that appear to modify this.
    • Can a readonly struct contain a mutable reference like a List? Yes, technically—but the struct itself doesn't enforce that the List isn't mutated. This breaks immutability semantics, so it's a code-smell and should be avoided.
    • How does readonly struct interact with readonly fields? Readonly fields complement readonly structs—use readonly on struct fields or readonly on the struct declaration itself to disable defensive copies.

    Related interview questions

    Further reading

    .NETCsharp
  251. 251What is tiered compilation?

    Expert

    Tiered compilation is a JIT optimization strategy introduced in .NET Core that compiles methods in two tiers: Tier 0 (quick, unoptimized) for fast startup, and Tier 1 (full optimizations) for frequently executed "hot" methods, balancing application startup time with steady-state performance.

    What is tiered compilation diagram

    How it works

    Tiered compilation addresses a fundamental trade-off in JIT compilation: spending time optimizing code improves runtime performance, but delays startup. The runtime tracks method call frequencies and recompiles hot methods with aggressive optimizations after they cross an invocation threshold.

    1. Tier 0 compilation: When a method is first called, the JIT compiles it quickly with minimal optimizations—just enough to be correct. This prioritizes startup speed over peak performance.
    2. Hot path detection: The runtime monitors method invocation counts. Methods called frequently are flagged as "hot."
    3. Tier 1 recompilation: Once a hot method reaches the threshold (typically thousands of calls), it is recompiled at Tier 1 with full optimizations: inlining, loop unrolling, SIMD vectorization, and other advanced techniques.
    4. Background compilation: Tier 1 recompilation happens on a background thread, so it doesn't block the application. The old Tier 0 code continues running until the optimized version is ready.
    5. Code replacement: When Tier 1 compilation completes, the runtime swaps the optimized code in place, and subsequent calls use the faster version.

    This two-stage approach allows applications to start quickly while still achieving near-peak performance in steady state—something that full upfront optimization cannot do.

    Example

    csharp
    public class TieredCompilationDemo
    {
        public static long ComputeSum(int iterations)
        {
            long sum = 0;
            // This method is called millions of times in the loop below
            // On first call: compiled at Tier 0 (quick, minimal optimization)
            // After ~30k calls: recompiled at Tier 1 (full optimization with inlining, loop unrolling, etc.)
            for (int i = 0; i < iterations; i++)
            {
                sum += Helper(i);
            }
            return sum;
        }
    
        private static long Helper(int value) => (long)value * value;
    
        static void Main()
        {
            var sw = System.Diagnostics.Stopwatch.StartNew();
            long result = 0;
            
            // Early calls use Tier 0; later calls use Tier 1 after recompilation
            for (int run = 0; run < 100; run++)
            {
                result = ComputeSum(10000);
            }
            
            sw.Stop();
            Console.WriteLine($"Result: {result}, Time: {sw.ElapsedMilliseconds}ms");
        }
    }
    

    This demo shows a tight loop where ComputeSum is invoked repeatedly; early runs execute Tier 0 code, and after the call count threshold is reached, the runtime recompiles with optimizations, causing later runs to execute noticeably faster.

    Key points to mention

    • Startup vs. throughput balance: Tier 0 gets the app running fast; Tier 1 ensures steady-state performance is excellent.
    • Threshold-based activation: Methods aren't recompiled automatically; only those called frequently enough cross the tier promotion threshold.
    • Background compilation: Tier 1 recompilation happens asynchronously, preventing startup stalls and ensuring responsiveness during optimization.
    • Configurable behavior: The tiered compilation strategy can be tuned via runtime configuration flags like TIERED_COMPILATION and TIER0_JITCOUNT_THRESHOLD.
    • Complementary to Native AOT: While tiered compilation optimizes JIT scenarios, Native AOT eliminates JIT altogether for maximum predictability and startup.

    Common follow-up questions

    • How does tiered compilation affect memory consumption? — The runtime keeps both Tier 0 and Tier 1 compiled versions in memory temporarily, increasing footprint slightly during recompilation, though the old code is eventually freed.
    • Can you disable tiered compilation? — Yes, set the environment variable TIERED_COMPILATION=0 to fall back to single-tier JIT, useful for debugging or when predictable performance is more important than throughput gains.
    • Does tiered compilation apply to all methods? — No; only methods meeting call-frequency thresholds are promoted to Tier 1. Rarely called methods stay at Tier 0 indefinitely to conserve CPU and memory.

    Related interview questions

    What is tiered compilation?
    .NETCsharpPerformance
  252. 252What is Dynamic PGO?

    Expert

    Dynamic PGO (Profile-Guided Optimization) is a runtime optimization technique that instruments Tier 0 JIT-compiled code to collect execution statistics, then uses that profiling data during Tier 1 compilation to apply aggressive optimizations like devirtualization, inlining, and code layout decisions. It has been enabled by default since .NET 8, significantly improving real-world application performance without requiring manual tuning.

    How it works

    Dynamic PGO operates within the .NET tiered compilation model. When code first runs, the Tier 0 JIT compiler generates quick, unoptimized code while instrumenting it to record runtime behavior—specifically which virtual method calls resolve to which concrete implementations, which branches execute frequently, and which functions are hot or cold. This profiling overhead is minimal and transparent to the application.

    As the same method executes repeatedly and reaches a configurable threshold (typically 10,000–30,000 calls), the runtime re-JITs it at Tier 1. During Tier 1 compilation, the JIT compiler consults the collected profile data and makes informed optimization decisions:

    • Devirtualization: If profiling shows a virtual call always resolves to one concrete type, the JIT generates a direct call with a guard, eliminating the virtual dispatch overhead.
    • Inlining: The JIT uses call frequency data to inline hot methods more aggressively.
    • Code layout: Hot and cold paths are organized in memory to improve CPU cache efficiency and branch prediction.
    • Speculative optimization: The JIT can generate code paths optimized for the most common observed case, with fallback paths for rare scenarios.

    This is "dynamic" because the profiling happens at runtime in production—no separate profiling run is needed, unlike traditional PGO in C++ with offline instrumentation.

    Example

    csharp
    
    public abstract class Animal
    {
        public abstract void Speak();
    }
    
    public class Dog : Animal
    {
        public override void Speak() => Console.WriteLine("Woof");
    }
    
    public class Cat : Animal
    {
        public override void Speak() => Console.WriteLine("Meow");
    }
    
    public class AnimalSpeaker
    {
        public void MakeSound(Animal animal)
        {
            // Virtual call—without PGO, always goes through dispatch
            // With Dynamic PGO: after profiling shows 99% are Dogs,
            // Tier 1 JIT devirtualizes this to a direct call with guard
            animal.Speak();
        }
    }
    
    // Usage
    var speaker = new AnimalSpeaker();
    for (int i = 0; i < 100000; i++)
    {
        speaker.MakeSound(new Dog()); // Hot path
    }
    // After 10k+ calls, Tier 1 re-JITs MakeSound with devirtualization
    

    This example shows a virtual method call that, when profiled, reveals it always (or almost always) calls Dog.Speak(). Dynamic PGO allows the Tier 1 JIT to emit direct code for the common case, with a type check to handle unexpected polymorphism.

    Key points to mention

    • Dynamic PGO is built into tiered compilation and requires no manual configuration or profiling runs—it profiles organically during normal execution.
    • It is enabled by default in .NET 8+ and can be disabled via TieredPGO runtime config property if needed for debugging.
    • The overhead of instrumentation is very low (<1–3% in most cases), making it practical for production code.
    • Polymorphism-heavy code (especially virtual calls and interface dispatch) sees the largest performance gains, often 5–20% improvements in throughput.
    • Works transparently with DATAS and other .NET 8+ optimization techniques to compound gains.

    Common follow-up questions

    • Can Dynamic PGO deoptimize if runtime behavior changes? Yes—if a previously monomorphic callsite suddenly receives a different type, the JIT may revert to the slower virtual dispatch path or invalidate and recompile.
    • How does this differ from traditional static PGO? Static PGO requires a separate instrumented build and training run; Dynamic PGO profiles organically in production with no build-time overhead.
    • Can you inspect PGO data or force Tier 1 promotion? The runtime collects this data internally; there is no public API to inspect or override it directly, but you can use ETW events and PerfView to observe JIT decisions.

    Related interview questions

    .NETCsharp
  253. 253What is ReadyToRun?

    Expert

    ReadyToRun (R2R) is a precompilation feature that converts .NET assemblies from IL (Intermediate Language) to native machine code at build time, dramatically improving cold startup performance. The runtime still uses JIT compilation for further optimization during execution. This makes R2R particularly valuable for containerized and serverless workloads where startup latency is critical.

    How it works

    ReadyToRun uses the .NET ahead-of-time (AOT) compiler, typically crossgen2, to precompile managed code into native code during the build process. The resulting assembly contains both the precompiled native code and the original IL as a fallback. When the application starts, the runtime loads the native code directly without waiting for JIT compilation, eliminating warm-up time. However, the JIT compiler still monitors execution and can optimize hot paths further based on runtime profiling data.

    The native code is platform-specific, so R2R binaries must be built for the target platform (Windows x64, Linux ARM64, etc.). The precompiled code includes metadata and relocation information to handle dynamically loaded assemblies and reflection scenarios. This hybrid approach balances startup speed with runtime optimization—you get instant code execution without sacrificing long-term performance tuning.

    Example

    csharp
    // Program.cs - a simple .NET 8 console app
    var sw = System.Diagnostics.Stopwatch.StartNew();
    
    // Simulate real work
    var result = Fibonacci(35);
    
    sw.Stop();
    Console.WriteLine($"Result: {result}, Time: {sw.ElapsedMilliseconds}ms");
    
    static int Fibonacci(int n) => n <= 1 ? n : Fibonacci(n - 1) + Fibonacci(n - 2);
    

    To publish with ReadyToRun enabled, use: dotnet publish -c Release -p:PublishReadyToRun=true. This creates a larger binary with precompiled native code, but the first run experiences minimal JIT delay since hot methods are already compiled.

    Key points to mention

    • Platform-specific: R2R binaries are compiled for a specific OS and architecture and cannot be ported directly between platforms.
    • Larger binary size: Precompiled native code increases assembly size (typically 20-40% larger), a trade-off for faster startup.
    • Hybrid compilation: Native precompiled code coexists with IL; the JIT still optimizes hot code at runtime for best long-term performance.
    • Build-time cost: Crossgen2 adds compilation overhead during the build process, increasing build times.
    • Fallback mechanism: If precompiled code cannot be used (e.g., unsupported scenarios), the runtime falls back to JIT, ensuring compatibility.

    Common follow-up questions

    • When should you use ReadyToRun vs. full AOT? Use R2R for general applications requiring fast startup without sacrificing JIT optimization; use full AOT only when startup is critical and binary size matters more than runtime performance.
    • Does ReadyToRun work with reflection and dynamic code? Most reflection is supported via IL fallback, but advanced dynamic scenarios may not benefit from precompilation and will revert to JIT.
    • What's the performance impact on container cold starts? R2R can reduce cold start by 30-50% depending on workload; this makes it ideal for serverless and Kubernetes environments where containers start frequently.

    Related interview questions

    Further reading

    .NETCsharpPerformance
  254. 254What is Native AOT?

    Expert

    Native AOT (Ahead-of-Time) compilation compiles your entire .NET application to a native executable at build time, eliminating the JIT (Just-In-Time) compiler and runtime. This produces a single, self-contained binary with dramatically faster startup time, lower memory footprint, and no dependency on the .NET runtime being installed.

    How it works

    Native AOT uses the CoreRT technology (evolved into the official Native AOT toolchain) to analyze your entire application at compile time. The process performs static analysis to determine which code paths are reachable, then compiles all necessary IL to native machine code. Unreachable code is trimmed away, resulting in a smaller executable. Because compilation happens before deployment, there's no warmup phase—the app runs at full performance immediately.

    The runtime is embedded into the binary, but only the portions your code actually needs. This is why IL (Intermediate Language) never executes; everything is pre-compiled to native CPU instructions. The trade-off is that dynamic features—reflection without metadata, late binding, and runtime code generation—become impossible or require explicit configuration through RootDescriptor XML files.

    Native AOT is particularly valuable for reducing cold start in containers and serverless scenarios, where startup latency and memory consumption directly impact cost and user experience.

    Example

    csharp
    // .csproj configuration for Native AOT
    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net8.0</TargetFramework>
        <PublishAot>true</PublishAot>
      </PropertyGroup>
    </Project>
    
    // Program.cs
    using System;
    
    class Program
    {
        static void Main()
        {
            Console.WriteLine("Hello from Native AOT!");
            var message = GetMessage("World");
            Console.WriteLine(message);
        }
    
        static string GetMessage(string name) => $"Hello, {name}!";
    }
    
    // Build with: dotnet publish -c Release -r win-x64
    // Produces: Program.exe (~10-20 MB, ~50 ms startup)

    This example shows the minimal configuration needed. When published with PublishAot=true, the entire application—including the trimmed runtime—becomes a standalone executable that starts in milliseconds.

    Key points to mention

    • No JIT overhead: Code is fully compiled before execution; no interpretation or runtime compilation delays.
    • Trim-safe code required: You must avoid dynamic reflection, Type.GetType() by string, and dynamic method invocation unless explicitly preserved via RootDescriptor.
    • Deployment flexibility: Single binary with embedded runtime; no .NET installation required on the target machine.
    • Framework support: ASP.NET Core, Console apps, and MAUI are officially supported; WinForms and WPF have limitations.
    • Build-time analysis constraints: The AOT compiler must conservatively assume which code might execute, which can lead to larger binaries if reflection usage is broad.

    Common follow-up questions

    • What are the trade-offs of Native AOT for ASP.NET Core?
    • How do you handle reflection and serialization in a Native AOT application? (Answer: Use source generators like System.Text.Json's JsonSourceGenerator, and mark reflective APIs with [DynamicallyAccessedMembers].)
    • Can you debug a Native AOT executable? (Answer: Limited debugging is possible with debug symbols, but the experience is not as rich as JIT debugging.)

    Related interview questions

    Further reading

    .NETCsharpPerformance
  255. 255What are the trade-offs of Native AOT for ASP.NET Core?

    Expert

    Native AOT for ASP.NET Core trades startup speed, low memory footprint, and smaller container images for the loss of runtime code generation, severely restricted reflection, and limited compatibility with libraries that depend on dynamic behavior. You gain predictability and containerization efficiency at the cost of flexibility and some ecosystem compatibility.

    How it works

    Native AOT compilation ahead-of-time converts your entire application—including the .NET runtime—into a single native executable at build time. The JIT compiler is eliminated entirely. For ASP.NET Core, this means your HTTP handlers, middleware, and service dependencies are all resolved and compiled before deployment. The runtime cannot emit IL or create types dynamically, so reflection-based registration and plugin systems must either be eliminated or use source-generated metadata.

    The process uses a trimmer to remove unused code and a static analyzer to warn about potential reflection failures. You lose features like MethodInfo.Invoke() on arbitrary types, Activator.CreateInstance() with runtime-determined types, and any library that introspects assemblies at startup. This is why System.Text.Json source generation becomes critical—serializers must know target types at compile time.

    Example

    csharp
    // Program.cs - Native AOT compatible
    var builder = WebApplication.CreateSlimBuilder(args);
    
    // ✓ This works: explicit registration
    builder.Services.AddSingleton<MyService>();
    builder.Services.ConfigureHttpJsonOptions(opts =>
    {
        opts.SerializerOptions.AddContext<JsonSerializerContext>();
    });
    
    var app = builder.Build();
    
    app.MapGet("/data", async (MyService svc) => 
        await svc.GetDataAsync());
    
    app.Run();
    
    // ✗ This FAILS with Native AOT:
    // var type = Type.GetType("Fully.Qualified.TypeName");
    // var instance = Activator.CreateInstance(type);
    
    [JsonSourceGenerationOptions(WriteIndented = true)]
    [JsonSerializable(typeof(DataModel))]
    internal partial class JsonSerializerContext 
        : JsonSerializerContext { }

    This example shows explicit service registration and source-generated JSON serialization—the patterns required for Native AOT. Removing the Activator.CreateInstance call is mandatory because the runtime cannot discover and instantiate types by name.

    Key points to mention

    • Startup and memory wins are substantial: Cold start can drop from 500ms to 50ms; memory footprint shrinks by 80%+. Ideal for serverless and microservices.
    • Reflection is severely constrained: You must explicitly declare what can be reflected on using DynamicallyAccessedMembers attributes and run static analysis warnings as errors during CI/CD.
    • Entity Framework Core has limited support: Only compiled queries and SqlQuery<T> work reliably; LINQ-to-Entities with dynamic predicates requires trimming annotations or switching to raw SQL.
    • Third-party library compatibility is uneven: Many DI containers, ORMs, and reflection-heavy libraries fail. You must test your entire dependency tree or maintain compatibility matrices.
    • Deployment is simpler but less flexible: A single self-contained binary is easier to containerize, but you cannot hotpatch or load plugins at runtime.

    Common follow-up questions

    • How do you handle configuration and environment-specific logic if reflection is blocked?
    • Can you use Entity Framework Core with Native AOT, and what are the limitations?
    • How do you diagnose and fix trim warnings in a large legacy codebase?

    Related interview questions

    Further reading

    .NETCsharpaspnetcorePerformance
  256. 256How does the JIT devirtualize calls?

    Expert

    JIT devirtualization is an optimization where the JIT compiler converts a virtual method call into a direct (non-virtual) call when it can prove or reliably predict the concrete type at runtime. This enables further optimizations like inlining, reducing dispatch overhead and improving performance significantly.

    How it works

    Virtual method calls normally require a runtime type check and indirection through a virtual method table (v-table) to locate the correct implementation. Devirtualization bypasses this by replacing the virtual dispatch with a direct call when the JIT has high confidence about which method will execute.

    The JIT uses several strategies to determine when devirtualization is safe:

    • Sealed types: If a class is sealed, it cannot be subclassed, so any virtual call on a sealed type's instance can be devirtualized immediately.
    • Profile-guided optimization (PGO): The runtime collects profiling data during execution showing which concrete types were observed at each call site. If one type dominates (e.g., 99% of calls), the JIT can devirtualize speculatively and add a guard that falls back if a different type appears.
    • Single concrete implementation: If the JIT can prove through static analysis that only one derived class implements a virtual method, it can devirtualize safely.
    • Readonly fields: If a field holding a polymorphic reference is readonly and initialized with a concrete type, the JIT knows its type never changes.

    Once devirtualized, the method becomes eligible for inlining, which is where the real performance gain occurs. Inlining eliminates the method call overhead entirely, allowing the JIT to optimize across method boundaries.

    Example

    csharp
    abstract class Animal
    {
        public abstract void Speak();
    }
    
    sealed class Dog : Animal
    {
        public override void Speak() => Console.WriteLine("Woof");
    }
    
    class Program
    {
        static void Main()
        {
            Animal dog = new Dog();
            for (int i = 0; i < 1_000_000; i++)
            {
                dog.Speak();  // JIT devirtualizes this to direct call
            }
        }
    }

    Because Dog is sealed, the JIT knows that dog.Speak() can only call Dog.Speak(), so it devirtualizes the call to a direct method invocation and likely inlines it, making the loop extremely fast.

    Key points to mention

    • Devirtualization requires the JIT to prove or guess the concrete type; sealed classes guarantee this statically.
    • PGO enables speculative devirtualization with guards—if the guard fails at runtime, execution falls back to a slower virtual dispatch path.
    • Devirtualization's main benefit is enabling inlining, not the elimination of the v-table lookup itself.
    • This optimization is particularly effective in loops and hot paths where method calls are frequent.
    • The JIT re-JITs code if profiling data changes, so a devirtualized call can be undone if new types appear at runtime.

    Common follow-up questions

    • How does PGO differ from static devirtualization, and when does the fallback guard execute?
    • Can you force or disable devirtualization, or does it happen automatically?
    • What's the relationship between devirtualization and CLR code generation—does it happen at JIT time or before?

    Related interview questions

    Further reading

    .NETCsharpPerformance
  257. 257What is inlining and how do you influence it?

    Expert

    Inlining is a JIT compilation optimization that replaces a method call with the method's body directly in the calling code, eliminating call overhead. You can hint at inlining using MethodImplOptions.AggressiveInlining, but the JIT compiler makes the final decision based on method size, complexity, and runtime conditions. It's most effective for small, frequently-called methods in hot paths.

    How it works

    When the JIT compiler encounters a method call, it evaluates whether inlining would be beneficial. Inlining removes the cost of a method invocation (pushing arguments, jumping to code, returning) by replacing the call with the actual method body. This is especially valuable in high-performance code where microseconds matter.

    The JIT uses heuristics to decide: methods must be reasonably small (typically under 32 IL instructions by default), not too complex, and called frequently enough to justify the code bloat. You cannot force inlining—the MethodImplOptions.AggressiveInlining attribute is only a hint that says "consider inlining even if this seems slightly larger than usual." Virtual methods, abstract methods, and methods with complex control flow are rarely inlined.

    Since JIT compilation happens at runtime, the JIT can inline based on actual runtime behavior, not just static analysis. This allows tiered compilation (.NET 5+) to make better inlining decisions as code executes.

    Example

    csharp
    using System.Runtime.CompilerServices;
    
    public class InliningDemo
    {
        // Without hint: JIT may inline (method is tiny)
        public int Add(int a, int b) => a + b;
        
        // With aggressive inlining hint: JIT is encouraged to inline
        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        public double Multiply(double a, double b) => a * b;
        
        // Will NOT inline: too complex, contains loops
        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        public int Sum(int[] numbers)
        {
            int total = 0;
            foreach (var n in numbers) total += n;
            return total;
        }
        
        public void Test()
        {
            // Hot path: called millions of times
            for (int i = 0; i < 1_000_000; i++)
            {
                var result = Add(i, 10);  // Likely inlined
                var scaled = Multiply(result, 2.5);  // Encouraged to inline
            }
        }
    }
    

    The Add and Multiply methods are likely inlined by the JIT because they're simple arithmetic operations; Sum will not be inlined despite the hint because it contains a loop and is too complex.

    Key points to mention

    • MethodImplOptions.AggressiveInlining is a hint, not a guarantee—the JIT makes the final call based on heuristics.
    • Inlining trades code size (bloat) for speed; excessive inlining can hurt cache locality and actually slow down code.
    • Small, hot-path methods (like getters, simple calculations) are best candidates; large or rarely-called methods should not be marked for aggressive inlining.
    • Virtual methods and interface calls are almost never inlined (except in tiered compilation with devirtualization).
    • Profiling and benchmarking with tools like BenchmarkDotNet are essential to verify inlining helps your scenario; never assume.

    Common follow-up questions

    • Can you force a method NOT to inline? Yes, use MethodImplOptions.NoInlining to prevent the JIT from inlining a method (useful for debugging or when inlining hurts performance).
    • Does AggressiveInlining work in Debug builds? No; optimizations including inlining are disabled in Debug. You must test in Release mode.
    • How does tiered compilation affect inlining? Tiered JIT (Tier 0 → Tier 1) allows the Tier 1 compiler to re-JIT hot methods with better inlining decisions based on runtime data.

    Related interview questions

    .NETCsharpPerformance
  258. 258How do you benchmark correctly in .NET?

    Expert

    Correct benchmarking in .NET requires BenchmarkDotNet, which automatically handles JIT warmup, tiered compilation, garbage collection, and statistical analysis across multiple iterations. Avoid naive Stopwatch-based measurements, which produce misleading results due to JIT compilation overhead, CPU throttling, and noise. Use realistic data, control variables, and isolate the code you're actually measuring.

    How it works

    BenchmarkDotNet runs your code multiple times in a dedicated process, discarding initial "warmup" iterations while the CLR JIT-compiles and optimizes. It then collects many measurements, removes outliers, and reports mean, median, and standard deviation—giving you statistically valid conclusions rather than single-run noise.

    Key mechanisms include:

    • Warmup iterations: The first runs are thrown away because JIT compilation and tiered compilation add overhead that doesn't reflect steady-state performance.
    • Multiple target runtimes: Run against .NET 6, 8, Framework simultaneously to compare behavior across platforms.
    • Garbage collection control: Force full GC between runs to measure cold-start performance, or disable it to measure steady-state.
    • Memory profiling: Automatically tracks allocations and Gen0/Gen1/Gen2 collections—critical for GC pressure analysis.
    • Statistical rigor: BenchmarkDotNet uses outlier detection and confidence intervals, not single measurements.

    Example

    csharp
    using BenchmarkDotNet.Attributes;
    using BenchmarkDotNet.Running;
    
    [MemoryDiagnoser]
    [SimpleJob(warmupCount: 3, targetCount: 5)]
    public class StringConcatBenchmark
    {
        private readonly string[] _words = { "Hello", "World", "From", "Benchmark" };
    
        [Benchmark(Baseline = true)]
        public string StringConcat()
        {
            string result = "";
            foreach (var word in _words)
                result += word;
            return result;
        }
    
        [Benchmark]
        public string StringBuilder()
        {
            var sb = new System.Text.StringBuilder();
            foreach (var word in _words)
                sb.Append(word);
            return sb.ToString();
        }
    
        [Benchmark]
        public string StringJoin()
        {
            return string.Join("", _words);
        }
    }
    
    class Program
    {
        static void Main(string[] args) => BenchmarkRunner.Run();
    }

    This compares three string concatenation approaches, automatically measuring execution time and allocations; [MemoryDiagnoser] shows heap pressure differences, and the baseline variant makes relative comparison clear.

    Key points to mention

    • Never use Stopwatch alone: A few milliseconds of measurement contains JIT overhead, context switches, and GC pauses—you need hundreds of samples to see the signal.
    • Test with realistic data: Benchmarking empty arrays or trivial inputs produces meaningless results; use data shapes your code actually encounters.
    • Beware of dead-code elimination: The JIT may optimize away your entire benchmark if results aren't used. BenchmarkDotNet prevents this; use Consume() or return values explicitly.
    • Run in Release mode only: Debug builds have different inlining, range checks, and optimizations that hide real performance.
    • Use [MemoryDiagnoser] to measure allocations: Throughput improvements mean little if you're triggering Gen2 collections; heap pressure often matters more than raw speed.

    Common follow-up questions

    • What's the difference between micro-benchmarks and macro-benchmarks? Micro-benchmarks isolate one operation in a tight loop (BenchmarkDotNet's domain); macro-benchmarks test end-to-end workflows and reveal real-world contention, memory fragmentation, and GC impact that micros hide.
    • How do you prevent the JIT from optimizing your benchmark away? Consume or return benchmark results, use Volatile.Read() for state, or declare variables volatile—BenchmarkDotNet handles this, but custom harnesses must be careful.
    • Why do results vary between runs even with BenchmarkDotNet? CPU frequency scaling, OS scheduling, thermal throttling, and NUMA effects still add noise; run on isolated machines in production environments for critical decisions.

    Related interview questions

    Further reading

    .NETCsharpPerformance
  259. 259How do you analyze allocations in a hot path?

    Expert

    To analyze allocations in a hot path, use BenchmarkDotNet with MemoryDiagnoser to measure heap allocations per iteration, dotnet-trace with allocation profiling events to capture real-world allocation patterns, or Visual Studio's memory profiler to inspect object retention and GC pressure. These tools pinpoint boxing, closures, LINQ materializations, and string concatenations that occur frequently in performance-critical code.

    How it works

    A hot path is code executed frequently—often in loops or per-request handlers. Even small allocations multiply: 1 KB per call × 1 million calls = 1 GB of garbage. Allocation analysis works by instrumenting the runtime to track heap object creation, then correlating allocations back to source code locations.

    BenchmarkDotNet's MemoryDiagnoser runs your code thousands of times in a controlled environment, collecting GC statistics and reporting Gen0, Gen1, Gen2 collections and total allocated bytes per operation. This reveals if a method allocates unexpectedly.

    dotnet-trace (command-line profiling) captures AllocationTick events at runtime, showing which call stacks allocate most frequently. You then load the trace in PerfView or Visual Studio to see allocation sources without modifying code.

    Visual Studio Profiler offers interactive memory snapshots: take a baseline, run your hot path, take another snapshot, and compare to see new objects created and their retention chains. This is useful for finding unintended object graphs.

    Common culprits in hot paths: boxing value types into collections, capturing loop variables in closures (creating new delegate instances), LINQ `.ToList()` or `.Where().Select()` chains that materialize intermediate collections, string interpolation, and implicit array allocations in params methods.

    Example

    csharp
    using BenchmarkDotNet.Attributes;
    using BenchmarkDotNet.Running;
    
    [MemoryDiagnoser]
    public class AllocationAnalysisBenchmark
    {
        private int[] _numbers = Enumerable.Range(1, 1000).ToArray();
    
        [Benchmark]
        public int BadAllocation()
        {
            // Allocates: new List (2x), closures for lambda captures
            return _numbers
                .Where(x => x > 500)
                .Select(x => x * 2)
                .Sum();
        }
    
        [Benchmark]
        public int GoodAllocation()
        {
            // No intermediate allocations; efficient loop
            int sum = 0;
            for (int i = 0; i < _numbers.Length; i++)
            {
                if (_numbers[i] > 500)
                    sum += _numbers[i] * 2;
            }
            return sum;
        }
    }
    
    // Output shows: BadAllocation ~5 KB allocated, GoodAllocation ~0 bytes.
    

    This benchmark reveals that LINQ chains allocate temporary enumerators and collections, while a manual loop avoids all allocations—critical knowledge for hot-path optimization.

    Key points to mention

    • BenchmarkDotNet is the gold standard for microbenchmarking allocations; MemoryDiagnoser integrates GC profiling directly into results.
    • dotnet-trace is production-friendly; capture allocation events from live servers with minimal overhead using `dotnet trace collect --providers Microsoft-DotNETRuntime:0x4000000000000014`.
    • Look for patterns: LINQ materializations, string building in loops, delegate/closure allocations, and hidden allocations like DateTime.Now or Guid.NewGuid().
    • Use struct enumerators and stackalloc to reduce heap pressure; `foreach` on arrays is allocation-free, but `.ToList().ForEach()` is not.
    • Measure before and after optimization; allocation reduction must correlate with actual performance gain (CPU time, throughput, GC pause duration).

    Common follow-up questions

    • "How do you identify which allocations matter most?" – Sort BenchmarkDotNet results by bytes allocated per iteration; focus on hot paths contributing >10% of total allocations. Use dotnet-trace call-stack analysis to weight by call frequency.
    • "Can you eliminate all allocations in a hot path?" – No; you'll always allocate method frames and return values. The goal is eliminating *intermediate* allocations (temporary lists, boxed values, delegate closures) that don't contribute to the output.
    • "What trade-offs exist between allocation reduction and code readability?" – A clean LINQ chain is more maintainable than hand-unrolled loops, but if profiling proves it's a bottleneck, refactor. Use `readonly struct` enumerators and ref parameters to balance both.

    Related interview questions

    Further reading

    .NETCsharpPerformance
  260. 260What are hidden allocations in C#?

    Expert

    Hidden allocations are unintended heap allocations that occur implicitly through language constructs, bypassing obvious memory cost. They commonly arise from closures capturing variables, boxing through interfaces, params arrays, LINQ iterators, string operations, and async state machines. Developers often miss these because they don't write explicit new keywords, making them invisible in code review but visible in profiler traces.

    How it works

    Hidden allocations occur because the C# compiler transforms certain language features into heap allocations to maintain closure semantics, type safety, or state management. When you use a local variable inside a nested function or lambda, the compiler creates a closure class on the heap to preserve that variable's lifetime beyond the method's stack frame. Similarly, boxing occurs when a struct is passed through an interface reference, forcing the value onto the heap. Params arrays create an array allocation for each call unless the compiler can optimize it away. LINQ query expressions generate iterator state machines that allocate when enumerated. String concatenation, especially in loops, creates intermediate string objects. Async state machines allocate heap memory to store local variables and continuation state when the method yields or awaits.

    These allocations are "hidden" because they're compiler-generated—the source code doesn't explicitly show memory allocation, yet the IL and resulting machine code performs heap work that triggers garbage collection pressure.

    Example

    csharp
    // Hidden allocation #1: Closure
    int value = 10;
    Func<int> closure = () => value + 5;  // Allocates closure class
    
    // Hidden allocation #2: Boxing
    interface IValue { int Get(); }
    struct Point : IValue { public int Get() => 42; }
    IValue boxed = new Point();  // Boxes Point to heap
    
    // Hidden allocation #3: Params array
    void Log(params string[] messages) { }
    Log("a", "b", "c");  // Allocates string[] array
    
    // Hidden allocation #4: LINQ iterator
    var query = Enumerable.Range(1, 1000000).Where(x => x % 2 == 0);
    foreach (var item in query) { }  // Iterator state machine allocated per enumeration
    
    // Hidden allocation #5: String concatenation
    string result = "";
    for (int i = 0; i < 100; i++)
        result += i.ToString();  // Allocates 100 intermediate strings
    

    Each line shows a common hidden allocation: closures capture variables by allocating a container class; boxing wraps structs in objects; params creates arrays; LINQ generates iterator state machines; and string concatenation creates temporary strings instead of a single result.

    Key points to mention

    • Closures allocate a compiler-generated class to hold captured variables, keeping them alive on the heap.
    • Boxing wraps value types in heap-allocated objects when cast to interfaces or object, violating value semantics.
    • Params arrays allocate an array for each call unless the compiler optimizes to a single literal argument.
    • LINQ iterators allocate state machines per enumeration; materializing with .ToList() or .ToArray() is often necessary.
    • String operations and async state machines silently allocate intermediate objects; use StringBuilder and ValueTask to reduce allocation pressure.

    Common follow-up questions

    Related interview questions

    Further reading

    .NETCsharpPerformance
  261. 261What is frozen collections in .NET 8?

    Expert

    Frozen collections in .NET 8 are immutable, thread-safe data structures optimized for read-heavy scenarios. FrozenDictionary and FrozenSet are built using specialized internal data structures that trade construction time for dramatically faster lookups, making them ideal when you create a collection once and read it many times. They're part of the System.Collections.Frozen namespace and represent a strategic choice for performance-critical read paths.

    How it works

    Frozen collections use a two-phase optimization strategy. During construction, they spend extra CPU cycles analyzing the data—computing optimal hash distributions, building specialized index structures, and organizing elements in memory-friendly layouts. This upfront investment pays dividends during the read phase, where lookups benefit from better cache locality, fewer hash collisions, and optimized branching paths.

    Unlike regular Dictionary, which uses a dynamic hash table that can resize, frozen collections are immutable after creation. This immutability enables aggressive optimizations: the runtime knows the data will never change, so it can precompute optimal memory layouts, remove bounds-checking overhead in hot paths, and make aggressive JIT inlining decisions.

    FrozenDictionary uses a specialized hash algorithm and memory layout designed to minimize cache misses during lookups. FrozenSet similarly optimizes for O(1) membership testing. Both collections are fully thread-safe without locks because their contents never change.

    Example

    csharp
    using System.Collections.Frozen;
    
    // Regular dictionary - flexible, mutable
    var regularDict = new Dictionary
    {
        { "apple", 10 },
        { "banana", 20 },
        { "cherry", 30 }
    };
    
    // Frozen dictionary - immutable, optimized for reads
    var frozenDict = regularDict.ToFrozenDictionary();
    
    // Or create directly
    var frozenSet = new[] { "admin", "user", "guest" }
        .ToFrozenSet();
    
    // Lookups are faster than regular collections
    if (frozenSet.Contains("admin"))
    {
        Console.WriteLine("Permission granted");
    }
    
    // This throws - frozen collections are immutable
    // frozenDict["orange"] = 40; // Compile error
    

    This example shows converting a regular dictionary to frozen form, then demonstrates the immutable nature and O(1) lookup performance frozen collections provide for permission checks and configuration lookups.

    Key points to mention

    • Immutable and thread-safe: No locks needed; perfect for shared configuration or permission sets accessed by multiple threads
    • Construction vs. lookup trade-off: Slower creation time is offset by significantly faster reads, especially beneficial when the ratio of reads to writes is very high
    • Memory optimized: Frozen collections use specialized data structures with better cache locality and reduced allocations during lookups
    • No LINQ mutations: Operations like .Where() or .OrderBy() return regular collections, not frozen ones, maintaining predictable performance characteristics
    • Ideal for configuration, permissions, and lookup tables: Common use cases where you load data once at startup and read it throughout the application lifetime

    Common follow-up questions

    • When should I use frozen collections vs. regular Dictionary? Use frozen collections when your read-to-write ratio is high (10:1 or greater) and the collection is shared across multiple threads or hot paths. For mutable collections, stick with Dictionary.
    • Do frozen collections support LINQ? Yes, but LINQ operations return new regular collections. If you need filtered results, consider whether you should freeze the filtered result separately or use a smaller frozen collection for that subset.
    • How do frozen collections compare to concurrent dictionaries? ConcurrentDictionary handles concurrent mutations safely; frozen collections optimize read throughput by eliminating locking entirely. Choose based on whether your data changes—if it doesn't, frozen is faster.

    Related interview questions

    .NETCsharp
  262. 262What is SearchValues<T>?

    Expert

    SearchValues<T> is a .NET 8+ type that precomputes an optimized, vectorized search structure for a set of characters or bytes, dramatically accelerating IndexOfAny-style queries. Instead of checking each search character one-by-one, it uses SIMD operations and specialized algorithms to find any member of the set in O(n) time with minimal per-character overhead.

    How it works

    SearchValues<T> is a readonly struct that accepts either char or byte and pre-analyzes the search set at construction time. Depending on the set size and characteristics, it selects an optimal strategy:

    • Bitmap/lookup table: For small sets (typically ≤ 16 members), it may use a compact bit array or jump table.
    • SIMD vectorization: For larger or diverse sets, it leverages SIMD and Vector<T> to compare multiple characters or bytes in parallel across 128-bit or 256-bit registers.
    • Probabilistic filters: It may combine techniques like Bloom filters or specialized bit patterns to reduce false positives and branch mispredictions.

    Once created, you call instance methods like Contains(), IndexOfAny(), or LastIndexOfAny() on your span/string, passing the SearchValues<T> object. The runtime then executes the precomputed vectorized logic, avoiding repeated branching and character-by-character comparisons.

    Example

    csharp
    using System;
    using System.Buffers;
    
    class Program
    {
        static void Main()
        {
            // Create a SearchValues instance for vowels (precomputed once)
            var vowels = SearchValues.Create("aeiouAEIOU");
            
            string text = "The quick brown fox jumps over the lazy dog";
            Span<char> span = text.AsSpan();
            
            // Vectorized search finds first vowel efficiently
            int index = span.IndexOfAny(vowels);
            Console.WriteLine($"First vowel at index: {index} = '{text[index]}'"); // 2, 'e'
            
            // Also works with bytes for low-level parsing
            var delimiters = SearchValues.Create(stackalloc byte[] { (byte)',', (byte);, (byte)':' });
            ReadOnlySpan<byte> data = "foo,bar;baz:qux"u8;
            int delimIndex = data.IndexOfAny(delimiters);
            Console.WriteLine($"First delimiter at: {delimIndex}"); // 3
        }
    }
    

    This example creates a reusable SearchValues<char> for vowels, then uses it to search a string span using optimized SIMD logic, returning the index of the first match far faster than a naive loop.

    Key points to mention

    • SearchValues<T> is immutable and thread-safe, so you should create it once and reuse it across many searches.
    • It only accepts char or byte; you cannot use it for arbitrary types.
    • Performance gains are most pronounced on longer strings or repeated searches; for tiny strings or single-use lookups, overhead may diminish the benefit.
    • It integrates seamlessly with Span<T> and ReadOnlySpan<T>, making it ideal for high-performance parsers, tokenizers, and validators.
    • Under the hood, it may use different strategies (bitmap, SIMD, or hybrid) depending on the set size and CPU capabilities at runtime.

    Common follow-up questions

    • When should I use SearchValues vs. a HashSet<char>?
      SearchValues<T> is optimized for sequential searching in spans; HashSet<char> is for membership tests. For string parsing, SearchValues is faster and more cache-friendly.
    • Can I use SearchValues with custom types?
      No, it only supports char and byte. For other types, you'd use alternative patterns like HashSet<T> or a custom trie structure.
    • Is SearchValues replacement for string.IndexOfAny(params char[])?
      Yes, it's the modern, optimized successor. string.IndexOfAny(params char[]) creates a temporary array; SearchValues precomputes once and reuses, eliminating allocation overhead.

    Related interview questions

    .NETCsharp
  263. 263What is SIMD and Vector<T>?

    Expert

    SIMD (Single Instruction Multiple Data) is a CPU feature that processes multiple values simultaneously in a single instruction. Vector<T> and the newer Vector128/256/512 types provide .NET APIs to leverage this hardware acceleration for high-performance numeric and text operations. When properly used, SIMD can deliver 2–16x speedups for data-parallel workloads without explicit parallelization.

    How it works

    Traditional scalar operations process one value per CPU cycle. SIMD widens the data path: a single instruction operates on a vector of values packed into a wide register (128, 256, or 512 bits). For example, adding two Vector256<int> instances adds eight 32-bit integers in one instruction.

    The Vector<T> type is a cross-platform abstraction that maps to the widest SIMD register available on the host CPU at runtime—typically 128 bits on older hardware, 256 bits on modern x86-64, and 128 bits on ARM. The concrete types Vector128<T>, Vector256<T>, and Vector512<T> (added in .NET 8) give fine-grained control when you need predictable vector widths or advanced CPU features (AVX-512).

    SIMD benefits are greatest for:

    • Dense numeric computations: summing arrays, matrix operations, image processing.
    • Vectorizable loops with no data dependencies between iterations.
    • Bulk text or byte operations: UTF-8 validation, compression, encryption.

    Performance gains diminish or vanish if the JIT cannot vectorize your loop, if cache misses dominate, or if overhead from setting up vectors exceeds the savings.

    Example

    csharp
    using System.Numerics;
    
    // Sum an array of integers using Vector<int>
    public static int VectorSum(int[] data)
    {
        var vectorSize = Vector<int>.Count; // e.g., 8 on AVX2
        var sum = Vector<int>.Zero;
        
        // Process multiple elements per iteration
        int i;
        for (i = 0; i <= data.Length - vectorSize; i += vectorSize)
        {
            var chunk = new Vector<int>(data, i);
            sum += chunk;
        }
        
        // Horizontal sum: add all elements in the vector
        int result = 0;
        for (int j = 0; j < vectorSize; j++)
            result += sum[j];
        
        // Handle remainder
        for (; i < data.Length; i++)
            result += data[i];
        
        return result;
    }
    

    This loop processes Vector<int>.Count elements (typically 8 on x64) per iteration, then horizontally reduces the vector to a scalar sum. The same scalar loop must clean up any remainder that doesn't fit evenly into vector width.

    Key points to mention

    • Hardware abstraction: Vector<T> adapts to the CPU's capabilities; Vector128/256/512 give you explicit control for portable or intrinsic-heavy code.
    • JIT support: The RyuJIT compiler can auto-vectorize simple loops, but explicit Vector<T> usage is more reliable and predictable.
    • Alignment and memory: SIMD performance depends on aligned memory access; unaligned loads incur penalties.
    • Not a silver bullet: Overhead, cache effects, and loop structure determine whether SIMD actually helps. Always profile.
    • Use cases: Image processing, DSP, ML inference, UTF-8 validation, and crypto are common beneficiaries; compare with Parallel.ForEach for coarser parallelism.

    Common follow-up questions

    • How do you check if SIMD is available on the runtime, and what fallbacks do you use?
    • What is the difference between Vector<T> and Vector128/256/512, and when would you use each?
    • How do you measure SIMD performance gains, and what gotchas lead to slower code?

    Related interview questions

    .NETCsharpPerformance
  264. 264How does the Kestrel request pipeline achieve high performance?

    Expert

    Kestrel achieves high performance through System.IO.Pipelines for zero-copy streaming, memory pooling to eliminate GC pressure, and hand-optimized HTTP parsing that minimizes allocations. By using transport abstractions over raw sockets and processing requests in a non-blocking, asynchronous pipeline, Kestrel can handle hundreds of thousands of concurrent connections efficiently.

    How does the Kestrel request pipeline achieve high performance diagram

    How it works

    Kestrel's request pipeline is built on System.IO.Pipelines, which provides a structured way to read, parse, and buffer data without copying bytes multiple times. Instead of allocating new buffers for each request, Kestrel uses ArrayPool<byte> to rent and return buffers, dramatically reducing garbage collection overhead. The transport layer abstracts away socket I/O details, allowing Kestrel to work with sockets, Unix domain sockets, or even in-memory pipes uniformly.

    The pipeline flow works like this: incoming bytes are read into pooled buffers, the parser examines them without copying, identifying headers and body boundaries. Request objects are created with references (not copies) to the buffer data. Only when the application explicitly reads headers or body content are those portions extracted. This lazy-evaluation approach, combined with span-based APIs (Span<T>, Memory<T>), ensures minimal allocations during normal operation.

    Kestrel also employs several micro-optimizations: HTTP header parsing uses state machines to avoid regex or substring operations; pipelined requests are handled in batches; and connection keep-alive is efficiently managed through scalable I/O completion ports (Windows) or epoll/kqueue (Linux). The transport layer is fully asynchronous, allowing a single thread to service thousands of sockets via OS-level event notifications rather than blocking threads.

    Example

    csharp
    // Simplified view of Kestrel's pipeline approach
    public class PipelineProcessor
    {
        private readonly PipeReader _pipeReader;
        private readonly ArrayPool<byte> _pool = ArrayPool<byte>.Shared;
    
        public async Task ProcessRequestsAsync()
        {
            while (true)
            {
                // Read without allocating new buffers
                ReadResult result = await _pipeReader.ReadAsync();
                ReadOnlySequence<byte> buffer = result.Buffer;
    
                // Parse HTTP headers using Span<T> (zero-copy)
                if (TryParseHttpRequest(buffer, out var headers, out var consumed))
                {
                    // Process headers without copying
                    foreach (var header in headers)
                    {
                        Console.WriteLine($"{header.Name}: {header.Value}");
                    }
    
                    _pipeReader.AdvanceTo(consumed);
                }
    
                if (result.IsCompleted) break;
            }
    
            await _pipeReader.CompleteAsync();
        }
    
        private bool TryParseHttpRequest(ReadOnlySequence<byte> buffer, 
            out List<(string Name, string Value)> headers, out SequencePosition consumed)
        {
            headers = new();
            consumed = buffer.Start;
            // State machine-based parsing (simplified)
            return true;
        }
    }
    

    This demonstrates how Kestrel uses PipeReader and span-based APIs to parse HTTP requests without allocating intermediate buffers or copying data.

    Key points to mention

    • System.IO.Pipelines: Provides a producer/consumer pattern optimized for I/O, eliminating the need to copy data between buffers.
    • ArrayPool<byte>: Pooled buffers are rented and returned, keeping allocation rates low and reducing GC pause times.
    • Span<T> and Memory<T>: Stack-allocated references enable parsing and header access without heap allocations.
    • Async/await throughout: Non-blocking I/O means threads are not consumed by individual connections; completion ports or epoll handle thousands per thread.
    • Transport abstraction: Sockets, Unix sockets, and in-memory pipes are handled uniformly, allowing optimization at the lowest level.

    Common follow-up questions

    • How does Kestrel handle backpressure when requests arrive faster than the application can process them? — The PipeReader pauses reading from the socket and buffers data in memory up to a configured limit; beyond that, OS-level backpressure kicks in.
    • What happens if the application doesn't read request body content promptly? — Kestrel keeps the buffer alive and may slow down further socket reads; explicit consumption is required to free pooled buffers.
    • How does Kestrel compare to libuv-based servers? — Kestrel removed libuv as a transport and now uses platform-native APIs (IOCP, epoll/kqueue), simplifying the stack and reducing latency.

    Main implementation steps

    1. Transport layer initialization: Kestrel accepts incoming connections via OS-level asynchronous I/O (IOCP on Windows, epoll/kqueue on Linux).
    2. Pipe creation: For each connection, a PipeReader and PipeWriter are created backed by pooled buffers from ArrayPool<byte>.
    3. Non-blocking read: Data is read into the pipe without blocking threads; thousands of connections can be serviced by a thread pool.
    4. State machine parsing: The HTTP parser uses a hand-optimized finite state machine to identify request lines, headers, and body boundaries using Span<T> without copying.
    5. Request object creation: A lightweight HttpRequest is created with references to buffer regions; body reading is deferred until the application accesses it.
    6. Response writing: The application writes to PipeWriter, which batches writes and flushes efficiently to the socket.
    7. Buffer recycling: After the response is sent, all pooled buffers are returned to the pool for reuse, minimizing allocations over time.

    Related interview questions

    Further reading

    How does the Kestrel request pipeline achieve high performance?
    .NETCsharpaspnetcorePerformance
  265. 265How do you tune Kestrel for production?

    Expert

    Kestrel tuning for production involves configuring connection limits, request body/header size thresholds, enabling HTTP/2 and HTTP/3 protocols, and deploying behind a reverse proxy (like Nginx or IIS) for TLS termination and load balancing. The goal is to maximize throughput, prevent resource exhaustion, and ensure security without sacrificing performance.

    How it works

    Kestrel is ASP.NET Core's high-performance web server, but it ships with conservative defaults to prevent abuse. Production tuning requires understanding the trade-offs between resource consumption, connection handling, and protocol features.

    Connection limits control how many simultaneous TCP connections Kestrel will accept. Too low and you reject valid clients; too high and the server exhausts memory and file descriptors. Request body and header size limits protect against malicious or misconfigured clients sending massive payloads that could cause denial-of-service.

    Protocol optimization involves enabling HTTP/2 (multiplexing over a single connection, reducing overhead) and HTTP/3 (QUIC protocol for faster handshakes and better mobile performance). TLS offloading to a reverse proxy removes CPU-intensive encryption work from Kestrel, allowing it to focus on business logic. This is especially important under high load.

    You also configure Kestrel's buffering behavior, thread pool sizes, and keep-alive timeouts to match your infrastructure and expected traffic patterns. Monitoring connection counts, request queues, and memory usage ensures your settings remain appropriate as load changes.

    Example

    csharp
    var builder = WebApplication.CreateBuilder(args);
    
    builder.WebHost.ConfigureKestrel(options =>
    {
        // Set global connection limits
        options.Limits.MaxConcurrentConnections = 5000;
        options.Limits.MaxConcurrentUpgradedConnections = 1000;
        
        // Request body and header limits
        options.Limits.MaxRequestBodySize = 50 * 1024 * 1024; // 50 MB
        options.Limits.MaxRequestHeadersTotalSize = 32 * 1024; // 32 KB
        
        // HTTP/2 settings
        options.Limits.Http2.MaxStreamsPerConnection = 100;
        options.Limits.Http2.InitialConnectionWindowSize = 65535;
        
        // Keep-alive timeout
        options.Limits.KeepAliveTimeout = TimeSpan.FromSeconds(30);
        
        // Listen on HTTPS with HTTP/2 and HTTP/3
        options.ListenAnyIP(5000, listenOptions =>
        {
            listenOptions.Protocols = HttpProtocols.Http1AndHttp2AndHttp3;
            listenOptions.UseHttps("path/to/cert.pfx", "password");
        });
    });
    
    var app = builder.Build();
    app.Run();
    

    This configuration sets connection limits to prevent resource exhaustion, restricts request sizes to block large payloads, enables HTTP/2 and HTTP/3 for modern clients, and enforces reasonable keep-alive timeouts.

    Key points to mention

    • Always deploy Kestrel behind a reverse proxy (Nginx, Apache, IIS) in production—it provides TLS termination, load balancing, and a security boundary.
    • Set MaxConcurrentConnections based on your infrastructure's file descriptor limits and available memory; typical values are 1000–10,000 depending on traffic.
    • Monitor connection metrics via IConnectionStatisticsFeature and application insights to catch bottlenecks before they impact users.
    • HTTP/2 is almost always beneficial; enable HTTP/3 if your infrastructure and clients support it (it requires QUIC support on your network).
    • Set KeepAliveTimeout conservatively (20–30 seconds) to avoid resource leaks from hung connections; use RequestHeadersTimeout to detect slow clients early.

    Common follow-up questions

    • How do you size and limit memory for .NET in Kubernetes? — Tuning Kestrel works hand-in-hand with container memory limits and GC settings.
    • What's the difference between HTTP/1.1, HTTP/2, and HTTP/3, and when should you use each? — HTTP/2 is default for most modern systems; HTTP/3 is newer and requires QUIC support; HTTP/1.1 is kept for legacy clients.
    • How do you diagnose a Kestrel performance bottleneck? — Use ETW tracing, perf counters, and connection statistics to identify whether the issue is CPU, memory, connection limits, or application code.

    Related interview questions

    .NETCsharpaspnetcore
  266. 266How does routing match endpoints efficiently?

    Expert

    Endpoint routing in ASP.NET Core uses a Deterministic Finite Automaton (DFA) matcher built at startup from all route templates to efficiently match URLs to endpoints before middleware executes. This matcher runs constraints and selectors in a single pass, eliminating redundant comparisons and enabling features like authorization to run with full endpoint knowledge.

    How it works

    When you configure endpoints—whether via MapGet, MapPost, or attribute routing—ASP.NET Core compiles all route templates into a single DFA-based state machine during app startup. This matcher is deterministic and fast because it pre-processes all possible routes into an optimized graph structure.

    During request handling, the routing system takes the incoming URL path and segments it, then traverses the DFA tree by matching literal segments and parameters. Constraints (like regex patterns, HTTP methods, or custom validators) are evaluated during traversal, not after. Once an endpoint is matched, it is attached to the HttpContext as HttpContext.GetEndpoint(), making it available to downstream middleware before the endpoint handler runs. This is crucial: the request pipeline order ensures routing executes early so authorization and other middleware can inspect the endpoint.

    The DFA approach is more efficient than regex-based matching because it avoids backtracking and evaluates routes in a single linear pass. If multiple routes could match, the system applies tie-breaking rules based on specificity: more segments, literal segments over parameters, and parameter constraints all increase priority.

    Example

    csharp
    var builder = WebApplication.CreateBuilder();
    var app = builder.Build();
    
    // Routes are compiled into a DFA at startup
    app.MapGet("/users/{id:int}", (int id) => $"User {id}");
    app.MapGet("/users/{id:guid}", (Guid id) => $"User {id}");
    app.MapPost("/users", () => "Create user");
    app.MapGet("/users/me", () => "Current user");
    
    app.Run();
    

    The matcher resolves /users/123 to the first endpoint (int constraint), /users/me to the fourth (literal match takes precedence), and POST /users to the third endpoint (HTTP method matching), all in a single DFA traversal without regex evaluation or backtracking.

    Key points to mention

    • Startup compilation: The DFA is built once at app startup, not per request, making matching extremely fast in production.
    • Constraint evaluation during traversal: Route constraints (int, guid, regex, custom) are evaluated during matching, not after, reducing wasted comparisons.
    • Endpoint availability to middleware: Matched endpoints are available via HttpContext.GetEndpoint() before the handler executes, enabling authorization and logging middleware to make decisions.
    • Specificity tie-breaking: Literal segments and constrained parameters have higher priority than generic catch-all routes, ensuring predictable matching.
    • No backtracking: Unlike regex-based matching, the DFA guarantees linear-time performance regardless of route complexity.

    Common follow-up questions

    • How do you debug which route was matched for a request? (Use HttpContext.GetEndpoint() and app.UseRouting() middleware to log the matched endpoint.)
    • What happens if multiple routes have the same pattern but different constraints? (The router applies tie-breaking rules; if ambiguous, an exception is thrown at startup.)
    • How does routing in ASP.NET Core differ from the old routing system? (Endpoint routing is attribute and method-based, compiles to a DFA at startup, and runs before middleware; the legacy system was regex-based and ran inside middleware.)

    Related interview questions

    Further reading

    .NETCsharpaspnetcore
  267. 267What is the role of EndpointDataSource?

    Expert

    EndpointDataSource is an abstraction that provides the collection of endpoints available to the routing system in ASP.NET Core. It decouples endpoint registration from routing, enabling dynamic endpoint discovery and modification at runtime. Custom implementations allow frameworks, plugins, or applications to add, remove, or transform endpoints without modifying core routing code.

    How it works

    EndpointDataSource is part of the routing middleware pipeline. When the application starts, the routing system queries all registered EndpointDataSource instances to build its route table. The data source returns a collection of Endpoint objects, each containing metadata, route patterns, handlers, and filters.

    EndpointDataSource supports both static and dynamic scenarios. Static sources (like conventional routing or MapGet) provide endpoints once at startup. Dynamic sources can re-evaluate endpoints on demand, enabling scenarios where endpoints change based on configuration, loaded plugins, or database state. This is achieved through the GetChangeToken() method, which signals when the endpoint collection has changed.

    The routing middleware uses these endpoints to match incoming requests. When a request arrives, the matcher finds the best endpoint and executes its associated handler. By centralizing endpoint discovery through EndpointDataSource, ASP.NET Core allows multiple registration mechanisms (conventional, attribute-based, minimal APIs, custom) to coexist seamlessly.

    Example

    csharp
    public class PluginEndpointDataSource : EndpointDataSource
    {
        private readonly List<Endpoint> _endpoints = new();
        private readonly CancellationTokenSource _changeToken = new();
    
        public override IReadOnlyList<Endpoint> Endpoints => _endpoints.AsReadOnly();
    
        public override IChangeToken GetChangeToken() => new CancellationChangeToken(_changeToken.Token);
    
        public void RegisterPluginEndpoint(string pattern, RequestDelegate handler, string displayName)
        {
            var metadata = new List<object> { new HttpMethodMetadata(new[] { "GET" }) };
            var endpoint = new RouteEndpoint(handler, RoutePatternFactory.Parse(pattern), 0, new EndpointMetadataCollection(metadata), displayName);
            _endpoints.Add(endpoint);
    
            // Signal that endpoints have changed
            _changeToken.Cancel();
        }
    }
    
    // In Program.cs
    var dataSource = new PluginEndpointDataSource();
    builder.Services.AddSingleton<EndpointDataSource>(dataSource);
    
    app.MapGet("/api/register-plugin", (HttpContext ctx, PluginEndpointDataSource source) =>
    {
        source.RegisterPluginEndpoint("/plugin/hello", ctx => ctx.Response.WriteAsJsonAsync(new { message = "Hello from plugin" }), "Plugin Endpoint");
        return Results.Ok("Plugin registered");
    });
    
    app.UseRouting();
    

    This example demonstrates a custom EndpointDataSource that allows plugins to register endpoints dynamically. When RegisterPluginEndpoint is called, it adds an endpoint and signals the change token to notify the routing system to re-evaluate available endpoints.

    Key points to mention

    • EndpointDataSource provides a pluggable mechanism for registering endpoints, supporting multiple registration strategies (conventional routing, attribute routing, minimal APIs, and custom sources).
    • It implements IChangeToken to notify the routing system when the endpoint collection changes, enabling dynamic endpoint discovery without application restart.
    • The abstraction separates endpoint registration from endpoint execution, making the routing system flexible and extensible.
    • Custom implementations are essential for advanced scenarios like plugin systems, multi-tenant applications, and framework-specific routing requirements.
    • The routing middleware queries all EndpointDataSource instances during startup (or when change tokens fire) to build an efficient route matcher.

    Common follow-up questions

    • How does the change token mechanism work, and when should you signal a change in a custom EndpointDataSource?
    • What is the difference between EndpointDataSource and conventional IRouter implementations in older ASP.NET Core versions?
    • How would you implement a multi-tenant EndpointDataSource that registers different endpoints based on the current tenant?

    Related interview questions

    Further reading

    .NETCsharpaspnetcore
  268. 268How would you build a multi-tenant SaaS on ASP.NET Core?

    Expert

    A multi-tenant SaaS architecture on ASP.NET Core resolves the tenant identity early (from subdomain, host header, or JWT claim), stores it in a scoped service, and applies tenant isolation at the database and cache layers using Entity Framework Core global query filters or separate databases. This ensures data isolation, performance, and security while keeping your application stateless and horizontally scalable.

    How it works

    Multi-tenancy requires four core components: tenant resolution, scoped context, data isolation, and resource isolation.

    Tenant Resolution occurs in middleware or a custom authentication handler, extracting the tenant identifier from the request—typically via subdomain (acme.saas.com), a header (X-Tenant-Id), a URL segment (/api/acme/...), or a JWT claim. This must happen early, before service resolution.

    Scoped Context stores the resolved tenant ID in an ITenantContext service registered with scoped lifetime. Each request gets its own isolated instance, safe for concurrent requests.

    Data Isolation can use two strategies: global query filters on a shared database (cheaper) or separate databases per tenant (stronger isolation, higher cost). Global query filters automatically append WHERE TenantId == currentTenant to all queries, while per-tenant databases use a connection string resolver.

    Resource Isolation extends beyond the database: cache keys must include the tenant ID, file storage must be partitioned by tenant (separate blob containers or folders), and configuration (feature flags, settings) must be tenant-aware.

    Example

    csharp
    // Scoped tenant context
    public interface ITenantContext
    {
        string TenantId { get; }
    }
    
    public class TenantContext : ITenantContext
    {
        public string TenantId { get; set; }
    }
    
    // Tenant resolution middleware
    public class TenantResolutionMiddleware
    {
        private readonly RequestDelegate _next;
    
        public TenantResolutionMiddleware(RequestDelegate next) => _next = next;
    
        public async Task InvokeAsync(HttpContext context, ITenantContext tenantContext)
        {
            var tenantId = context.Request.Host.Host.Split('.')[0]; // Extract from subdomain
            if (tenantId == "www" || tenantId == "api")
                tenantId = context.Request.Headers["X-Tenant-Id"].ToString();
    
            if (string.IsNullOrEmpty(tenantId))
                throw new InvalidOperationException("Tenant not resolved");
    
            ((TenantContext)tenantContext).TenantId = tenantId;
            await _next(context);
        }
    }
    
    // DbContext with global query filter
    public class SaasDbContext : DbContext
    {
        private readonly ITenantContext _tenantContext;
    
        public SaasDbContext(DbContextOptions options, ITenantContext tenantContext)
            : base(options) => _tenantContext = tenantContext;
    
        public DbSet Customers { get; set; }
    
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            // Global query filter: all Customer queries automatically filter by TenantId
            modelBuilder.Entity()
                .HasQueryFilter(c => c.TenantId == _tenantContext.TenantId);
        }
    }
    
    // Startup
    services.AddScoped();
    services.AddDbContext();
    app.UseMiddleware();
    

    This example resolves the tenant from the subdomain, stores it in a scoped TenantContext, and applies a global query filter to ensure every query automatically filters by the current tenant—preventing accidental cross-tenant data leaks.

    Key points to mention

    • Tenant resolution timing: Resolve tenant in middleware before service resolution; use claims-based resolution for stateless APIs.
    • Scoped lifetime is critical: The tenant context must be scoped to the request, not singleton, to avoid tenant leakage across concurrent requests.
    • Global query filters provide safety: They catch bugs early by automatically isolating queries; per-tenant databases offer stronger isolation but add complexity and cost.
    • Cache and blob storage isolation: Include TenantId in cache keys and use separate blob containers; a leaked cache key can expose another tenant's data.
    • Consider hybrid approaches: Use a shared database for small tenants and separate databases for large or high-security tenants (siloed model).

    Common follow-up questions

    • How do you handle background jobs in a multi-tenant system? See how to resolve scoped services in a background service—you must explicitly pass the TenantId to the job and resolve TenantContext manually, as jobs run outside the HTTP request context.
    • What happens if a tenant ID is missing or invalid? Always fail-fast with a 400 or 401 response in middleware; never default to a tenant or skip filtering.
    • How do you migrate a single-tenant app to multi-tenant? Add a TenantId column to existing tables (nullable initially), backfill with a default tenant, then make non-nullable; update queries incrementally with global filters.

    Related interview questions

    Further reading

    .NETCsharpaspnetcore
  269. 269Compare database-per-tenant and shared-schema multi-tenancy.

    Expert

    Database-per-tenant allocates a separate database instance to each tenant, providing complete isolation, independent scaling, and simplified compliance auditing. Shared-schema multi-tenancy stores all tenants in a single database with a TenantId column filtering data at the query level, reducing infrastructure costs and operational overhead. The choice depends on tenant count, data isolation requirements, and budget constraints.

    How it works

    Database-per-tenant architecture provisions a dedicated database for each customer. The application resolves the correct connection string based on tenant context (subdomain, header, claims) and connects to that tenant's database. Schema migrations, backups, and scaling decisions can be made per-tenant independently. This model excels when you need strict data isolation, regulatory compliance (HIPAA, GDPR per-tenant audit trails), or expect highly variable workloads across tenants.

    Shared-schema multi-tenancy uses a single database where all tables include a TenantId column or discriminator. A middleware or query filter intercepts all queries to automatically append WHERE TenantId = currentTenantId, preventing cross-tenant data leakage. This approach concentrates infrastructure but requires disciplined tenant filtering logic—a missing filter in any query can expose sensitive data to the wrong tenant.

    Example

    csharp
    // Shared-schema: Query filtering via EF Core
    public class TenantContext : DbContext
    {
        private readonly string _tenantId;
        
        public TenantContext(string tenantId) => _tenantId = tenantId;
        
        public DbSet<Invoice> Invoices { get; set; }
        
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            // Automatically filter all Invoice queries by TenantId
            modelBuilder.Entity<Invoice>()
                .HasQueryFilter(i => i.TenantId == _tenantId);
        }
    }
    
    // Database-per-tenant: Connection resolution
    public class TenantConnectionProvider
    {
        private readonly IHttpContextAccessor _httpContextAccessor;
        private readonly Dictionary<string, string> _tenantConnections;
        
        public string GetConnectionString()
        {
            var tenantId = _httpContextAccessor.HttpContext?.User
                .FindFirst("tenant_id")?.Value;
            return _tenantConnections.TryGetValue(tenantId, out var conn) 
                ? conn 
                : throw new InvalidOperationException("Tenant not found");
        }
    }
    

    The first example uses EF Core's HasQueryFilter to enforce tenant isolation at the query level; the second resolves the correct database connection based on tenant claims in the JWT token.

    Key points to mention

    • Isolation & compliance: Database-per-tenant guarantees physical separation, easing regulatory audits and customer concerns; shared-schema relies on application logic that must be bulletproof.
    • Cost & operations: Database-per-tenant requires more infrastructure, backup solutions, and migration management; shared-schema scales economically but concentrates "noisy-neighbor" risk (one tenant's heavy query impacts all others).
    • Migration complexity: EF Core migrations on database-per-tenant require orchestrating schema changes across many databases; shared-schema applies one migration to all tenants at once.
    • Query safety: Shared-schema demands tenant-filtering middleware or query filters on every DbSet; a single oversight (raw SQL, forgot HasQueryFilter) leaks data between tenants.
    • Tenant onboarding: Database-per-tenant requires provisioning a new database; shared-schema only needs inserting a row, enabling faster, cheaper tenant signup.

    Comparison table

    Criterion Database-per-Tenant Shared-Schema Multi-Tenancy
    Data Isolation Complete physical separation; no cross-tenant risk Logical (query filters); vulnerable to implementation bugs
    Infrastructure Cost High; scales linearly with tenant count Low; single database serves many tenants
    Migration Management Complex; must coordinate updates across multiple databases Simple; one migration applies to all data
    Tenant Onboarding Slow; requires database provisioning and seeding Fast; insert row and configure in seconds
    Noisy-Neighbor Impact Minimal; tenants have dedicated resources High; one tenant's heavy queries affect all others
    Compliance & Auditing Per-tenant backups and audit logs; easier to prove isolation Requires careful design; harder to demonstrate separation to auditors
    Compare database-per-tenant and shared-schema multi-tenancy. diagram

    Common follow-up questions

    • "How do you prevent query-filter bypasses in shared-schema?" — Use middleware to inject tenant context, enforce HasQueryFilter on all entities, audit raw SQL queries, and consider read-only contexts that reject unsupported operations.
    • "What hybrid approach exists?" — Many enterprises use database-per-region or per-tier: free/trial tenants share a database; enterprise customers get dedicated databases for compliance and performance guarantees.
    • "How do you handle reporting across all tenants?" — Database-per-tenant requires aggregating data from many sources; shared-schema can run a single query but must handle performance and cross-tenant security scans carefully.

    Related interview questions

    Further reading

    Compare database-per-tenant and shared-schema multi-tenancy.
    .NETCsharpefcore
  270. 270How do you design an API gateway in .NET?

    Expert

    An API gateway in .NET is a reverse proxy built with YARP that routes client requests to backend microservices while handling cross-cutting concerns like authentication, rate limiting, request/response transformation, and load balancing. It acts as a single entry point, decoupling clients from internal service topology and keeping business logic centralized rather than scattered across services.

    How it works

    An API gateway sits between clients and your microservices, intercepting all incoming requests. It inspects the request path, headers, and query parameters to route traffic to the appropriate backend service. Beyond routing, it handles concerns that shouldn't live in every microservice: validating JWT tokens, enforcing rate limits, transforming headers, logging, and load balancing across service instances.

    YARP (Yet Another Reverse Proxy) is Microsoft's modern solution for building gateways. It's lightweight, high-performance, and integrates seamlessly with .NET's dependency injection and middleware pipeline. You configure routes and policies in code or configuration files, then YARP handles the heavy lifting of proxying requests with minimal overhead. You can also chain custom middleware to intercept requests before they reach the backend.

    This pattern provides API versioning at the gateway level, shields internal service details from clients, and allows you to modify backend topology without changing client code. It's particularly valuable in microservices architectures where you want a single, versioned API contract for external consumers.

    Example

    csharp
    using Yarp.ReverseProxy.Configuration;
    
    var builder = WebApplicationBuilder.CreateBuilder(args);
    
    builder.Services.AddReverseProxy()
        .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
    
    builder.Services.AddAuthentication("Bearer")
        .AddJwtBearer(options =>
        {
            options.Authority = "https://your-auth-server";
            options.Audience = "your-api";
        });
    
    builder.Services.AddAuthorization();
    
    var app = builder.Build();
    
    app.UseAuthentication();
    app.UseAuthorization();
    
    app.MapReverseProxy(pipeline =>
    {
        pipeline.UseSessionAffinity();
        pipeline.UseLoadBalancing();
    });
    
    app.Run();
    

    This configures YARP as a gateway that enforces JWT authentication before routing requests to backend services defined in appsettings.json, with session affinity and load balancing built in.

    Key points to mention

    • YARP over alternatives: YARP is lightweight, cloud-native, and integrates with .NET ecosystem better than external proxies like nginx for managed environments. It also runs as a standard ASP.NET Core app on Kestrel.
    • Configuration-driven routing: Define routes and cluster destinations in appsettings.json or code; YARP handles dynamic discovery, failover, and health checks without custom logic.
    • Cross-cutting concerns: Move authentication, rate limiting, logging, and header transformation to the gateway to avoid duplication and ensure consistency across all services.
    • Security at the perimeter: The gateway enforces end-to-end API security by validating tokens and sanitizing requests before they reach business logic.
    • No business logic: The gateway routes and transforms; backends handle domain logic. This keeps the gateway fast and stateless, enabling horizontal scaling.

    Common follow-up questions

    • How do you handle request/response transformation? Use YARP's transform delegates or custom middleware to modify headers, query strings, or request bodies before forwarding to or returning from backends.
    • What about caching and rate limiting? Implement caching policies and rate-limiting middleware in the gateway pipeline; libraries like AspNetCoreRateLimit or StackExchange.Redis integrate cleanly.
    • How do you version APIs at the gateway? Route based on URL path prefix (e.g., /v1/, /v2/) or headers, mapping each version to different backend clusters, allowing independent service evolution.

    Related interview questions

    Further reading

    .NETCsharpaspnetcoreSecurity
  271. 271What is YARP?

    Expert

    YARP (Yet Another Reverse Proxy) is Microsoft's open-source, high-performance reverse proxy library built on ASP.NET Core. It enables you to route incoming HTTP requests to backend services while handling concerns like load balancing, SSL termination, and request transformation—all configured programmatically in C# or through JSON configuration files.

    How it works

    YARP operates as middleware within an ASP.NET Core application, intercepting incoming requests and forwarding them to configured backend services (clusters). It uses System.IO.Pipelines under the hood for efficient, low-allocation streaming of request and response bodies, making it capable of handling high throughput scenarios.

    The core workflow involves three steps: (1) the incoming request matches a route configuration, (2) the request is transformed if needed, and (3) it is forwarded to the appropriate backend service in a configured cluster. Responses flow back through YARP with optional transformations before being sent to the client.

    Unlike traditional reverse proxies (nginx, HAProxy), YARP is a .NET library, not a separate process. This allows you to integrate proxy logic directly into your application, customize behavior in C# code, and leverage the entire ASP.NET Core ecosystem. It's lightweight enough to embed in microservices yet powerful enough to serve as a dedicated API gateway.

    Example

    csharp
    // Program.cs - .NET 8+
    var builder = WebApplication.CreateBuilder(args);
    
    // Add YARP with configuration from appsettings.json
    builder.Services.AddReverseProxy()
        .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
        .AddTransforms<CustomTransformer>();
    
    var app = builder.Build();
    
    app.MapReverseProxy();
    app.Run();
    
    // appsettings.json
    {
      "ReverseProxy": {
        "Routes": [
          {
            "RouteId": "api-route",
            "ClusterId": "api-cluster",
            "Match": { "Path": "/api/{**catch-all}" },
            "Transforms": [
              { "PathPattern": "/backend/{**catch-all}" }
            ]
          }
        ],
        "Clusters": [
          {
            "ClusterId": "api-cluster",
            "Destinations": {
              "dest1": { "Address": "http://backend1:5000" },
              "dest2": { "Address": "http://backend2:5000" }
            }
          }
        ]
      }
    }
    
    // Custom transformer for advanced behavior
    public class CustomTransformer : HttpTransformer
    {
        public override ValueTask TransformRequestAsync(
            HttpContext httpContext, HttpRequestMessage proxyRequest, string destinationPrefix)
        {
            proxyRequest.Headers.Add("X-Forwarded-By", "YARP");
            return base.TransformRequestAsync(httpContext, proxyRequest, destinationPrefix);
        }
    }

    This configuration routes requests matching /api/** to a cluster of two backend services, rewrites the path, and injects a custom header via a transformer.

    Key points to mention

    • Performance: Built on Kestrel with efficient streaming using System.IO.Pipelines—handles thousands of requests per second with low memory overhead.
    • Configuration flexibility: Combine JSON-based configuration with programmatic customization in C# for dynamic routing, authentication, and request/response transformation.
    • Extensibility: Transform requests/responses, implement custom load balancing policies, add middleware for logging, rate limiting, or request enrichment.
    • Use cases: API gateways (see designing an API gateway), service mesh ingress, microservice aggregation, and legacy system integration.
    • Zero-copy proxying: Streams request/response bodies directly without buffering, ideal for large payloads and WebSocket upgrades.

    Common follow-up questions

    • How does YARP compare to traditional reverse proxies like nginx? — YARP runs in-process within .NET, offering tighter integration and dynamic configuration in C#, while nginx is a separate, battle-tested process. Choose YARP for embedded gateways; nginx for standalone infrastructure.
    • Can YARP handle WebSocket and gRPC traffic? — Yes, YARP supports both WebSocket upgrades and HTTP/2 gRPC forwarding through transparent protocol pass-through.
    • How do you implement circuit breaking or retry logic in YARP? — Use custom transformers, middleware, or integrate libraries like Polly for resilience patterns within your proxy configuration.

    Related interview questions

    .NETCsharpaspnetcorePerformance
  272. 272How do you design for horizontal scaling in ASP.NET Core?

    Expert

    Horizontal scaling in ASP.NET Core requires designing applications to be stateless, externalizing session and cache storage, persisting Data Protection keys securely, and ensuring all background jobs are idempotent and safe to run on multiple instances simultaneously. The core principle is that any instance must be able to handle any request without relying on local state, and all shared resources must live outside the application process.

    How it works

    When you scale horizontally, you run multiple identical instances of your ASP.NET Core application behind a load balancer. Each request can be routed to any instance, so no instance can store user state, session data, or cache locally. Instead, you externalize these concerns to shared infrastructure.

    Session data belongs in a distributed cache like Redis or a SQL database, not in memory. Similarly, authentication tokens, user preferences, and temporary data must be accessible from any instance. The Data Protection API requires that encryption keys be persisted to a shared location—typically a configuration store or database—so all instances can decrypt the same data.

    Background jobs present another challenge: if you schedule a job on one instance, other instances won't know about it. Use a centralized job queue (Hangfire, Azure Service Bus, or similar) so jobs are processed reliably across instances. Additionally, ensure jobs are idempotent—if a job runs twice, it should produce the same result as running once.

    Example

    csharp
    // Startup configuration for horizontal scaling
    public class Program
    {
        public static void Main(string[] args)
        {
            var builder = WebApplication.CreateBuilder(args);
    
            // Store session in Redis, not in-memory
            builder.Services.AddStackExchangeRedisCache(options =>
            {
                options.Configuration = builder.Configuration.GetConnectionString("Redis");
            });
    
            builder.Services.AddSession(options =>
            {
                options.IdleTimeout = TimeSpan.FromMinutes(30);
                options.Cookie.IsEssential = true;
            });
    
            // Persist Data Protection keys to a shared location
            builder.Services.AddDataProtection()
                .PersistKeysToStackExchangeRedis(
                    ConnectionMultiplexer.Connect(
                        builder.Configuration.GetConnectionString("Redis")),
                    "DataProtection-Keys");
    
            // Use distributed cache for application caching
            builder.Services.AddScoped(sp =>
                sp.GetRequiredService());
    
            // Register Hangfire for distributed background jobs
            builder.Services.AddHangfire(config =>
                config.UseRedisStorage(builder.Configuration.GetConnectionString("Redis")));
    
            builder.Services.AddHangfireServer();
    
            var app = builder.Build();
    
            app.UseSession();
            app.MapControllers();
            app.Run();
        }
    }
    
    // Controller example: don't store state locally
    [ApiController]
    [Route("api/[controller]")]
    public class UserController : ControllerBase
    {
        private readonly IDistributedCache _cache;
        private readonly IBackgroundJobClient _backgroundJobs;
    
        public UserController(IDistributedCache cache, IBackgroundJobClient backgroundJobs)
        {
            _cache = cache;
            _backgroundJobs = backgroundJobs;
        }
    
        [HttpGet("{id}")]
        public async Task GetUser(int id)
        {
            var cacheKey = $"user:{id}";
            var cached = await _cache.GetStringAsync(cacheKey);
            
            if (!string.IsNullOrEmpty(cached))
                return Ok(JsonSerializer.Deserialize(cached));
    
            // Fetch from database, store in distributed cache
            var user = new UserDto { Id = id, Name = "John Doe" };
            await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(user),
                new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) });
    
            return Ok(user);
        }
    
        [HttpPost("{id}/process")]
        public IActionResult EnqueueUserProcessing(int id)
        {
            // Queue job across all instances via Hangfire
            BackgroundJob.Enqueue(() => ProcessUserAsync(id));
            return Accepted();
        }
    
        public async Task ProcessUserAsync(int id)
        {
            // This job is idempotent: safe to run on any instance
            await Task.Delay(100); // Simulate work
        }
    }
    

    This example configures Redis for distributed session storage and caching, persists Data Protection keys to shared Redis, and uses Hangfire to queue background jobs that can run on any instance safely.

    Key points to mention

    • Stateless instances: No local memory state—all data must be retrievable from any instance, enabling load balancer flexibility.
    • Distributed caching: Use Redis, Azure Cache for Redis, or SQL Server instead of in-memory caches; configure session providers to use external stores.
    • Data Protection key persistence: Configure Data Protection API to persist keys to shared infrastructure so all instances can decrypt the same data.
    • Idempotent background jobs: Design background jobs to be idempotent and queue them centrally (Hangfire, Service Bus) rather than on individual instances.
    • Sticky sessions or distributed state: Avoid session affinity if possible; if necessary, use a distributed session backend rather than in-memory storage.

    Common follow-up questions

    • How do you handle distributed locks in a scaled environment? Use Redis Locks or a centralized coordination service sparingly—locks can become bottlenecks. Prefer optimistic concurrency (versioning) or eventual consistency patterns.
    • What happens if Redis goes down in a horizontally scaled setup? Implement failover strategies (Redis Sentinel, cluster mode), circuit breakers, and graceful degradation to fall back to database queries or cache misses.
    • How do you ensure idempotency in background jobs? Use unique identifiers, record job execution with timestamps, check for duplicates before processing, and design jobs so re-running them produces the same state.

    Related interview questions

    Further reading

    .NETCsharpaspnetcore
  273. 273How do you implement distributed locking?

    Expert

    Distributed locking ensures mutual exclusion across multiple processes or nodes in a distributed system. The most reliable approaches use Redis (with RedLock), database advisory locks, or cloud blob leases. Always set expiry times, assume locks can be lost, and design your critical sections to be idempotent so failures don't corrupt state.

    How it works

    Distributed locks solve the problem of coordinating access to shared resources when your application runs on multiple servers. Unlike local locks (which only work within one process), distributed locks must be held by an external service that all nodes can trust.

    The three main strategies differ in failure modes and latency. Redis-based locks (via StackExchange.Redis or Redlock libraries) are fast and simple but require Redis availability and careful timeout tuning—a process holding the lock can crash, so you must set a TTL (time-to-live) to auto-release it. Database advisory locks (PostgreSQL's pg_advisory_lock or SQL Server's sp_getapplock) are durable and integrated with your data store, making them safer for critical operations. Azure Blob Leases or similar cloud primitives offer strong semantics but add cloud provider coupling.

    The core challenge: if a process acquires a lock, then crashes, the lock must expire automatically. But if the TTL is too short, legitimate work might get preempted; if too long, recovery is slow. The solution is to design work as idempotent operations—if a lock expires mid-operation and another process repeats the work, the final state is correct anyway. Additionally, consider a heartbeat mechanism where the lock holder periodically renews the lock during long operations.

    Example

    csharp
    using StackExchange.Redis;
    
    public class DistributedLockService
    {
        private readonly IDatabase _redis;
        private readonly string _lockKeyPrefix = "lock:";
    
        public DistributedLockService(IConnectionMultiplexer redis)
        {
            _redis = redis.GetDatabase();
        }
    
        public async Task AcquireLockAsync(string resourceId, TimeSpan lockDuration)
        {
            var lockKey = $"{_lockKeyPrefix}{resourceId}";
            var lockValue = Guid.NewGuid().ToString();
            
            // SET NX (only set if not exists) with expiry
            var acquired = await _redis.StringSetAsync(
                lockKey, 
                lockValue, 
                lockDuration, 
                When.NotExists
            );
            
            return acquired;
        }
    
        public async Task ReleaseLockAsync(string resourceId, string lockValue)
        {
            var lockKey = $"{_lockKeyPrefix}{resourceId}";
            
            // Use Lua script to ensure we only delete our own lock
            var script = @"
                if redis.call('get', KEYS[1]) == ARGV[1] then
                    return redis.call('del', KEYS[1])
                else
                    return 0
                end";
            
            await _redis.ScriptEvaluateAsync(
                script, 
                new RedisKey[] { lockKey }, 
                new RedisValue[] { lockValue }
            );
        }
    
        public async Task DoLockedWorkAsync(string resourceId)
        {
            const int lockDurationSeconds = 30;
            var lockDuration = TimeSpan.FromSeconds(lockDurationSeconds);
            var lockValue = Guid.NewGuid().ToString();
    
            if (!await AcquireLockAsync(resourceId, lockDuration))
            {
                throw new InvalidOperationException("Could not acquire lock");
            }
    
            try
            {
                // Idempotent operation (e.g., update account balance, reconcile data)
                await PerformCriticalWorkAsync();
            }
            finally
            {
                await ReleaseLockAsync(resourceId, lockValue);
            }
        }
    
        private async Task PerformCriticalWorkAsync() => await Task.Delay(100);
    }
    

    This example uses Redis SET NX with an expiry to acquire a lock atomically, stores a unique value to prevent accidental cross-process deletion, and uses a Lua script for safe release.

    Key points to mention

    • Always set an expiry/TTL—without it, a crashed process holds the lock forever and deadlocks the system.
    • Use a unique token per lock holder (like a GUID) so only the true owner can release the lock, preventing accidental conflicts.
    • Design critical sections as idempotent—assume the lock can expire or be lost; the operation should be safe if executed twice.
    • Consider heartbeat renewal for long operations—extend the lock TTL periodically if work is still ongoing, preventing premature timeout.
    • Choose the right backend—Redis is fast and sufficient for most use cases, but databases offer durability and cloud blob leases offer strong semantics for cloud-native apps.

    Common follow-up questions

    • What happens if the lock expires while work is still running? This is why idempotency is critical. If another process acquires the lock and repeats the operation, both processes' changes should result in a consistent final state. For example, incrementing a counter is not idempotent, but setting a field to a specific value is.
    • How do you handle Redis failure in a lock-based system? Use a Redis cluster or Sentinel for high availability. Alternatively, fall back to a database-backed lock or implement a circuit breaker to degrade gracefully.
    • What's the difference between this and a semaphore? A semaphore allows multiple concurrent accessors (count > 1); a lock typically allows only one. For rate-limiting across nodes, you might use a distributed semaphore backed by Redis or a database counter.

    Related interview questions

    Further reading

    .NETCsharpasync
  274. 274How do you design a reliable event-driven system with .NET?

    Expert

    A reliable event-driven system in .NET requires the outbox pattern for transactional event publishing, idempotent message consumers with inbox tables to prevent duplicate processing, exponential backoff retries, dead-letter queues for failed messages, schema versioning for backward compatibility, and distributed tracing to correlate events across services. This combination ensures exactly-once processing semantics, resilience to failures, and observability.

    How it works

    The outbox pattern solves the dual-write problem: instead of publishing an event to a message broker and updating your database in separate transactions, you write both the domain event and the event record to your database in a single transaction. A background worker then polls the outbox table and publishes events to the broker, ensuring no events are lost if the application crashes.

    On the consumer side, an inbox table tracks processed message IDs. Before handling a message, you check if it already exists in the inbox—if it does, you skip processing (idempotency). After successful processing, you insert the message ID into the inbox within the same transaction as your business logic, guaranteeing exactly-once semantics even if a consumer crashes mid-processing.

    Exponential backoff retries (e.g., 1s, 2s, 4s, 8s) prevent overwhelming downstream systems during transient failures. Messages that exhaust retries move to dead-letter queues for manual investigation. Schema versioning (versioning event payloads or using discriminated unions) allows services to evolve independently. Distributed tracing with correlation IDs ties all related events together across services, simplifying debugging.

    Example

    csharp
    // Outbox pattern with Entity Framework Core
    public class OrderCreatedEvent
    {
        public Guid OrderId { get; set; }
        public decimal Amount { get; set; }
        public int Version { get; set; } = 1;
    }
    
    public class OutboxEvent
    {
        public Guid Id { get; set; }
        public string EventType { get; set; }
        public string Payload { get; set; }
        public DateTime CreatedAt { get; set; }
        public bool Published { get; set; }
    }
    
    public class OrderService
    {
        private readonly ApplicationDbContext _db;
        private readonly ILogger _logger;
    
        public async Task CreateOrderAsync(Order order)
        {
            using var transaction = _db.Database.BeginTransaction();
            
            // Update domain state
            _db.Orders.Add(order);
    
            // Write event to outbox (same transaction)
            var outboxEvent = new OutboxEvent
            {
                Id = Guid.NewGuid(),
                EventType = nameof(OrderCreatedEvent),
                Payload = JsonSerializer.Serialize(new OrderCreatedEvent 
                { 
                    OrderId = order.Id, 
                    Amount = order.Amount 
                }),
                CreatedAt = DateTime.UtcNow,
                Published = false
            };
            _db.OutboxEvents.Add(outboxEvent);
            
            await _db.SaveChangesAsync();
            await transaction.CommitAsync();
        }
    }
    
    // Outbox publisher (background worker)
    public class OutboxPublisher : BackgroundService
    {
        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
        {
            while (!stoppingToken.IsCancellationRequested)
            {
                var unpublished = await _db.OutboxEvents
                    .Where(e => !e.Published)
                    .ToListAsync(stoppingToken);
    
                foreach (var evt in unpublished)
                {
                    try
                    {
                        await _messageBroker.PublishAsync(evt.EventType, evt.Payload);
                        evt.Published = true;
                        await _db.SaveChangesAsync(stoppingToken);
                    }
                    catch (Exception ex)
                    {
                        _logger.LogError(ex, "Failed to publish event {EventId}", evt.Id);
                    }
                }
    
                await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
            }
        }
    }
    
    // Idempotent consumer with inbox
    public class OrderProcessedEventHandler
    {
        public async Task HandleAsync(OrderCreatedEvent evt)
        {
            if (await _db.InboxEvents.AnyAsync(e => e.MessageId == evt.OrderId))
                return; // Already processed
    
            using var transaction = _db.Database.BeginTransaction();
            
            // Process event
            var notification = new Notification { OrderId = evt.OrderId };
            _db.Notifications.Add(notification);
    
            // Record in inbox (atomically)
            _db.InboxEvents.Add(new InboxEvent { MessageId = evt.OrderId, ProcessedAt = DateTime.UtcNow });
            
            await _db.SaveChangesAsync();
            await transaction.CommitAsync();
        }
    }
    

    This code demonstrates the outbox pattern writing events and orders atomically, a background publisher moving events to the broker, and an idempotent handler using an inbox table to prevent duplicate processing.

    Key points to mention

    • Outbox + Inbox: Guarantees exactly-once delivery and processing semantics, eliminating data loss and duplicate handling
    • Transactional consistency: Use database transactions to couple domain changes with event writes, preventing partial failures
    • Idempotent operations: Design consumers so replaying the same message produces the same result (same inbox record, no side effects)
    • Retry strategy with backoff and DLQ: Use exponential backoff for transient failures; move messages to dead-letter queues after max retries for manual intervention
    • Schema versioning and tracing: Version events in payloads; include correlation IDs in message headers to track causality across services

    Common follow-up questions

    • How do you handle event schema evolution without breaking consumers? (Discriminated unions, versioned fields, or adapter patterns)
    • What's the difference between the outbox pattern and consuming directly from a queue, and when would you use each?
    • How do you implement distributed tracing and correlation IDs across async event chains? (OpenTelemetry, activity IDs in headers)

    Related interview questions

    Further reading

    .NETCsharpArchitecture
  275. 275What is the saga pattern and how do you implement it in .NET?

    Expert

    The saga pattern is a distributed transaction pattern that coordinates long-running business processes across multiple microservices by maintaining state and executing compensating actions on failure. Instead of a traditional two-phase commit, a saga breaks a transaction into a series of local transactions, each with a corresponding compensating action to handle rollbacks. In .NET, frameworks like MassTransit and NServiceBus provide built-in saga support with state machine definitions, persistence, and automatic compensation handling.

    How it works

    A saga maintains state across service boundaries by orchestrating a sequence of steps. When a step fails, the saga executes compensating actions in reverse order to undo previous changes. There are two primary implementation approaches:

    Choreography: Services publish events and react to them directly, with each service knowing which events trigger its actions. This is decoupled but can become difficult to follow as complexity grows.

    Orchestration: A central saga coordinator explicitly invokes each service step and handles compensation. This is more explicit and maintainable but introduces a central point of coordination.

    The saga persists its state in a database, allowing it to resume after failures. When a compensating transaction is needed, the saga either publishes a compensation event or directly invokes a compensation endpoint on the service. The state machine defines valid transitions and ensures idempotency—critical in distributed systems where messages may be retried.

    Example

    csharp
    public class OrderSagaState : SagaStateMachineInstance
    {
        public Guid CorrelationId { get; set; }
        public string State { get; set; }
        public Guid OrderId { get; set; }
        public decimal OrderAmount { get; set; }
    }
    
    public class OrderSaga : StateMachine<OrderSagaState>
    {
        public OrderSaga()
        {
            InstanceState(x => x.State);
    
            Event(() => OrderSubmitted, x => x.CorrelateById(m => m.OrderId));
            Event(() => PaymentApproved, x => x.CorrelateById(m => m.OrderId));
            Event(() => PaymentRejected, x => x.CorrelateById(m => m.OrderId));
    
            Initially(
                When(OrderSubmitted)
                    .Then(context => context.Saga.OrderAmount = context.Message.Amount)
                    .Send(new Uri("rabbitmq://localhost/payment-service"), 
                        context => new ProcessPaymentCommand { OrderId = context.Saga.OrderId })
                    .TransitionTo(WaitingForPayment));
    
            During(WaitingForPayment,
                When(PaymentApproved)
                    .Send(new Uri("rabbitmq://localhost/fulfillment-service"),
                        context => new FulfillOrderCommand { OrderId = context.Saga.OrderId })
                    .TransitionTo(Completed),
                When(PaymentRejected)
                    .Send(new Uri("rabbitmq://localhost/order-service"),
                        context => new CancelOrderCommand { OrderId = context.Saga.OrderId })
                    .TransitionTo(Failed));
        }
    
        public State WaitingForPayment { get; private set; }
        public State Completed { get; private set; }
        public State Failed { get; private set; }
    
        public Event<OrderSubmittedEvent> OrderSubmitted { get; private set; }
        public Event<PaymentApprovedEvent> PaymentApproved { get; private set; }
        public Event<PaymentRejectedEvent> PaymentRejected { get; private set; }
    }
    

    This MassTransit saga orchestrates an order workflow: when an order is submitted, it sends a payment request; if approved, it triggers fulfillment; if rejected, it sends a cancellation command—all with automatic state persistence and retry semantics.

    Key points to mention

    • State machine definition: Sagas use explicit state machines to define valid transitions, making the workflow explicit and maintainable, unlike choreography-only approaches.
    • Compensating actions: Each saga step has a corresponding compensation to undo work on failure, simulating transactional semantics without ACID guarantees.
    • Idempotency: Sagas must be idempotent because distributed messaging guarantees at-least-once delivery; replay must produce the same outcome.
    • Persistence: MassTransit and NServiceBus persist saga state to a database, enabling recovery after crashes and proper correlation of events.
    • Correlation ID: Events are correlated back to saga instances using a correlation ID, ensuring messages reach the correct saga state machine instance.

    Common follow-up questions

    • How does saga state persistence work, and what storage backends are supported? MassTransit supports EF Core, MongoDB, DynamoDB, and more. State is stored after each transition, allowing resume on failure. You typically configure persistence in the container during configuration.
    • What's the difference between orchestration and choreography sagas? Orchestration uses a central coordinator that explicitly invokes services and knows the flow; choreography has services react to events independently, making it looser but harder to visualize and test.
    • How do you handle saga timeouts and long-running processes? Sagas can schedule timeout events using `Schedule()` in MassTransit. When a timeout fires and the saga is still in a waiting state, it can trigger compensation or escalation logic.

    Related interview questions

    Further reading

    .NETCsharpArchitecture
  276. 276How do you handle eventual consistency in user-facing features?

    Expert

    Handling eventual consistency in user-facing features means acknowledging that data won't be instantly available after a request, then masking that delay through optimistic UI updates, pending states, and asynchronous notifications. The key is returning a 202 Accepted response immediately, providing a status endpoint to poll or using SignalR for real-time updates, while displaying provisional UI changes that get confirmed or rolled back when the backend operation completes.

    How it works

    Eventual consistency occurs naturally in distributed systems, microservices, and event-driven architectures where operations complete asynchronously. Rather than forcing clients to wait for full consistency, you embrace the delay and provide excellent UX:

    • Immediate acknowledgment: Accept the request with HTTP 202 Accepted, returning a resource identifier or status URL.
    • Optimistic updates: Update the UI immediately with the expected state while the backend processes the change.
    • Status tracking: Expose a status endpoint the client polls or subscribe to real-time updates via SignalR or WebSocket.
    • Reconciliation: When the backend operation completes, notify the client. If it failed, rollback the optimistic UI and show an error.
    • User communication: Display pending badges, skeleton loaders, or notifications so users understand something is in progress.

    This pattern is common in modern applications: uploading files, processing payments, bulk operations, or syncing data across systems. The user perceives responsiveness, and the backend has time to ensure correctness.

    Example

    csharp
    
    // API controller accepting a command and returning 202
    [ApiController]
    [Route("api/[controller]")]
    public class OrdersController : ControllerBase
    {
        private readonly IMediator _mediator;
    
        [HttpPost("process")]
        public async Task<IActionResult> ProcessOrder([FromBody] ProcessOrderCommand cmd)
        {
            // Generate unique operation ID
            var operationId = Guid.NewGuid().ToString();
            
            // Queue the operation without waiting for completion
            _ = _mediator.Send(cmd with { OperationId = operationId });
            
            // Return 202 Accepted with status URL
            return Accepted($"/api/orders/status/{operationId}", 
                new { operationId, statusUrl = $"/api/orders/status/{operationId}" });
        }
    
        // Status endpoint for polling
        [HttpGet("status/{operationId}")]
        public async Task<IActionResult> GetStatus(string operationId)
        {
            var status = await _mediator.Send(new GetOperationStatusQuery { OperationId = operationId });
            
            return Ok(new 
            { 
                operationId,
                status = status.State, // "Pending", "Completed", "Failed"
                result = status.Result,
                error = status.ErrorMessage
            });
        }
    }
    
    // SignalR hub for real-time notifications
    public class OrderProcessingHub : Hub
    {
        public async Task SubscribeToOrder(string operationId)
        {
            await Groups.AddToGroupAsync(Connection.ConnectionId, $"order-{operationId}");
        }
    }
    
    // When operation completes, notify via hub
    public class OrderProcessedEventHandler : INotificationHandler<OrderProcessedEvent>
    {
        private readonly IHubContext<OrderProcessingHub> _hub;
    
        public async Task Handle(OrderProcessedEvent notification, CancellationToken ct)
        {
            await _hub.Clients
                .Group($"order-{notification.OperationId}")
                .SendAsync("OrderCompleted", new 
                { 
                    operationId = notification.OperationId,
                    status = "Completed",
                    orderId = notification.OrderId
                }, ct);
        }
    }
    

    This example shows a POST that immediately returns 202 with an operation ID, a GET status endpoint for polling, and a SignalR hub that pushes updates when processing finishes, allowing the client to display pending state initially and update optimistically.

    Key points to mention

    • Use HTTP 202 Accepted to signal the request was received but processing is deferred, keeping the HTTP contract semantic.
    • Provide both polling (status endpoint) and push (SignalR/WebSocket) options; polling is simpler, push is more efficient.
    • Display pending UI states, checkmarks, or spinners so users never think the app is broken.
    • Handle failure gracefully: if the backend operation fails, show an error message and offer retry or rollback options.
    • Consider how you handle long-running requests separately—202 patterns are best for operations that naturally complete asynchronously (message queues, background jobs), not streaming responses.

    Common follow-up questions

    • How do you prevent duplicate processing? Use idempotency keys: store the operation ID and initial request hash, reject retries with the same key.
    • What if the client never polls or reconnects? Set a TTL on the operation status cache; persist critical state in the database so users can check later.
    • How does this relate to CQRS read models? The status endpoint queries a read model that lags behind the write model; this is expected and communicated to the user.

    Related interview questions

    .NETCsharpArchitecture
  277. 277How do you design idempotent message consumers?

    Expert

    Idempotent message consumers ensure that processing the same message multiple times produces the same result as processing it once. This is achieved by storing processed message IDs in the same database transaction as business side effects, using natural idempotency patterns (like upserts), and designing handlers to tolerate duplicate and out-of-order delivery. The core principle is: deduplication at the consumer level combined with idempotent operations makes the system resilient to message broker failures and retries.

    How it works

    Idempotent message consumers rely on three complementary strategies:

    1. Deduplication via processed message tracking: Before processing a message, check if its ID already exists in a ProcessedMessages table within the same transaction context as your business logic. If it exists, skip processing and return success (idempotent read). If not, process the message and insert the message ID atomically.

    2. Natural idempotency: Design your business operations to be inherently idempotent. Use database upserts instead of inserts, update operations instead of increments, or state-based commands instead of event-based deltas. For example, "set account balance to X" is idempotent; "add Y to account balance" is not.

    3. Duplicate and out-of-order tolerance: Handlers must assume messages may arrive multiple times or in unexpected order. Use versioning, timestamps, or causality tokens to detect and reject stale or redundant messages. Treat message IDs as unique identifiers for deduplication, not sequence numbers for ordering guarantees.

    The transaction boundary is critical: both the deduplication record and the business side effect must be committed or rolled back together, preventing "at-most-once" semantics from degrading into "lost updates."

    Example

    csharp
    public class IdempotentOrderConsumer
    {
        private readonly IServiceScopeFactory _scopeFactory;
        private readonly ILogger<IdempotentOrderConsumer> _logger;
    
        public async Task HandleOrderCreatedAsync(OrderCreatedEvent @event)
        {
            using var scope = _scopeFactory.CreateScope();
            var context = scope.ServiceProvider.GetRequiredService<OrderDbContext>();
    
            using var transaction = await context.Database.BeginTransactionAsync();
            try
            {
                // Check if already processed
                var alreadyProcessed = await context.ProcessedMessages
                    .AnyAsync(pm => pm.MessageId == @event.MessageId);
    
                if (alreadyProcessed)
                {
                    _logger.LogInformation("Message {MessageId} already processed, skipping.", @event.MessageId);
                    return; // Idempotent: return success without re-processing
                }
    
                // Natural idempotency: upsert order
                var order = await context.Orders
                    .FirstOrDefaultAsync(o => o.OrderId == @event.OrderId);
    
                if (order == null)
                {
                    order = new Order
                    {
                        OrderId = @event.OrderId,
                        CustomerId = @event.CustomerId,
                        Total = @event.Total,
                        Status = OrderStatus.Created,
                        CreatedAt = DateTime.UtcNow
                    };
                    context.Orders.Add(order);
                }
                else
                {
                    // Tolerate replayed messages: only update if state is valid
                    if (order.Status == OrderStatus.Pending)
                    {
                        order.Status = OrderStatus.Created;
                    }
                }
    
                // Record deduplication in same transaction
                context.ProcessedMessages.Add(new ProcessedMessage
                {
                    MessageId = @event.MessageId,
                    MessageType = nameof(OrderCreatedEvent),
                    ProcessedAt = DateTime.UtcNow
                });
    
                await context.SaveChangesAsync();
                await transaction.CommitAsync();
    
                _logger.LogInformation("Order {OrderId} processed successfully.", @event.OrderId);
            }
            catch (Exception ex)
            {
                await transaction.RollbackAsync();
                _logger.LogError(ex, "Failed to process message {MessageId}.", @event.MessageId);
                throw;
            }
        }
    }
    

    This consumer deduplicates by checking ProcessedMessages, uses an upsert pattern for the order (idempotent by nature), and records the processed message ID in the same transaction, ensuring that retries are safe and side effects are never duplicated.

    Key points to mention

    • Transaction atomicity: Deduplication state and business side effects must commit together. Use database transactions or event sourcing to guarantee consistency.
    • Natural idempotency over deduplication: Whenever possible, design operations to be inherently idempotent (upserts, state-based commands) rather than relying solely on message tracking, which scales better and requires less infrastructure.
    • Message ID as the deduplication key: Use a unique, stable message identifier (often a GUID or correlation ID from the message broker) rather than business keys, which may collide across different logical messages.
    • Timeout and cleanup strategy: Define retention policies for the ProcessedMessages table. Older records can be archived or deleted after a TTL, balancing replay protection against storage costs.
    • Integration with the outbox pattern: Combine idempotent consumers with outbox-based message publishing to achieve end-to-end reliability in event-driven systems.

    Common follow-up questions

    • How do you handle out-of-order messages in an idempotent consumer? Use versioning or timestamps on the message and the persisted state; reject messages with older versions or earlier timestamps than what is already stored.
    • What happens if the deduplication table grows too large? Implement a cleanup job to archive or purge old entries after a retention period (e.g., 30 days), or use a distributed cache (Redis) for temporary deduplication with automatic expiration.
    • How is this different from API idempotency? API idempotency uses idempotency keys in HTTP headers and caches responses; message consumer idempotency stores message IDs in a database and replays state changes safely. Both ensure "at-most-once" semantics from the client's perspective.

    Related interview questions

    Further reading

    .NETCsharp
  278. 278What is Domain-Driven Design in .NET terms?

    Expert

    Domain-Driven Design (DDD) is an architectural approach that centers software development around the business domain, using a ubiquitous language shared between developers and domain experts. In .NET terms, it structures code into bounded contexts containing aggregates, entities, value objects, and domain events, all kept free from infrastructure concerns so the business logic remains testable and maintainable.

    How it works

    DDD starts by identifying the problem domain and dividing it into bounded contexts—logical boundaries where a particular domain language and model apply. Within each context lives a domain layer containing core business objects.

    Entities are domain objects with a unique identity that persists over time (like a Customer or Order). Value Objects have no identity and are immutable, defined entirely by their attributes (like Money or Address). Aggregates are clusters of entities and value objects treated as a single unit of consistency; an aggregate root controls access to its internal members, ensuring invariants are maintained.

    Domain Events represent something that happened in the business—when an aggregate's state changes significantly, it raises an event. These events decouple aggregates and enable communication across bounded contexts. How do you raise and dispatch domain events? is a natural extension here.

    The domain layer depends only on other domain code, never on database frameworks or HTTP libraries. Infrastructure concerns (repositories, logging, API clients) live in separate layers and depend inward on the domain layer.

    Example

    csharp
    // Value Object - immutable, no identity
    public record Money(decimal Amount, string Currency)
    {
        public Money Add(Money other) =>
            other.Currency != Currency
                ? throw new InvalidOperationException("Currency mismatch")
                : new Money(Amount + other.Amount, Currency);
    }
    
    // Domain Event
    public record OrderPlacedEvent(Guid OrderId, Guid CustomerId, Money Total, DateTime OccurredAt);
    
    // Aggregate Root - controls invariants
    public class Order
    {
        private List _items = new();
    
        public Guid Id { get; private set; }
        public Guid CustomerId { get; private set; }
        public Money Total { get; private set; }
        public List DomainEvents { get; private set; } = new();
    
        private Order() { } // For ORM
    
        public static Order Create(Guid customerId) =>
            new() { Id = Guid.NewGuid(), CustomerId = customerId, Total = new Money(0, "USD") };
    
        public void AddItem(OrderItem item)
        {
            _items.Add(item);
            Total = Total.Add(item.Price);
        }
    
        public void Place()
        {
            if (_items.Count == 0) throw new InvalidOperationException("Order cannot be empty");
            DomainEvents.Add(new OrderPlacedEvent(Id, CustomerId, Total, DateTime.UtcNow));
        }
    }
    
    // Entity with identity
    public class OrderItem
    {
        public Guid Id { get; set; }
        public string ProductName { get; set; }
        public Money Price { get; set; }
    }
    

    This example shows an Order aggregate root enforcing business rules, using immutable records for the Money value object, and raising a domain event when the order is placed.

    Key points to mention

    • DDD prioritizes business logic clarity over technical convenience; the domain layer has zero framework dependencies.
    • Aggregates enforce consistency boundaries—only the aggregate root is modified externally; internal state changes only through root methods.
    • Bounded contexts allow large systems to split into manageable pieces with independent domain models and anti-corruption layers between them.
    • Value Objects using records or custom immutable classes reduce bugs by preventing accidental mutation.
    • Domain Events decouple aggregates and enable event sourcing or eventual consistency patterns across contexts.

    Common follow-up questions

    • How do you implement repositories in a DDD architecture without leaking infrastructure details into the domain?
    • What is the difference between a DTO and a domain entity, and when should each be used?
    • How do bounded contexts communicate when they span multiple deployment units or teams?

    Related interview questions

    Further reading

    .NETCsharpArchitecture
  279. 279How do you enforce aggregate invariants?

    Expert

    Enforce aggregate invariants by encapsulating state within the aggregate root, exposing only behavior methods that validate business rules before state changes, ensuring all modifications happen through the root entity, and persisting the entire aggregate atomically in a single transaction. This prevents invalid state and maintains consistency according to domain rules.

    How it works

    An aggregate is a cluster of domain objects that must maintain consistency boundaries. Invariants are rules that must always be true—for example, an order's total cannot be negative, a user cannot have a negative account balance, or a shopping cart cannot exceed inventory limits.

    Enforce invariants by making the aggregate root the sole entry point for changes. Internal state fields remain private, and value modifications only occur through public behavior methods. These methods contain validation logic that throws exceptions if invariants would be violated. The aggregate refuses invalid operations at the boundary rather than allowing bad state to exist temporarily.

    Persistence is equally critical: save the entire aggregate as one atomic unit. If any part of the aggregate fails validation during save, the entire transaction rolls back. This ensures the database never contains a partially-invalid aggregate. Combined with optimistic or pessimistic locking, this prevents race conditions where two threads modify the same aggregate concurrently.

    The pattern works hand-in-hand with encapsulation—clients cannot bypass behavior methods to set fields directly, and the aggregate never exposes mutable collections that external code could modify without triggering validation.

    Example

    csharp
    public class ShoppingCart
    {
        private readonly List<CartItem> _items = new();
        private const int MaxItemsPerCart = 100;
    
        public Guid Id { get; private set; }
        public IReadOnlyList<CartItem> Items => _items.AsReadOnly();
    
        public void AddItem(Product product, int quantity)
        {
            if (quantity <= 0)
                throw new InvalidOperationException("Quantity must be positive.");
            
            if (_items.Count + quantity > MaxItemsPerCart)
                throw new InvalidOperationException("Cart exceeds maximum item limit.");
    
            var existing = _items.FirstOrDefault(i => i.ProductId == product.Id);
            if (existing != null)
                existing.IncreaseQuantity(quantity);
            else
                _items.Add(new CartItem(product.Id, product.Price, quantity));
        }
    
        public void RemoveItem(Guid productId)
        {
            var item = _items.FirstOrDefault(i => i.ProductId == productId);
            if (item == null)
                throw new InvalidOperationException("Item not in cart.");
            
            _items.Remove(item);
        }
    
        public decimal CalculateTotal()
        {
            var total = _items.Sum(i => i.Price * i.Quantity);
            if (total < 0) // Defensive check
                throw new InvalidOperationException("Cart total is invalid.");
            return total;
        }
    }
    
    // Usage: all mutations go through the aggregate root
    var cart = new ShoppingCart();
    cart.AddItem(product, 5); // Validates before adding
    cart.RemoveItem(productId); // Validates before removing
    // cart._items.Add(...) — compile error, private field
    

    The aggregate root validates every operation (AddItem, RemoveItem) before allowing state changes, preventing negative quantities, oversized carts, and invalid totals.

    Key points to mention

    • Private mutable state: Keep collections and fields private; expose read-only views (IReadOnlyList) so external code cannot circumvent validation.
    • Behavior-driven API: Design public methods around business actions (AddItem, RemoveItem, Checkout) rather than property setters that allow arbitrary state mutations.
    • Fail fast with exceptions: Validate and throw immediately when an invariant would be violated; never allow the aggregate into an invalid state, even temporarily.
    • Atomic persistence: Always save the entire aggregate in one database transaction; use unit-of-work or repository patterns to enforce this discipline.
    • Constructor validation: Validate invariants in constructors too; a new aggregate must start in a valid state.

    Common follow-up questions

    • How do you handle invariant violations in an event-sourced system? — Apply the same rules when replaying events; reject events that would break invariants, and consider snapshots to avoid replaying all history.
    • What if an invariant spans multiple aggregates? — Use a process manager or saga to coordinate actions across aggregate boundaries; each aggregate remains self-contained, and the orchestrator handles cross-aggregate consistency.
    • How does this relate to the repository pattern? — The repository saves and loads entire aggregates; it enforces that you never partially persist an aggregate or load a stale version.

    Related interview questions

    Further reading

    .NETCsharpArchitecture
  280. 280How do you raise and dispatch domain events?

    Expert

    Direct answer: Domain events are raised by aggregates and stored in a collection, then dispatched after a successful transaction commit—typically via a SaveChanges interceptor, unit of work, or outbox pattern. The dispatcher (often MediatR or a custom event bus) publishes them to handlers, decoupling the domain model from infrastructure concerns.

    How it works

    Domain events represent something that has happened in your business domain and are central to Domain-Driven Design. Aggregates collect these events internally without immediately publishing them. When SaveChanges commits, an interceptor or unit of work extracts and dispatches them.

    The pattern ensures two critical properties: atomicity (events are published only if the transaction succeeds) and isolation (handlers run outside the domain model, preventing cascading side effects). Most implementations use an outbox table—events are persisted alongside domain data, then a background job picks them up and publishes them reliably, even if the message broker is temporarily unavailable.

    Example

    csharp
    // Domain event
    public abstract class DomainEvent
    {
        public DateTime OccurredAt { get; } = DateTime.UtcNow;
        public Guid AggregateId { get; protected set; }
    }
    
    public class OrderCreatedEvent : DomainEvent
    {
        public OrderCreatedEvent(Guid orderId, string customerName, decimal total)
        {
            AggregateId = orderId;
            CustomerName = customerName;
            Total = total;
        }
        public string CustomerName { get; }
        public decimal Total { get; }
    }
    
    // Aggregate
    public class Order
    {
        private List<DomainEvent> _domainEvents = new();
        public Guid Id { get; private set; }
        public string CustomerName { get; private set; }
        public decimal Total { get; private set; }
    
        public static Order Create(Guid id, string customerName, decimal total)
        {
            var order = new Order
            {
                Id = id,
                CustomerName = customerName,
                Total = total
            };
            order._domainEvents.Add(new OrderCreatedEvent(id, customerName, total));
            return order;
        }
    
        public IReadOnlyList<DomainEvent> GetDomainEvents() => _domainEvents.AsReadOnly();
        public void ClearDomainEvents() => _domainEvents.Clear();
    }
    
    // SaveChanges interceptor (EF Core)
    public class DomainEventDispatcherInterceptor : SaveChangesInterceptor
    {
        private readonly IMediator _mediator;
    
        public DomainEventDispatcherInterceptor(IMediator mediator) => _mediator = mediator;
    
        public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
            DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken)
        {
            var aggregates = eventData.Context?.ChangeTracker
                .Entries()
                .Select(e => e.Entity)
                .OfType<Order>()
                .ToList() ?? new();
    
            var events = aggregates
                .SelectMany(a => a.GetDomainEvents())
                .ToList();
    
            return await base.SavingChangesAsync(eventData, result, cancellationToken);
        }
    
        public override async ValueTask<int> SavedChangesAsync(
            SaveChangesCompletedEventData eventData, int result, CancellationToken cancellationToken)
        {
            var aggregates = eventData.Context?.ChangeTracker
                .Entries()
                .Select(e => e.Entity)
                .OfType<Order>()
                .ToList() ?? new();
    
            var events = aggregates
                .SelectMany(a => a.GetDomainEvents())
                .ToList();
    
            aggregates.ForEach(a => a.ClearDomainEvents());
    
            foreach (var @event in events)
            {
                await _mediator.Publish(@event, cancellationToken);
            }
    
            return await base.SavedChangesAsync(eventData, result, cancellationToken);
        }
    }
    
    // Dependency injection
    services.AddScoped<DomainEventDispatcherInterceptor>();
    services.AddDbContext<OrderContext>((sp, options) =>
        options.AddInterceptors(sp.GetRequiredService<DomainEventDispatcherInterceptor>()));
    

    This example shows an aggregate raising an event during creation, an interceptor collecting events after commit, and MediatR dispatching them to handlers—ensuring atomicity and clean separation of concerns.

    Key points to mention

    • Transactional safety: Events are published only after the transaction commits, preventing orphaned handlers if the save fails.
    • Outbox pattern: For guaranteed delivery, persist events to an outbox table in the same transaction, then a background process publishes them—this survives application crashes.
    • Event ordering and idempotency: Handlers must be idempotent and process events in a predictable order, especially in distributed systems.
    • Decoupling: Aggregates don't reference handlers directly; MediatR or an event bus abstracts the dispatch, allowing handlers to be added or removed without changing the domain model.
    • Clear state after dispatch: Always clear the events collection after publishing to prevent re-dispatching on subsequent SaveChanges calls.

    Common follow-up questions

    • How do you guarantee event delivery if the application crashes after commit but before dispatch? Use an outbox table in the same database transaction; a background job polls and publishes events reliably.
    • Should domain events be persisted separately, or is the aggregate history enough? Domain events should be persisted (often in an outbox or event store) for reliable delivery and audit trails, separate from the current aggregate state.
    • How do you handle eventual consistency when a handler fails? Implement retry logic in handlers, use a dead-letter queue, and ensure handlers are idempotent so replaying doesn't corrupt state.

    Related interview questions

    Further reading

    .NETCsharpArchitecture
  281. 281What are strongly typed IDs?

    Expert

    Strongly typed IDs are wrapper types around primitive identifiers (like GUIDs or integers) that prevent accidental mixing of different entity IDs at compile time. Instead of passing raw Guid or int values, you wrap them in semantic types such as OrderId or CustomerId, making invalid operations impossible and improving code clarity.

    How it works

    The core idea is to replace domain-agnostic primitives with domain-specific types. A method expecting an OrderId will reject a CustomerId at compile time, eliminating an entire class of bugs. Modern C# makes this practical through records (or record structs) that provide value semantics, lightweight syntax, and built-in equality.

    In Entity Framework Core, you configure value converters to tell EF how to store and retrieve these wrapper types from the database. For JSON serialization, you add custom JSON converters so the ID serializes as its underlying value, not a nested object. This keeps the domain model strongly typed while maintaining clean data representations.

    The workflow is: define a record struct wrapping your ID primitive, configure EF Core's HasConversion() to handle database persistence, add a JSON converter for API contracts, and use the type throughout your domain logic. The compiler prevents mixing OrderId with CustomerId, while the infrastructure transparently converts to/from primitives at boundaries.

    Example

    csharp
    // Define strongly typed ID as a record struct
    public readonly record struct OrderId(Guid Value);
    public readonly record struct CustomerId(Guid Value);
    
    // Domain model
    public class Order
    {
        public OrderId Id { get; set; }
        public CustomerId CustomerId { get; set; }
        public string Description { get; set; }
    }
    
    // EF Core configuration
    modelBuilder.Entity<Order>()
        .Property(o => o.Id)
        .HasConversion(id => id.Value, value => new OrderId(value));
    
    modelBuilder.Entity<Order>()
        .Property(o => o.CustomerId)
        .HasConversion(id => id.Value, value => new CustomerId(value));
    
    // JSON converter (System.Text.Json)
    public class OrderIdJsonConverter : JsonConverter<OrderId>
    {
        public override OrderId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
            => new(reader.GetGuid());
    
        public override void Write(Utf8JsonWriter writer, OrderId value, JsonSerializerOptions options)
            => writer.WriteStringValue(value.Value);
    }
    
    // Usage
    var orderId = new OrderId(Guid.NewGuid());
    var customerId = new CustomerId(Guid.NewGuid());
    
    // This compiles
    var order = new Order { Id = orderId, CustomerId = customerId };
    
    // This does NOT compile – type mismatch
    // var badOrder = new Order { Id = customerId, CustomerId = orderId };

    The example shows how record structs wrap GUIDs, EF Core converts them for storage, and JSON converters serialize them cleanly—all while the compiler prevents accidental ID swapping.

    Key points to mention

    • Type safety at compile time: Mixing OrderId with CustomerId fails immediately, not at runtime.
    • Record structs vs. classes: Use readonly record struct for minimal overhead; records provide value equality and immutability by default.
    • EF Core integration: HasConversion() maps strongly typed IDs to their underlying primitives in the database without cluttering the schema.
    • JSON serialization: Custom JSON converters ensure IDs serialize as scalars (e.g., "550e8400-e29b-41d4-a716-446655440000") rather than nested objects, keeping APIs clean.
    • Performance: Struct-based IDs avoid heap allocations; the runtime cost is negligible compared to the safety and clarity gains.

    Common follow-up questions

    • Should I use a class or struct for strongly typed IDs? Prefer readonly record struct for zero-copy semantics and immutability; classes add unnecessary heap pressure.
    • How do I handle EF Core queries with strongly typed IDs? Write ordersDb.Orders.Where(o => o.Id == orderId); EF Core's value converter automatically translates the comparison to the underlying Guid.
    • Is this worth the boilerplate? Yes, especially in large codebases; preventing even one cross-entity bug justifies the small setup cost, and tooling (source generators, library patterns) can reduce it further.

    Related interview questions

    Further reading

    .NETCsharpefcoreArchitecture
  282. 282How do you design a read model in CQRS?

    Expert

    In CQRS, a read model is a denormalized, query-optimized data store built specifically to serve read operations, separate from the write model. It's populated asynchronously from domain events or projections, allowing you to optimize schema and performance for queries while accepting eventual consistency with the authoritative write model.

    How it works

    The read model exists as a separate data store—often denormalized SQL tables, NoSQL documents, or Elasticsearch indices—shaped entirely around query access patterns, not normalization rules. When a write operation occurs on the command side, it generates domain events. Projections subscribe to these events and update the read model in the background, transforming event data into query-friendly formats.

    This separation provides several advantages. First, you can optimize read schemas independently: flatten hierarchies, pre-aggregate data, duplicate information across tables, or index heavily without compromising write performance. Second, reads scale horizontally because they hit a dedicated store rather than competing with writes. Third, you can serve different read models for different clients—one optimized for dashboards, another for reports, another for mobile apps—all from the same event stream.

    The trade-off is eventual consistency: after a write, reads may lag slightly while projections process events. This is acceptable in most business domains but must be designed intentionally and communicated to users.

    Example

    csharp
    // Domain event
    public record OrderPlacedEvent(
        string OrderId,
        string CustomerId,
        decimal Amount,
        DateTime Timestamp);
    
    // Read model (denormalized for query performance)
    public class OrderSummary
    {
        public string OrderId { get; set; }
        public string CustomerName { get; set; }
        public decimal Amount { get; set; }
        public string Status { get; set; }
        public DateTime PlacedAt { get; set; }
    }
    
    // Projection that builds the read model
    public class OrderSummaryProjection
    {
        private readonly IOrderSummaryRepository _repository;
    
        public async Task HandleOrderPlacedAsync(OrderPlacedEvent @event)
        {
            var customer = await _repository.GetCustomerAsync(@event.CustomerId);
            
            var summary = new OrderSummary
            {
                OrderId = @event.OrderId,
                CustomerName = customer.Name,
                Amount = @event.Amount,
                Status = "Pending",
                PlacedAt = @event.Timestamp
            };
    
            await _repository.AddSummaryAsync(summary);
        }
    }
    
    // Query handler (no business logic, direct read)
    public class GetOrderSummaryQueryHandler
    {
        private readonly IOrderSummaryRepository _repository;
    
        public async Task HandleAsync(string orderId)
        {
            return await _repository.GetByIdAsync(orderId);
        }
    }

    This example shows how a projection listens to domain events, enriches them with reference data (customer name), and writes denormalized documents to a read model optimized for fast lookups.

    Key points to mention

    • Denormalization by design: Read models intentionally violate normalization to optimize query speed; data duplication is expected and managed through projections.
    • Event-driven updates: Read models stay synchronized via subscriptions to domain events, not direct queries or shared database triggers, preserving architectural boundaries.
    • Eventual consistency: Projections update asynchronously, so reads may briefly return stale data; design your UI and business logic accordingly (show timestamps, use refresh buttons).
    • Technology flexibility: Read models can be SQL Server, PostgreSQL, MongoDB, Elasticsearch, or Redis depending on query patterns; write model and read model need not use the same database.
    • Multiple read models: Build different projections for different audiences—reports, dashboards, mobile clients—all fed by the same event stream, maximizing reusability.

    Common follow-up questions

    • How do you handle eventual consistency in the UI? Show last-updated timestamps, disable features until projection completes, use optimistic locking, or implement WebSocket subscriptions that notify clients when data is ready.
    • What if a projection fails mid-update? Use idempotent projections (safe to replay), implement outbox patterns to ensure events aren't lost, and build monitoring/replay logic to recover from failures.
    • How does this relate to CQRS overall? Read models are the output side of CQRS; they physically separate read and write concerns, enabling independent scaling and schema optimization.

    Related interview questions

    Further reading

    .NETCsharpArchitecture
  283. 283When is event sourcing a good fit?

    Expert

    Event sourcing is a good fit for domains requiring complete audit trails, temporal queries (asking "what was the state at time X?"), or complex workflows where understanding how you arrived at a state matters as much as the current state itself. It's less suitable for simple CRUD applications where the audit requirement is minimal and query complexity is low, since event sourcing introduces operational overhead in event versioning, projection management, and eventual consistency handling.

    How it works

    Event sourcing persists the complete history of state changes as a sequence of immutable events rather than storing only the current state. Instead of updating a record in place, you append events to an event store. The current state is reconstructed by replaying events from the beginning, or more efficiently, by building and maintaining projections—denormalized read models derived from events.

    This design creates a natural audit log: you always know exactly what happened, when, and in what order. It enables temporal queries ("show me the account balance on March 15th"), supports complex domain logic that depends on history, and facilitates debugging by replaying events to any point in time. The trade-off is increased complexity: you must handle event versioning as your domain evolves, manage projection consistency (often eventual), and deal with event store deployment and maintenance.

    Example

    csharp
    // Domain events
    public abstract record DomainEvent(Guid AggregateId, DateTime OccurredAt);
    public record AccountCreated(Guid AggregateId, string Owner, DateTime OccurredAt) 
        : DomainEvent(AggregateId, OccurredAt);
    public record MoneyDeposited(Guid AggregateId, decimal Amount, DateTime OccurredAt) 
        : DomainEvent(AggregateId, OccurredAt);
    public record MoneyWithdrawn(Guid AggregateId, decimal Amount, DateTime OccurredAt) 
        : DomainEvent(AggregateId, OccurredAt);
    
    // Aggregate that rebuilds state from events
    public class BankAccount
    {
        public Guid Id { get; private set; }
        public string Owner { get; private set; }
        public decimal Balance { get; private set; }
        private List _uncommittedEvents = [];
    
        public static BankAccount FromHistory(IEnumerable events)
        {
            var account = new BankAccount();
            foreach (var evt in events)
                account.ApplyEvent(evt, isNew: false);
            return account;
        }
    
        public void CreateAccount(string owner)
        {
            var evt = new AccountCreated(Guid.NewGuid(), owner, DateTime.UtcNow);
            ApplyEvent(evt, isNew: true);
        }
    
        public void Deposit(decimal amount)
        {
            if (amount <= 0) throw new ArgumentException("Amount must be positive");
            var evt = new MoneyDeposited(Id, amount, DateTime.UtcNow);
            ApplyEvent(evt, isNew: true);
        }
    
        public void Withdraw(decimal amount)
        {
            if (amount > Balance) throw new InvalidOperationException("Insufficient funds");
            var evt = new MoneyWithdrawn(Id, amount, DateTime.UtcNow);
            ApplyEvent(evt, isNew: true);
        }
    
        private void ApplyEvent(DomainEvent evt, bool isNew)
        {
            switch (evt)
            {
                case AccountCreated ac:
                    Id = ac.AggregateId;
                    Owner = ac.Owner;
                    Balance = 0;
                    break;
                case MoneyDeposited md:
                    Balance += md.Amount;
                    break;
                case MoneyWithdrawn mw:
                    Balance -= mw.Amount;
                    break;
            }
            if (isNew) _uncommittedEvents.Add(evt);
        }
    
        public IReadOnlyList GetUncommittedEvents() => _uncommittedEvents.AsReadOnly();
    }

    This example shows how an aggregate reconstructs its state by replaying events; persisting events instead of just the final balance provides complete history for auditing and temporal queries.

    Key points to mention

    • Audit and compliance: Event sourcing naturally satisfies regulatory requirements for immutable audit trails without separate logging infrastructure.
    • Temporal queries: You can answer "what was the state at time T?" by replaying events up to that timestamp, impossible with traditional state-only storage.
    • Event versioning: As your domain evolves, old events must still be interpretable; handle this with upcasting or event adapters to avoid replaying failure.
    • Projection complexity: Building and maintaining read models (often following CQRS patterns) adds operational complexity; eventual consistency may impact user experience.
    • Avoid for simple systems: If you have basic CRUD with minimal audit needs and no temporal queries, traditional databases are simpler and faster.

    Common follow-up questions

    • How do you handle event versioning when your domain model changes?
    • What's the difference between event sourcing and event-driven systems, and when would you use each?
    • How do you manage performance and storage when replaying thousands or millions of events?

    Related interview questions

    Further reading

    .NETCsharpArchitecture
  284. 284How do you version events safely?

    Expert

    Event versioning requires adding new fields as optional, never redefining existing field meanings, maintaining a version or schema identifier, and using upcasters to transform old events into new formats during deserialization. This ensures that old events remain readable and valid as your event schema evolves, preventing data loss and maintaining event store integrity in event sourcing systems.

    How it works

    Event versioning is critical in event sourcing because once an event is written to the event store, you cannot modify it—you can only add to the store going forward. The challenge is that your application logic inevitably changes: new fields become necessary, business rules shift, and event structures need refinement. Versioning solves this by establishing a contract that allows both old and new consumers to coexist.

    The core strategy involves three practices:

    1. Add optional fields only: When you need to capture new information, add it as an optional field with a sensible default. Old events deserializing into the new schema simply receive the default value, ensuring no schema mismatch errors.

    2. Never change field semantics: If a field exists and worked correctly in version 1, never reinterpret what it means. If the meaning needs to change fundamentally, create a new field instead. This prevents silent data corruption where old events are misinterpreted.

    3. Use upcasters: An upcaster is a transformation function that converts an old event version into the new version before your domain logic processes it. During deserialization, the upcaster runs automatically, ensuring your application always works with current event shapes while respecting historical data.

    4. Include version metadata: Store a version number or schema identifier with every event. This allows the event store and upcasters to identify which version they're dealing with and apply the correct transformation chain.

    Example

    csharp
    // Event base with versioning
    public abstract record Event
    {
        public int SchemaVersion { get; init; } = 1;
    }
    
    // Version 1: Original event
    public record OrderCreatedV1(
        Guid OrderId,
        string CustomerName,
        decimal Amount
    ) : Event;
    
    // Version 2: Added optional field for customer email
    public record OrderCreatedV2(
        Guid OrderId,
        string CustomerName,
        decimal Amount,
        string? CustomerEmail = null
    ) : Event
    {
        public override int SchemaVersion => 2;
    }
    
    // Upcaster to transform V1 → V2
    public class OrderCreatedUpcaster
    {
        public Event Upcast(Event @event) => @event switch
        {
            OrderCreatedV1 v1 => new OrderCreatedV2(
                v1.OrderId,
                v1.CustomerName,
                v1.Amount,
                CustomerEmail: null  // Default for missing field
            ),
            _ => @event
        };
    }
    
    // Deserializer that applies upcasters
    public class EventDeserializer
    {
        private readonly OrderCreatedUpcaster _upcaster = new();
    
        public Event Deserialize(string json, int schemaVersion)
        {
            var baseEvent = schemaVersion switch
            {
                1 => JsonSerializer.Deserialize(json)!,
                2 => JsonSerializer.Deserialize(json)!,
                _ => throw new InvalidOperationException($"Unknown version {schemaVersion}")
            };
    
            return _upcaster.Upcast(baseEvent);
        }
    }
    
    // Usage
    var oldEventJson = """{"orderId":"...", "customerName":"Alice", "amount":100.50}""";
    var deserializer = new EventDeserializer();
    var upcastedEvent = deserializer.Deserialize(oldEventJson, schemaVersion: 1);
    
    // upcastedEvent is now OrderCreatedV2 with CustomerEmail = null
    Console.WriteLine(upcastedEvent);  // OrderCreatedV2 { ..., CustomerEmail = null }
    

    This example demonstrates versioning a simple order event: old V1 events are automatically transformed into V2 shape during deserialization, adding the new optional email field while preserving all original data.

    Key points to mention

    • Immutability matters: Events in the store are immutable; versioning lets you evolve the schema without rewriting history.
    • Upcasters form a pipeline: As versions accumulate (V1 → V2 → V3), chain upcasters to transform events through intermediate versions, ensuring each step is logical and testable.
    • Default values are essential: Optional fields must have sensible defaults; null or default(T) are typical choices for missing data in old events.
    • Document field semantics: Record what each field means in comments or documentation; this prevents future developers from accidentally changing field meaning.
    • Version everything consistently: Use a single version number or timestamp per event; avoid separate versioning per field, which becomes unmanageable.

    Common follow-up questions

    • What if I need to remove a field? Never truly remove it; mark it as deprecated, stop populating it in new events, but keep upcasters honoring it for old events. This prevents breaking old data readers.
    • How do you test upcasters? Write unit tests that serialize old event versions, run them through upcasters, and verify the output matches the expected new schema and retains all original data.
    • How does this relate to API versioning? See How do you approach API backward compatibility? Event versioning is similar but applies to domain events in your store rather than HTTP contracts.

    Related interview questions

    .NETCsharpArchitecture
  285. 285How do you scale reads with EF Core and SQL Server or PostgreSQL?

    Expert

    To scale reads with EF Core, use read replicas with separate read-only DbContexts, implement compiled queries and projections to minimize data transfer, cache frequently accessed data, and ensure proper indexing on read replicas. Route all writes to the primary database while distributing read operations across replicas. This architecture separates read and write concerns, allowing independent scaling of each workload.

    How it works

    Read scaling with EF Core involves distributing query load across multiple database instances. Your primary database handles all writes (inserts, updates, deletes), while read replicas—exact copies of the primary—handle queries. EF Core connects to replicas through a separate, read-only DbContext configured with a replica connection string. To maximize efficiency, use compiled queries (EF.CompileAsyncQuery) which parse and cache the query plan, reducing per-execution overhead. Projections (using .Select()) limit data fetched to only required columns, reducing I/O and network bandwidth. Caching layers (in-memory or distributed like Redis) eliminate redundant database hits for hot data. Indexes on replicas are identical to the primary but can be tuned specifically for common read patterns. Connection pooling ensures efficient reuse of database connections.

    Example

    csharp
    // Read-only DbContext configured for replica
    public class ReadOnlyProductContext : DbContext
    {
        public DbSet<Product> Products { get; set; }
    
        protected override void OnConfiguring(DbContextOptionsBuilder options)
        {
            // Connection string points to read replica
            options.UseSqlServer("Server=replica.azure.com;Database=Shop;");
        }
    }
    
    // Compiled query for fast repeated execution
    public class ProductQueries
    {
        private static readonly Func<ReadOnlyProductContext, int, Task<ProductDto>> GetProductByIdCompiled =
            EF.CompileAsyncQuery((ReadOnlyProductContext ctx, int id) =>
                ctx.Products
                    .Where(p => p.Id == id)
                    .Select(p => new ProductDto { Id = p.Id, Name = p.Name, Price = p.Price })
                    .FirstOrDefault());
    
        public static Task<ProductDto> GetProductById(ReadOnlyProductContext ctx, int id)
        {
            return GetProductByIdCompiled(ctx, id);
        }
    }
    
    // Usage in service
    public class ProductService
    {
        private readonly ReadOnlyProductContext _readContext;
        private readonly IDistributedCache _cache;
    
        public ProductService(ReadOnlyProductContext readContext, IDistributedCache cache)
        {
            _readContext = readContext;
            _cache = cache;
        }
    
        public async Task<ProductDto> GetProductAsync(int id)
        {
            // Check cache first
            var cached = await _cache.GetStringAsync($"product:{id}");
            if (cached != null)
                return JsonSerializer.Deserialize<ProductDto>(cached);
    
            // Query replica using compiled query
            var product = await ProductQueries.GetProductById(_readContext, id);
    
            // Cache for 5 minutes
            await _cache.SetStringAsync($"product:{id}", 
                JsonSerializer.Serialize(product), 
                new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) });
    
            return product;
        }
    }
    

    This example demonstrates a read-only DbContext pointing to a replica, a compiled query for efficient repeated execution with projections limiting columns, and caching to further reduce replica hits.

    Key points to mention

    • Separation of concerns: Write operations always use the primary; reads exclusively use replicas, preventing contention and enabling independent scaling of read workloads.
    • Compiled queries: EF.CompileAsyncQuery pre-parses the LINQ expression into SQL, eliminating compilation overhead on every execution—critical for high-volume reads.
    • Projections reduce data transfer: Using .Select() to return only required columns minimizes network bandwidth and memory pressure compared to materializing full entities.
    • Replication lag awareness: Read replicas may lag behind the primary by milliseconds to seconds; critical reads requiring latest data may need to query the primary, necessitating read-write splitting logic.
    • Connection pooling and indexing: Configure adequate connection pool size for replicas and create indexes tailored to read query patterns, not just mirroring primary indexes.

    Common follow-up questions

    • How do you handle eventual consistency when replicas lag? Implement read-write splitting: route transactional or strongly-consistent queries to the primary, cache-friendly reads to replicas. Use event-driven cache invalidation to sync replicas quickly.
    • What's the difference between this and CQRS? Read replica scaling is an infrastructure pattern using separate database instances; CQRS is an architectural pattern separating command and query models, often with entirely different data stores optimized per concern.
    • How do you monitor replica lag in production? SQL Server and PostgreSQL expose replication metrics; query sys.dm_database_replica_states or pg_stat_replication. Set alerts when lag exceeds acceptable thresholds and route queries to primary dynamically.

    Related interview questions

    Further reading

    .NETCsharpefcore
  286. 286How do you troubleshoot slow EF Core queries in production?

    Expert

    Troubleshooting slow EF Core queries requires a multi-layered approach: enable query logging with durations, examine the generated SQL and execution plans, identify missing indexes, and audit tracking and lazy-loading overhead. In production, you must balance diagnostic detail with performance impact, often using Application Insights or SQL Server Extended Events to capture telemetry without degrading throughput.

    How it works

    EF Core query performance issues typically stem from one of several root causes. First, the ORM may generate inefficient SQL—N+1 queries, unnecessary JOINs, or SELECT * when only specific columns are needed. Second, the database itself may lack indexes or suffer from parameter sniffing and plan cache issues. Third, EF Core's change tracking and lazy loading can load far more data than needed into memory.

    The diagnostic workflow starts with SQL logging: configure a logger to capture generated SQL and execution time. Next, inspect the SQL in SQL Server Management Studio or your database tool and run EXPLAIN ANALYZE to see the execution plan. Look for full table scans, missing indexes, or inefficient predicates. Then test with raw SQL to confirm the database can execute a better query efficiently. Finally, apply fixes: add explicit loading, use projections with Select, disable tracking for read-only queries, or refactor to raw SQL when the ORM overhead is unavoidable.

    Example

    csharp
    // Enable SQL logging with Serilog
    var optionsBuilder = new DbContextOptionsBuilder<MyDbContext>()
        .LogTo(Console.WriteLine, LogLevel.Information)
        .ConfigureWarnings(w => w.Throw(RelationalEventId.MultipleCollectionIncludeWarning));
    
    // Slow: N+1 problem with lazy loading
    var orders = dbContext.Orders.ToList();
    foreach (var order in orders)
    {
        Console.WriteLine(order.Customer.Name); // Lazy load per iteration
    }
    
    // Better: Explicit eager loading
    var ordersWithCustomers = dbContext.Orders
        .Include(o => o.Customer)
        .AsNoTracking() // Disable tracking for read-only queries
        .ToList();
    
    // Best: Projection to load only needed columns
    var orderSummaries = dbContext.Orders
        .Select(o => new { o.Id, CustomerName = o.Customer.Name, o.Total })
        .AsNoTracking()
        .ToList();
    
    // Log query duration explicitly
    var sw = Stopwatch.StartNew();
    var results = dbContext.Orders.Where(o => o.Total > 1000).ToList();
    sw.Stop();
    logger.LogWarning("Query took {Duration}ms", sw.ElapsedMilliseconds);

    This example demonstrates three escalating improvements: identifying the N+1 problem via logs, using Include and AsNoTracking, and finally switching to projections to minimize data transfer and memory allocation.

    Key points to mention

    • Enable SQL logging with LogTo or Application Insights to capture generated SQL and timing in production safely.
    • Always examine the actual execution plan in the database; what looks inefficient in C# may compile to efficient SQL, and vice versa.
    • Use AsNoTracking() for read-only queries to avoid change-tracking overhead, which can be significant with large result sets.
    • Check for missing indexes on frequently filtered or joined columns; EF Core generates valid SQL but cannot optimize missing database statistics.
    • Consider raw SQL or Dapper for complex queries where EF Core's abstraction adds measurable overhead; measure first, then decide.

    Common follow-up questions

    • How do you monitor EF Core query performance in production? – Use Application Insights, Azure Monitor, or Serilog with structured logging to track query duration and exception rates without blocking the request path.
    • What is the difference between Include and explicit loading? – Include loads related data in a single query via JOIN or separate SELECT; explicit loading uses lazy-load triggers. Include is generally faster but requires more memory.
    • How does AsNoTracking improve performance? – It disables the identity map and change detector, saving memory and CPU for read-only scenarios; use it whenever you don't need to update entities.

    Related interview questions

    Further reading

    .NETCsharpefcore
  287. 287What is parameter sniffing and how does it affect .NET apps?

    Expert

    Parameter sniffing occurs when SQL Server caches an execution plan based on the parameter values submitted with the first query execution, then reuses that same plan for subsequent executions with different parameter values—often producing suboptimal performance. This is a form of query performance degradation that causes intermittent slowness in .NET applications and is particularly common when data distribution is skewed.

    How it works

    When SQL Server receives a parameterized query for the first time, the query optimizer analyzes the parameter values provided and generates an execution plan optimized for those specific values. This plan is then cached in the procedure cache. On subsequent executions, SQL Server reuses the cached plan without re-optimizing, even if the new parameter values have vastly different characteristics.

    For example, if a column has highly skewed data (10% of rows match value A, 90% match value B), the first execution with value A might use an index seek, producing an efficient plan. But when the same query runs with value B, the cached plan still uses an index seek, which now scans most of the table—far less efficient than a table scan or different index strategy would be.

    This reuse was designed for performance (avoiding repeated compilation), but it backfires when data distribution is uneven or query selectivity varies significantly across parameter values.

    Example

    csharp
    // EF Core query that demonstrates parameter sniffing risk
    using var context = new OrderContext();
    
    // First execution: searches for a rare VIP status
    // SQL Server caches a plan optimized for low cardinality
    var vipOrders = context.Orders
        .Where(o => o.Status == "VIP")
        .ToList();
    
    // Subsequent execution: searches for "Normal" status (90% of data)
    // Reuses the cached plan optimized for rare VIP, causing slowness
    var normalOrders = context.Orders
        .Where(o => o.Status == "Normal")
        .ToList();
    

    The first query caches an index seek plan; the second query uses the same cached plan and performs poorly because "Normal" should ideally use a table scan or different strategy.

    Key points to mention

    • Plan cache reuse: SQL Server caches compiled plans by query shape and query hash, reusing them for identical queries with different parameters to save compilation overhead.
    • Skewed data is the culprit: Parameter sniffing only becomes a performance problem when data distribution is uneven or selectivity differs greatly between parameter values.
    • Intermittent nature: Symptoms are unpredictable slowness that depends on which execution reached the server first and populated the cache, making it difficult to reproduce consistently.
    • Mitigation strategies: Use query hints (RECOMPILE, OPTIMIZE FOR), force plan guides, add filtered statistics on skewed columns, or redesign queries to be less sensitive to parameter values.
    • EF Core consideration: EF Core translates LINQ to SQL, so parameter sniffing issues occur at the SQL Server level; be aware when using .FromSqlInterpolated() or raw SQL queries.

    Common follow-up questions

    • How do you detect parameter sniffing in production? Monitor query execution times with Extended Events or Query Store; look for high variance in Duration for the same query shape. Use DBCC FREEPROCCACHE in a test environment to clear the plan cache and rerun—if performance improves on the second execution, sniffing is likely.
    • What is the difference between parameter sniffing and other EF Core performance issues? Parameter sniffing is a SQL Server plan-caching behavior; it differs from N+1 query problems, which are caused by application-level iteration patterns in LINQ.
    • When should you use RECOMPILE vs. OPTIMIZE FOR hints? Use RECOMPILE when you want a fresh plan every execution (costly but safe); use OPTIMIZE FOR (@param = value) to lock the plan to a specific parameter value that represents typical workload distribution.

    Related interview questions

    .NETCsharpefcore
  288. 288How do you implement bulk operations efficiently?

    Expert

    Bulk operations are performed efficiently by bypassing the change tracker (using ExecuteUpdate and ExecuteDelete), leveraging database-native bulk operations like SqlBulkCopy or PostgreSQL COPY, and batching changes to minimize round trips. For large-scale inserts, third-party libraries like EFCore.BulkExtensions provide optimized implementations that significantly outperform traditional DbContext.SaveChanges() loops.

    How it works

    Bulk operations work by operating directly on the database rather than loading entities into memory and tracking changes through the DbContext. Traditional EF Core patterns load every entity, attach it to the change tracker, and execute individual INSERT/UPDATE/DELETE statements—prohibitively slow for thousands of records. Bulk approaches instead:

    1. Skip the change tracker: ExecuteUpdate and ExecuteDelete generate single SQL statements affecting multiple rows, avoiding entity materialization.

    2. Use native database bulk loading: SqlBulkCopy streams data directly into SQL Server; PostgreSQL's COPY command bypasses transaction logging overhead.

    3. Batch and pipeline: Send multiple operations in batches, reducing network latency and context overhead.

    4. Leverage third-party optimizations: Libraries like EFCore.BulkExtensions wrap these techniques with automatic batching, indexing strategies, and identity management for inserts.

    Example

    csharp
    // Scenario: Insert 100,000 product records efficiently
    
    // Option 1: ExecuteUpdate/ExecuteDelete (EF Core 7+) for updates/deletes
    var affectedRows = await dbContext.Products
        .Where(p => p.Price < 10)
        .ExecuteUpdateAsync(s => s
            .SetProperty(p => p.Discount, p => p.Discount + 0.1m));
    
    // Option 2: Bulk insert using SqlBulkCopy (SQL Server)
    var products = Enumerable.Range(1, 100000)
        .Select(i => new Product { Name = $"Product {i}", Price = i * 1.5m })
        .ToList();
    
    using (var bulkCopy = new SqlBulkCopy(dbContext.Database.GetDbConnection() as SqlConnection))
    {
        bulkCopy.DestinationTableName = "Products";
        bulkCopy.BatchSize = 5000;
        
        var table = new DataTable();
        table.Columns.Add("Name");
        table.Columns.Add("Price");
        foreach (var p in products)
            table.Rows.Add(p.Name, p.Price);
        
        await bulkCopy.WriteToServerAsync(table);
    }
    
    // Option 3: EFCore.BulkExtensions (handles identity + auto-batching)
    await dbContext.BulkInsertAsync(products, options => 
    {
        options.BatchSize = 5000;
        options.UseTransaction = true;
    });
    

    The first approach uses a single SQL UPDATE statement; the second streams raw data directly into SQL Server; the third abstracts complexity with automatic batching and identity management.

    Key points to mention

    • Change tracker overhead: Avoid loading large datasets into memory or the change tracker—use ExecuteUpdate/ExecuteDelete for direct database modifications.
    • Database-native operations: SqlBulkCopy, PostgreSQL COPY, and MySQL LOAD DATA are orders of magnitude faster than parameterized inserts.
    • Batching: Divide large operations into batches (typically 5,000–10,000 rows) to balance memory use and performance.
    • Identity and concurrency: Libraries like EFCore.BulkExtensions handle identity column management and pessimistic locking automatically; manual SqlBulkCopy requires careful planning.
    • Trade-offs: Bulk operations bypass validation, triggers, and change tracking—ensure business logic is enforced at the database level or before bulk execution.

    Common follow-up questions

    • How do you handle identity columns in bulk inserts? – Use SqlBulkCopy's SqlBulkCopyOptions.KeepIdentity to preserve source IDs, or rely on EFCore.BulkExtensions' automatic identity retrieval post-insert.
    • What's the performance difference between ExecuteUpdate and a loop with SaveChanges()? – ExecuteUpdate generates a single SQL statement (typically 50–100x faster); SaveChanges() loops require one round trip per entity.
    • How do you ensure data integrity during bulk operations? – Wrap operations in transactions, pre-validate data, use database constraints, and test rollback scenarios.

    Related interview questions

    Further reading

    .NETCsharpefcore
  289. 289How do you manage zero-downtime database migrations?

    Expert

    Zero-downtime database migrations use the expand-and-contract pattern: add new schema elements, deploy code that writes to both old and new structures simultaneously, backfill historical data, switch reads to the new structure, then remove the old structure in a later release. This decouples schema changes from deployments, eliminating downtime.

    How it works

    The expand-and-contract pattern splits a breaking database change into three phases:

    Phase 1 (Expand): Add the new column, table, or index without removing the old one. Deploy code that writes to both simultaneously. The application is unaware of the migration—it simply writes redundantly. At this point, reads still come from the old structure.

    Phase 2 (Migrate): Backfill the new structure with historical data from the old one. For large tables, this may happen asynchronously or in batches to avoid locking. Once backfill completes, deploy a code change that reads from the new structure while still writing to both.

    Phase 3 (Contract): After confirming correctness and allowing time for rollback if needed, remove the old structure in a subsequent release. By then, only the new structure is active.

    This approach ensures the database schema is never incompatible with running code. You can roll back at any phase by reverting the code change, and no readers or writers are ever blocked.

    Example

    csharp
    // Phase 1: Expand – add new column, write to both
    public class UserService
    {
        private readonly DbContext _db;
    
        public async Task UpdateUserEmailAsync(int userId, string newEmail)
        {
            var user = await _db.Users.FindAsync(userId);
            user.Email = newEmail;
            user.EmailV2 = newEmail;  // Write to both during transition
            await _db.SaveChangesAsync();
        }
    }
    
    // Phase 2: Migrate & contract – read from new, write to both
    public class UserQueryService
    {
        private readonly DbContext _db;
    
        public async Task GetUserEmailAsync(int userId)
        {
            var user = await _db.Users.FindAsync(userId);
            return user.EmailV2 ?? user.Email;  // Read from new with fallback
        }
    }
    
    // Backfill job (run once, offline-safe)
    public class EmailMigrationBackfillJob
    {
        private readonly DbContext _db;
    
        public async Task BackfillEmailV2Async()
        {
            await _db.Database.ExecuteSqlAsync(
                "UPDATE Users SET EmailV2 = Email WHERE EmailV2 IS NULL");
        }
    }
    
    // Phase 3: Contract – remove EmailV2, rename Email (in next release)
    // No code changes needed if EmailV2 was already the read source
    

    This example shows dual-write and fallback reads: the old Email column is still written but EmailV2 (the new structure) is read first, with a safe fallback for null values during backfill.

    Key points to mention

    • Decouples schema from code: Database schema changes and application deployments happen independently, so you avoid coordination windows and downtime.
    • Safe rollback: If a deployed code change causes issues, revert the application code; the database schema remains backward-compatible.
    • Backfill strategy matters: For large tables, batch updates or async jobs prevent locking production queries. Consider off-peak windows or gradual migration.
    • Testing is critical: Validate data consistency between old and new structures during the migration phase to catch corruption early.
    • Documentation and monitoring: Track which phase you're in and monitor both read and write paths to detect dual-write bugs or data divergence.

    Common follow-up questions

    • How do you handle dual-write bugs? – If the old and new paths diverge, use a consistency check job (compare counts, checksums) to detect issues before removing the old structure. Consider feature flags to disable new writes if problems arise.
    • Does this work with ORM migrations? – EF Core migrations can generate the expand phase, but you must manually code the dual-write logic and backfill jobs. Some teams disable auto-migrations in production for this reason.
    • What about foreign key changes or renames? – Renames are trickier; consider creating a new column/table and treating the old as deprecated. Foreign key constraints may require intermediate views or careful ordering of operations across related tables.

    Related interview questions

    .NETCsharpefcore
  290. 290How do you design resilient service-to-service communication?

    Expert

    Resilient service-to-service communication requires a multi-layered strategy combining timeouts, retries with exponential backoff and jitter, circuit breakers, bulkheads, fallbacks, and distributed deadline propagation. This ensures that transient failures don't cascade, that resources are protected, and that the overall system degrades gracefully under stress rather than failing catastrophically.

    How it works

    Service-to-service communication is vulnerable to network latency, temporary service unavailability, and resource exhaustion. A resilient design addresses each failure mode:

    Timeouts prevent indefinite hangs by bounding how long a caller will wait. Retries with exponential backoff and jitter recover from transient failures without overwhelming a recovering service—jitter randomizes retry delays to prevent thundering herd problems. Circuit breakers stop sending requests to a failing service after a threshold, fast-failing instead and allowing the service time to recover. Bulkheads isolate thread pools or connection pools per dependency so one slow service doesn't starve others. Fallbacks provide degraded functionality when a service is unavailable, and deadline propagation ensures child calls respect parent timeouts, preventing resource waste on requests destined to timeout anyway.

    Together, these patterns create a system where temporary blips don't trigger cascading failures, overloaded services get breathing room, and clients fail fast and predictably.

    Example

    csharp
    var resilientPolicy = Policy
        .Handle<HttpRequestException>()
        .Or<OperationCanceledException>()
        .OrResult<HttpResponseMessage>(r => (int)r.StatusCode >= 500)
        .WaitAndRetryAsync(
            retryCount: 3,
            sleepDurationProvider: retryAttempt =>
                TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)) 
                + TimeSpan.FromMilliseconds(Random.Shared.Next(0, 100)),
            onRetry: (outcome, timespan, attempt, context) =>
                Console.WriteLine($"Retry {attempt} after {timespan.TotalSeconds}s"))
        .WrapAsync(
            Policy.CircuitBreakerAsync<HttpResponseMessage>(
                handledEventsAllowedBeforeBreaking: 5,
                durationOfBreak: TimeSpan.FromSeconds(30),
                onBreak: (outcome, duration) =>
                    Console.WriteLine($"Circuit broken for {duration.TotalSeconds}s")))
        .WrapAsync(
            Policy.BulkheadAsync<HttpResponseMessage>(
                maxParallelization: 10,
                maxQueuingActions: 20))
        .WrapAsync(
            Policy.TimeoutAsync<HttpResponseMessage>(TimeSpan.FromSeconds(5)));
    
    using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
    cts.CancelAfter(TimeSpan.FromSeconds(5));
    
    var response = await resilientPolicy.ExecuteAsync(
        ct => httpClient.GetAsync("https://service/api/data", ct),
        cts.Token);
    

    This uses Polly to stack timeout, bulkhead, circuit breaker, and retry policies with exponential backoff and jitter, with cancellation propagated through linked tokens to ensure deadlines are respected end-to-end.

    Key points to mention

    • Only retry idempotent operations; for non-idempotent calls, ensure the service is designed to be idempotent or use request deduplication.
    • Jitter is critical—without it, all retrying clients hammer a recovering service at the same times, causing thundering herd.
    • Circuit breakers should trip on both exceptions and application-level errors (5xx responses); configure break duration to give the service time to recover.
    • Bulkheads prevent one slow dependency from consuming all thread pool threads, protecting other services.
    • Propagate deadlines and cancellation tokens through the call stack so work is abandoned early if the parent timeout expires.
    • Monitor circuit breaker state, retry counts, and timeout rates; these are early warning signs of systemic problems.

    Common follow-up questions

    • How do you decide between a fallback and letting the error propagate? (Fallbacks mask availability problems; use them only for non-critical features or cached data.)
    • What's the difference between idempotent and non-idempotent operations in retry scenarios? (Idempotent ops can be safely retried; non-idempotent ones risk double-processing unless the service deduplicates by request ID.)
    • How does deadline propagation work with async/await and CancellationToken? (Use CancellationTokenSource.CreateLinkedTokenSource to combine parent and local timeouts, and always pass the token to async operations.)

    Related interview questions

    Further reading

    .NETCsharpPerformance
  291. 291What is a bulkhead?

    Expert

    A bulkhead is a resilience pattern that isolates critical resources—such as thread pools, connection pools, or concurrency limits—per dependency, preventing one failing or slow dependency from exhausting shared capacity and cascading failures across your entire system. Named after ship compartments that prevent flooding from spreading, bulkheads ensure fault isolation at the resource level.

    How it works

    In a distributed system, your application typically communicates with multiple external services: databases, payment gateways, third-party APIs, and internal microservices. Without isolation, if one dependency becomes slow or unresponsive, its requests accumulate and consume all available threads or connection pool slots, starving other dependencies and making your entire application unresponsive.

    A bulkhead partitions resources so each dependency gets its own limited allocation. When the PaymentService bulkhead is exhausted, it only rejects or delays PaymentService requests—UserService requests continue flowing normally. This is achieved by maintaining separate thread pools, connection pools, or semaphores per dependency. When a bulkhead reaches capacity, new requests are rejected immediately (fail-fast) or queued with a timeout, preventing thread starvation.

    Bulkheads work best paired with circuit breakers and retry policies. While a circuit breaker stops sending requests to a failing service, a bulkhead ensures that the attempt to send them doesn't starve other parts of your system. Together, they form a comprehensive fault-isolation strategy.

    Example

    csharp
    // Using Polly Bulkhead policy
    var bulkheadPolicy = Policy.BulkheadAsync(
        maxParallelization: 5,
        maxQuueueingActions: 10
    );
    
    var services = new ServiceCollection();
    services.AddHttpClient("PaymentService")
        .ConfigureHttpClient(client => client.BaseAddress = new Uri("https://payment-api.com"))
        .AddPolicyHandler(bulkheadPolicy);
    
    services.AddHttpClient("UserService")
        .ConfigureHttpClient(client => client.BaseAddress = new Uri("https://user-api.com"))
        .AddPolicyHandler(Policy.BulkheadAsync(maxParallelization: 10, maxQuueueingActions: 20));
    
    // In your application
    public class OrderService
    {
        private readonly HttpClient _paymentClient;
        private readonly HttpClient _userClient;
    
        public OrderService(IHttpClientFactory factory)
        {
            _paymentClient = factory.CreateClient("PaymentService");
            _userClient = factory.CreateClient("UserService");
        }
    
        public async Task ProcessOrderAsync(int orderId)
        {
            // PaymentService uses 5 max parallel; UserService uses 10
            // If PaymentService is slow, it won't consume UserService capacity
            var payment = await _paymentClient.PostAsync("/charge", content);
            var user = await _userClient.GetAsync($"/users/{orderId}");
        }
    }
    

    This code creates separate Polly bulkhead policies for PaymentService and UserService, each with its own max parallelization and queue limits, ensuring one service's overload doesn't starve the other.

    Key points to mention

    • Isolation scope: Bulkheads isolate by dependency (external service, database, queue), not globally—each gets its own resource partition.
    • Fail-fast behavior: When a bulkhead is exhausted, requests are rejected immediately with a BulkheadRejectedException, preventing thread buildup and enabling timeouts.
    • Thread pool vs. semaphore: Can be implemented as separate thread pools (isolation) or semaphores guarding a shared thread pool (simpler but less guaranteed).
    • Combination with other patterns: Bulkheads complement circuit breakers, timeouts, and retries—they prevent cascading failure before it reaches the service boundary.
    • Monitoring: Track bulkhead rejections and queue depth as key metrics to detect when dependencies are degrading or when your bulkhead limits need tuning.

    Common follow-up questions

    • How do you choose max parallelization and queue size? Start with baseline load testing; measure 95th percentile latency and throughput per dependency, then set bulkhead limits slightly above peak expected load with buffer for transient spikes.
    • What's the difference between a bulkhead and a circuit breaker? A bulkhead isolates resource capacity per dependency; a circuit breaker stops sending requests when a service is failing. Use both: bulkhead prevents starvation, circuit breaker prevents cascading failures.
    • Can you have nested bulkheads? Yes—you might have a bulkhead per HTTP client and another per database connection pool, creating layers of isolation for different resource types.

    Related interview questions

    Further reading

    .NETCsharpasyncPerformance
  292. 292How do you implement observability for a microservices system in .NET?

    Expert

    Microservices observability in .NET is implemented using OpenTelemetry to collect traces, metrics, and logs with consistent resource attributes, correlation IDs, and RED metrics (Rate, Errors, Duration) per service. Export these signals to a backend like Jaeger, Prometheus, or cloud providers, then build dashboards and alert on Service Level Objectives (SLOs) to catch issues before they impact users.

    How it works

    Observability comprises three pillars: traces show request flow across services, metrics track system health, and logs provide detailed events. In microservices, a single user request spans multiple services, making correlation IDs essential for tracking the full journey. OpenTelemetry provides a vendor-neutral SDK that instruments your code and exports to any backend.

    Each service should expose RED metrics: Request Rate (requests/sec), Error Rate (errors/sec), and Duration (latency percentiles). Consistent resource attributes (service name, version, environment, pod ID) allow correlation across logs, metrics, and traces. A central observability platform ingests all signals, enables querying across them, and triggers alerts when SLOs are breached.

    Example

    csharp
    var builder = WebApplication.CreateBuilder(args);
    
    // Configure OpenTelemetry
    var tracingOtlpEndpoint = builder.Configuration["Otel:TracingEndpoint"];
    var metricsOtlpEndpoint = builder.Configuration["Otel:MetricsEndpoint"];
    
    builder.Services
        .AddOpenTelemetry()
        .ConfigureResource(r => r
            .AddService(
                serviceName: "OrderService",
                serviceVersion: "1.0.0",
                serviceInstanceId: Environment.MachineName))
        .WithTracing(tracing => tracing
            .AddAspNetCoreInstrumentation()
            .AddHttpClientInstrumentation()
            .AddEntityFrameworkCoreInstrumentation()
            .AddOtlpExporter(opt => opt.Endpoint = new Uri(tracingOtlpEndpoint)))
        .WithMetrics(metrics => metrics
            .AddAspNetCoreInstrumentation()
            .AddHttpClientInstrumentation()
            .AddRuntimeInstrumentation()
            .AddOtlpExporter(opt => opt.Endpoint = new Uri(metricsOtlpEndpoint)));
    
    builder.Logging.AddOpenTelemetry(logging => logging
        .AddOtlpExporter(opt => opt.Endpoint = new Uri(metricsOtlpEndpoint)));
    
    var app = builder.Build();
    
    app.MapGet("/orders/{id}", async (int id, ILogger<Program> logger) => {
        logger.LogInformation("Fetching order {OrderId}", id);
        var order = await app.Services.GetRequiredService<OrderDbContext>()
            .Orders.FindAsync(id);
        return order;
    });
    
    app.Run();
    

    This code configures OpenTelemetry to export traces, metrics, and logs to OTLP endpoints, automatically instruments ASP.NET Core, HTTP clients, and EF Core, and adds resource attributes for service identification across the observability backend.

    Key points to mention

    • Use OpenTelemetry as a vendor-neutral standard; it works with Jaeger, Prometheus, Datadog, Azure Monitor, and others without code changes.
    • Propagate correlation IDs through the W3C Trace Context standard so requests are tracked across service boundaries automatically.
    • Implement RED metrics (Rate, Errors, Duration) and SLO-based alerting rather than alerting on raw thresholds; alert when you breach 99.9% availability, not when CPU hits 75%.
    • Tag all signals with consistent resource attributes (service name, version, environment, pod/instance ID) to enable fast root-cause analysis and correlation across logs, traces, and metrics.
    • Use sampling in production to reduce storage costs; sample all error traces and a percentage of successful ones, and store all metrics.

    Common follow-up questions

    • How do you handle sampling in high-volume systems, and what trade-offs exist? (Head-based vs. tail-based sampling, balancing cost vs. visibility.)
    • How do you correlate a customer complaint about slow orders with observability data? (Use correlation ID from trace to find all related logs, spans, and metrics; check service dependencies for bottlenecks.)
    • What metrics would you alert on to avoid alert fatigue? (Alert on SLO violations and error budgets, not raw thresholds; tune alert thresholds based on baseline behavior.)

    Related interview questions

    .NETCsharpArchitecture
  293. 293What is .NET Aspire?

    Expert

    .NET Aspire is an opinionated, cloud-native stack for building and running distributed applications with built-in observability, service orchestration, and seamless integration with databases, messaging systems, and cloud platforms. It provides an App Host for local development orchestration, service defaults for cross-cutting concerns, and component integrations that reduce boilerplate while enabling production-ready telemetry from day one.

    How it works

    .NET Aspire consists of three core components working together:

    App Host: A console application that orchestrates your entire distributed system locally. It discovers and manages service dependencies, assigns ports dynamically, and ensures services start in the correct order. The App Host mimics your production topology, allowing developers to test multi-service interactions without Docker or cloud infrastructure.

    Service Defaults: A shared NuGet package that configures observability across all services. It sets up OpenTelemetry for traces, metrics, and logs, integrates structured logging, and configures resilience patterns. Every service gets consistent instrumentation without repetitive configuration.

    Component Integrations: Pre-built connectors for databases (PostgreSQL, SQL Server, MongoDB), messaging (RabbitMQ, Azure Service Bus), caching (Redis), and other infrastructure. These handle connection strings, health checks, and container lifecycle management automatically.

    The orchestration works through a resource-based model where you declare services, containers, and dependencies declaratively, and Aspire handles wiring them together—assigning connection strings, managing startup order, and exposing dashboards for monitoring.

    Example

    csharp
    // Program.cs - App Host
    var builder = DistributedApplication.CreateBuilder(args);
    
    var postgres = builder
        .AddPostgres("postgres")
        .AddDatabase("products-db");
    
    var messaging = builder
        .AddRabbitMQ("messaging");
    
    var api = builder
        .AddProject<Projects.ProductApi>("product-api")
        .WithReference(postgres)
        .WithReference(messaging);
    
    builder
        .AddProject<Projects.OrderService>("order-service")
        .WithReference(api)
        .WithReference(messaging);
    
    builder.Build().Run();
    
    // ProductApi startup
    var builder = WebApplication.CreateBuilder(args);
    builder.AddServiceDefaults();
    builder.Services.AddNpgsql<ProductContext>("products-db");
    var app = builder.Build();
    app.MapDefaultEndpoints();
    app.Run();

    This example defines a distributed system with PostgreSQL and RabbitMQ; the App Host orchestrates both services, injects connection strings, and enables local debugging of the entire topology.

    Key points to mention

    • Local-first development: Run and debug multi-service architectures without containers or cloud deployments, dramatically speeding up the inner loop.
    • Built-in observability: OpenTelemetry, structured logging, and health checks are configured automatically; the Aspire dashboard provides real-time metrics and trace inspection.
    • Infrastructure as code: Declarative resource management removes manual connection-string wrangling and environment-specific configuration logic.
    • Production parity: The same resource definitions and service defaults that work locally translate to cloud deployments (Azure Container Apps, Kubernetes) with minimal changes.
    • Reduced boilerplate: Component integrations eliminate repetitive setup for databases, messaging, and logging across services.

    Common follow-up questions

    • How does .NET Aspire differ from Docker Compose? Aspire is tightly integrated with .NET and provides service discovery, dynamic port allocation, and observability dashboards out of the box. Docker Compose is more general-purpose but requires manual configuration for networking and logging.
    • Can I use Aspire in production? The App Host is for local development only; Aspire integrates with cloud platforms (Azure Container Apps, Kubernetes) via deployment extensions. Orchestration logic is expressed once and deployed to multiple targets.
    • Does Aspire replace Helm or Terraform? No—Aspire handles application-level orchestration and observability. You still use Helm or Terraform for infrastructure provisioning, but Aspire eliminates boilerplate service configuration and provides a unified development experience.

    Related interview questions

    Further reading

    .NETCsharpefcore
  294. 294How do you secure service-to-service calls?

    Expert

    Service-to-service security requires mutual authentication (mTLS) or OAuth 2.0 client credentials flow with short-lived tokens, combined with audience validation, network isolation, and careful token handling to prevent unauthorized access across service boundaries. Never reuse user tokens for inter-service calls, and always validate that tokens are intended for your specific service.

    How it works

    Service-to-service (S2S) authentication differs fundamentally from user authentication because there is no human involved and calls happen automatically at scale. Two primary patterns dominate:

    mTLS (Mutual TLS): Both client and server present X.509 certificates to each other. The operating system or service mesh (Kubernetes, Consul) validates these certificates before the TCP connection is established. This works at the transport layer and is transparent to application code. It's ideal for microservices in controlled environments where certificate infrastructure exists.

    OAuth 2.0 Client Credentials: Service A requests a token from an authorization server by presenting its client ID and client secret. The token (JWT) is short-lived (5–15 minutes) and includes an audience claim specifying the intended recipient service. Service B validates the token's signature, expiration, and audience before processing the request. This is more portable across cloud boundaries and doesn't require shared PKI.

    Both patterns should be combined with network segmentation (services communicate only over private networks) and audience validation (Service B must verify the token claims it as the target, preventing token reuse across services).

    Example

    csharp
    // Service A: Acquire and use a client credentials token
    public class ServiceAClient
    {
        private readonly HttpClient _httpClient;
        private readonly ITokenProvider _tokenProvider;
    
        public ServiceAClient(HttpClient httpClient, ITokenProvider tokenProvider)
        {
            _httpClient = httpClient;
            _tokenProvider = tokenProvider;
        }
    
        public async Task CallServiceBAsync()
        {
            // Get a fresh token for Service B
            var token = await _tokenProvider.GetTokenAsync("service-b");
            
            var request = new HttpRequestMessage(HttpMethod.Get, "https://service-b-internal/api/data");
            request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
            
            var response = await _httpClient.SendAsync(request);
            return await response.Content.ReadAsStringAsync();
        }
    }
    
    // ITokenProvider caches tokens and refreshes before expiry
    public interface ITokenProvider
    {
        Task GetTokenAsync(string audience);
    }
    
    // Service B: Validate incoming token
    builder.Services.AddAuthentication("Bearer")
        .AddJwtBearer("Bearer", options =>
        {
            options.Authority = "https://auth-server";
            options.Audience = "service-b";  // Validate this service is the intended recipient
            options.TokenValidationParameters.ValidateAudience = true;
            options.TokenValidationParameters.ValidateLifetime = true;
        });
    
    builder.Services.AddAuthorization(policy =>
        policy.AddPolicy("ServiceToService", p =>
            p.RequireAuthenticatedUser()));
    
    app.MapGet("/api/data", () => "Sensitive data")
        .RequireAuthorization("ServiceToService");
    

    This example shows Service A acquiring a short-lived token scoped to Service B, then Service B validating both the token signature and the audience claim before responding.

    Key points to mention

    • Short-lived tokens: Client credentials tokens should expire in 5–15 minutes; cache and refresh transparently via ITokenProvider.
    • Audience validation: Always validate the aud claim; prevents a compromised token for one service being used against another.
    • Network isolation: Use private networks, service meshes (Istio), or VPCs; S2S calls should never traverse the public internet.
    • Never share user tokens: User access tokens are for the user's session; create separate service credentials for backend calls. Sharing violates the principle of least privilege and auditing.
    • Rotate credentials: Client secrets and certificates must be rotated regularly; use secrets vaults (Azure Key Vault, HashiCorp Vault) and automated rotation policies.

    Common follow-up questions

    • "Should we use mTLS or OAuth for our microservices?" mTLS is simpler and more transparent if you have PKI infrastructure and operate in a single cluster; OAuth is more flexible for multi-cloud, multi-organization scenarios. Many teams use both—OAuth for authentication at ingress, mTLS for internal mesh communication.
    • "How do we handle token revocation if a service is compromised?" Short token lifetimes (5–15 min) naturally limit exposure. For immediate revocation, implement a token blacklist or use refresh token revocation on the auth server side; compromised services won't be able to obtain new tokens.
    • "What about rate limiting and distributed tracing across S2S calls?" Add correlation IDs to every S2S request header and propagate them through logs; use API gateways or service meshes to enforce per-service rate limits and quotas based on client identity.

    Related interview questions

    Further reading

    .NETCsharpSecurity
  295. 295How do you implement token exchange or on-behalf-of flows?

    Expert

    Token exchange (OBO—on-behalf-of flow) allows a middle-tier service to exchange a user's incoming token for a new token scoped to a downstream API, preserving the user's identity while applying least-privilege access. This is implemented via OAuth 2.0 on-behalf-of grant type at your identity provider (Azure AD, Auth0, etc.). The middle service never sees the user's password and acts as a trusted client while maintaining an audit trail of the original user.

    How do you implement token exchange or on-behalf-of flows diagram

    How it works

    In a typical three-tier architecture, a client authenticates to Service A with a user token. Service A needs to call Service B on behalf of that user. Rather than passing the original token (which may have scopes for Service A only), Service A exchanges that token with the identity provider using the on-behalf-of grant. The identity provider validates the incoming token, confirms Service A is a trusted client, and issues a new token scoped only for Service B. This preserves the user context while enforcing granular permissions at each layer.

    The flow involves:

    1. Client authentication: User authenticates to Service A and receives a token with Service A scopes.
    2. Service-to-service call: Service A receives a request and must call Service B on behalf of the user.
    3. Token exchange request: Service A sends the user's token to the identity provider along with Service A's client credentials and requests a token for Service B.
    4. Identity provider validation: The IdP validates both the user token and Service A's client identity, then issues a new token for Service B with appropriate scopes.
    5. Downstream call: Service A calls Service B using the new token; Service B sees the original user's identity in the token claims.
    6. Audit trail: All exchanges are logged at the identity provider, creating a complete audit trail.

    Example

    csharp
    public class TokenExchangeService
    {
        private readonly IConfidentialClientApplication _app;
        private readonly string _scopes;
    
        public TokenExchangeService(IConfidentialClientApplication app, string scopes)
        {
            _app = app;
            _scopes = scopes;
        }
    
        public async Task<string> ExchangeTokenAsync(string userAssertionToken)
        {
            try
            {
                var result = await _app.AcquireTokenOnBehalfOf(
                    scopes: new[] { _scopes },
                    userAssertion: userAssertionToken)
                    .ExecuteAsync();
    
                return result.AccessToken;
            }
            catch (MsalServiceException ex)
            {
                throw new InvalidOperationException("Token exchange failed.", ex);
            }
        }
    }
    
    // Usage in a controller
    [ApiController]
    [Route("api/[controller]")]
    public class ProxyController : ControllerBase
    {
        private readonly TokenExchangeService _exchangeService;
        private readonly HttpClient _httpClient;
    
        [Authorize]
        [HttpGet("downstream")]
        public async Task<IActionResult> GetDownstreamData()
        {
            var incomingToken = User.FindFirst("token_value")?.Value;
            var newToken = await _exchangeService.ExchangeTokenAsync(incomingToken);
    
            _httpClient.DefaultRequestHeaders.Authorization = 
                new AuthenticationHeaderValue("Bearer", newToken);
    
            var response = await _httpClient.GetAsync("https://downstream-api/data");
            return Ok(await response.Content.ReadAsStringAsync());
        }
    }
    

    This example uses MSAL's AcquireTokenOnBehalfOf to exchange an incoming user token for a new token scoped to a downstream service, then includes it in the authorization header.

    Key points to mention

    • Least privilege: Each service receives only the scopes it needs, not all scopes from the original user token.
    • Client credentials required: The middle service must be registered as a confidential client (with client secret or certificate) to perform the exchange; you'll store these securely using configuration or Azure Key Vault.
    • Token caching: Cache exchanged tokens with appropriate expiry to avoid excessive IdP calls and improve performance.
    • Error handling: Distinguish between transient failures (retry) and permanent auth failures (return 401); MsalServiceException provides detailed error codes.
    • Audit trail: The identity provider logs the original user, the requesting service, and the target service—critical for compliance and security investigations.

    Common follow-up questions

    • How do you handle token refresh in OBO flows? The exchanged token is typically short-lived; cache it and re-exchange only when expired, using the original user token (which may also need refresh via a refresh token).
    • What if the identity provider doesn't support on-behalf-of? Some providers use assertion grants or custom flows; alternatively, use service-to-service (client credentials) flow if user context isn't strictly necessary, or implement a custom token proxy.
    • How do you prevent token replay or misuse? Configure token binding, IP restrictions, and mutual TLS (mTLS) at the identity provider and enforce strict certificate validation on client credentials.

    Related interview questions

    Further reading

    How do you implement token exchange or on-behalf-of flows?
    .NETCsharpSecurity
  296. 296How do you protect against mass assignment in APIs?

    Expert

    Mass assignment protection requires binding user input to explicit, narrowly-scoped DTOs rather than directly to entity models, whitelisting only the fields clients are permitted to modify, and validating ownership and authorization before applying any changes. This prevents attackers from injecting unexpected properties—such as admin flags or role assignments—that the API developer never intended to be updatable.

    How it works

    Mass assignment vulnerabilities occur when an API accepts raw request payloads and automatically maps all submitted properties onto database entities. An attacker can then submit extra fields (like isAdmin: true or roleId: 999) that were never meant to be client-controllable, potentially escalating their privileges or corrupting data.

    The core defense is explicit input validation through DTOs. Instead of accepting a user entity directly, you define a focused UpdateUserDto containing only the fields clients should modify. Model binding will only populate these declared properties; anything else is ignored. Combine this with ownership verification—ensure the authenticated user owns or has permission to modify the resource—before persisting changes. Finally, use attribute-based whitelisting (like [Bindable] in .NET) or explicit property mapping to make your intent crystal clear.

    Example

    csharp
    // ❌ VULNERABLE: Direct entity binding
    [HttpPut("{id}")]
    public async Task<IActionResult> UpdateUser(int id, [FromBody] User user)
    {
        var existing = await _db.Users.FindAsync(id);
        existing.Email = user.Email;
        existing.IsAdmin = user.IsAdmin;  // Attacker can set this!
        existing.RoleId = user.RoleId;    // Attacker can set this!
        await _db.SaveChangesAsync();
        return Ok(existing);
    }
    
    // ✅ SECURE: Explicit DTO with ownership check
    public class UpdateUserDto
    {
        public string Email { get; set; }
        public string FirstName { get; set; }
    }
    
    [HttpPut("{id}")]
    [Authorize]
    public async Task<IActionResult> UpdateUser(int id, [FromBody] UpdateUserDto dto)
    {
        var userId = int.Parse(User.FindFirst(ClaimTypes.NameIdentifier)?.Value);
        
        // Ownership check: user can only modify their own profile
        if (id != userId)
            return Forbid();
        
        var existing = await _db.Users.FindAsync(id);
        if (existing == null)
            return NotFound();
        
        existing.Email = dto.Email;
        existing.FirstName = dto.FirstName;
        // IsAdmin, RoleId, CreatedAt, etc. are never touched
        
        await _db.SaveChangesAsync();
        return Ok(new { existing.Id, existing.Email, existing.FirstName });
    }
    

    The secure version uses a dedicated DTO that declares exactly which fields are updateable, verifies the requester owns the resource, and never exposes sensitive properties like IsAdmin to client manipulation.

    Key points to mention

    • Use DTOs as a whitelist: Define separate CreateDto and UpdateDto classes; only include properties clients should control. Entity models should never be request payloads.
    • Always validate ownership: Before applying changes, confirm the authenticated user has permission to modify that specific resource—check user ID, tenant ID, or role-based access.
    • Separate read and write models: A response DTO may include read-only fields (like CreatedAt, LastModified) that are excluded from input DTOs, making your API contract explicit.
    • Validate and sanitize input: Use FluentValidation or Data Annotations to ensure DTO properties meet business rules before persisting, catching both malformed and malicious payloads.
    • Log and audit changes: Track who modified what and when; this helps detect and investigate suspicious bulk updates that might indicate an attack.

    Common follow-up questions

    • What if you need to support multiple update scenarios (e.g., user self-edit vs. admin override)? Create separate endpoints or DTOs: UpdateOwnProfileDto (user-scoped) and AdminUpdateUserDto (admin-scoped). Apply stricter authorization checks to the admin endpoint.
    • How do you handle nested objects or related entities? Only expose IDs in input DTOs; never accept full nested entities. Validate that referenced IDs exist and belong to the correct context (e.g., user's organization) before linking.
    • What's the difference between mass assignment and SQL injection? Mass assignment is about unauthorized field modification through the API layer; SQL injection is code-level exploitation of parameterized queries. Both are serious, but mass assignment happens at the business logic level.

    Related interview questions

    .NETCsharpSecurity
  297. 297How do you implement auditing across an application?

    Expert

    Implement auditing by leveraging EF Core interceptors to capture entity changes at the database layer, storing immutable audit records with timestamps and user context, and correlating changes across distributed operations using correlation IDs. This approach provides a complete historical trail of who changed what and when, essential for compliance, debugging, and forensics.

    How it works

    Auditing captures state changes systematically by intercepting Entity Framework Core's SaveChanges pipeline. Using EF Core interceptors, you detect added, modified, and deleted entities before they commit to the database. For each change, you create an audit record containing the entity type, primary key, property names, old and new values, timestamp, user identity, and a correlation ID for distributed tracing.

    The audit records themselves are stored in an append-only table—immutable by design—preventing accidental or malicious modification of the audit trail. This separation ensures auditing logic doesn't interfere with the main application flow. Correlation IDs, propagated via ambient context or dependency injection, link related changes across service boundaries, enabling investigation of cascading updates or saga-like workflows.

    Authentication context (obtained from HttpContext.User or ambient claims) captures who performed the action. For batch operations or background jobs, you inject the user context explicitly. Filtering and querying audit records allows you to reconstruct historical state, identify data anomalies, and generate compliance reports.

    Example

    csharp
    // Audit record entity
    public class AuditEntry
    {
        public int Id { get; set; }
        public string EntityName { get; set; }
        public string Operation { get; set; } // Create, Update, Delete
        public object EntityId { get; set; }
        public string ChangedProperties { get; set; } // JSON
        public string OldValues { get; set; } // JSON
        public string NewValues { get; set; } // JSON
        public string UserId { get; set; }
        public string CorrelationId { get; set; }
        public DateTime Timestamp { get; set; }
    }
    
    // Interceptor
    public class AuditingInterceptor : SaveChangesInterceptor
    {
        private readonly IHttpContextAccessor _contextAccessor;
        private readonly ICorrelationIdProvider _correlationIdProvider;
    
        public AuditingInterceptor(IHttpContextAccessor contextAccessor, ICorrelationIdProvider correlationIdProvider)
        {
            _contextAccessor = contextAccessor;
            _correlationIdProvider = correlationIdProvider;
        }
    
        public override ValueTask> SavingChangesAsync(
            DbContextEventData eventData,
            InterceptionResult result,
            CancellationToken cancellationToken = default)
        {
            var dbContext = eventData.Context;
            var auditEntries = new List();
            var userId = _contextAccessor.HttpContext?.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "System";
            var correlationId = _correlationIdProvider.GetCorrelationId();
    
            foreach (var entry in dbContext.ChangeTracker.Entries())
            {
                if (entry.State == EntityState.Unchanged) continue;
    
                var auditEntry = new AuditEntry
                {
                    EntityName = entry.Entity.GetType().Name,
                    Operation = entry.State.ToString(),
                    EntityId = entry.Entity.GetType().GetProperty("Id")?.GetValue(entry.Entity),
                    UserId = userId,
                    CorrelationId = correlationId,
                    Timestamp = DateTime.UtcNow,
                    OldValues = JsonSerializer.Serialize(entry.OriginalValues.ToDictionary(p => p.Metadata.Name, p => p.CurrentValue)),
                    NewValues = JsonSerializer.Serialize(entry.CurrentValues.ToDictionary(p => p.Metadata.Name, p => p.CurrentValue))
                };
    
                auditEntries.Add(auditEntry);
            }
    
            if (auditEntries.Any())
            {
                dbContext.Set().AddRange(auditEntries);
            }
    
            return base.SavingChangesAsync(eventData, result, cancellationToken);
        }
    }
    
    // Registration
    services.AddScoped();
    services.AddDbContext((sp, options) =>
        options.UseSqlServer(connectionString)
               .AddInterceptors(sp.GetRequiredService()));
    

    This interceptor captures entity changes before SaveChanges executes, automatically populating audit records with user and correlation context, then adds them to the same transaction.

    Key points to mention

    • Interceptors operate at the DbContext level, capturing all changes regardless of data access pattern (direct SaveChanges, change tracking, migrations).
    • Store audit records in an append-only table and consider archiving old records to prevent unbounded growth in high-volume systems.
    • Use correlation IDs (trace IDs from distributed tracing or request identifiers) to link audit entries across multiple operations and services.
    • Serialize old and new values as JSON to handle complex types and enable efficient querying and reporting of specific property changes.
    • Exclude sensitive fields (passwords, credit cards) from audit records using data annotations or interceptor logic to comply with security policies.

    Common follow-up questions

    • How do you handle performance impact of auditing in high-throughput systems? — Consider async audit persistence to a separate database or message queue, batch writes, or sampling strategies for non-critical entities.
    • How do you ensure audit records themselves cannot be tampered with? — Use database permissions to restrict audit table updates, implement digital signatures or checksums on audit entries, and consider immutable storage solutions like event stores.
    • How do you query and report on audit data efficiently? — Index on UserId, Timestamp, and EntityId; partition large audit tables by date; use materialized views for common queries; or denormalize to a dedicated audit data warehouse.

    Related interview questions

    .NETCsharp
  298. 298How do you design for GDPR data deletion?

    Expert

    Designing for GDPR data deletion requires creating a comprehensive data map, implementing both hard deletion and reversible anonymization strategies, and ensuring cascading removal across primary databases, backups, caches, and audit logs within defined retention windows. The core principle is making it technically feasible to locate and eliminate personal data on demand while maintaining system integrity and compliance evidence.

    How it works

    GDPR data deletion (the right to be forgotten) demands a multi-layered approach because personal data doesn't exist in a single location. You must:

    1. Map personal data across systems: Document where personally identifiable information (PII) lives—primary databases, caches, search indexes, backups, data warehouses, third-party APIs, and logs. Create a data inventory with sensitivity levels and retention justifications.

    2. Choose deletion strategy: Hard deletion physically removes records; anonymization irreversibly strips identifiers so data becomes non-personal. Use hard deletion for direct PII, anonymization for analytics and backups where deletion is impractical.

    3. Implement cascading deletion: When a user requests deletion, trigger a workflow that removes data from the primary data store, then propagates to dependent systems—caches (Redis), search engines (Elasticsearch), read replicas, and data lakes.

    4. Handle backups and retention: Backups cannot be immediately deleted (disaster recovery), but you must document their retention period and ensure deletion occurs when backups expire. Implement immutable backup deletion windows.

    5. Audit and compliance logging: Log all deletion requests with timestamps and outcomes, but exclude the deleted data itself from logs to prevent re-identification.

    Example

    csharp
    public class GdprDataDeletionService
    {
        private readonly DbContext _db;
        private readonly IDistributedCache _cache;
        private readonly ILogger<GdprDataDeletionService> _logger;
        private readonly IEnumerable<IDataDeletionHandler> _deletionHandlers;
    
        public async Task DeleteUserDataAsync(int userId)
        {
            var deletionId = Guid.NewGuid();
            var timestamp = DateTime.UtcNow;
    
            try
            {
                // Log deletion request (without PII)
                _logger.LogInformation("Deletion request {DeletionId} initiated for user {UserId}", 
                    deletionId, userId);
    
                // 1. Delete from primary database
                var user = await _db.Users.FindAsync(userId);
                if (user != null)
                {
                    _db.Users.Remove(user);
                    await _db.SaveChangesAsync();
                }
    
                // 2. Delete related data with cascade
                var userOrders = _db.Orders.Where(o => o.UserId == userId);
                _db.Orders.RemoveRange(userOrders);
                await _db.SaveChangesAsync();
    
                // 3. Invalidate caches
                await _cache.RemoveAsync($"user:{userId}");
                await _cache.RemoveAsync($"user:{userId}:preferences");
    
                // 4. Trigger deletion handlers for external systems
                foreach (var handler in _deletionHandlers)
                {
                    await handler.DeleteAsync(userId);
                }
    
                // 5. Log successful completion
                _logger.LogInformation(
                    "Deletion request {DeletionId} completed at {Timestamp}", 
                    deletionId, timestamp);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Deletion failed for {DeletionId}", deletionId);
                throw;
            }
        }
    }
    
    public interface IDataDeletionHandler
    {
        Task DeleteAsync(int userId);
    }
    
    public class SearchIndexDeletionHandler : IDataDeletionHandler
    {
        private readonly IElasticsearchClient _elasticsearch;
    
        public async Task DeleteAsync(int userId)
        {
            await _elasticsearch.DeleteAsync(new DeleteRequest("users", userId.ToString()));
        }
    }
    

    This example demonstrates a deletion workflow that removes data from the primary database, cascades to related entities, clears caches, invokes external handlers for search indexes and third-party systems, and logs the entire operation for audit compliance.

    Key points to mention

    • Data inventory is foundational: You cannot delete what you don't know exists. Create and maintain a comprehensive data map including sensitivity classifications and retention justifications.
    • Backup retention vs. deletion: Communicate backup retention windows to users; deletion cannot be immediate if active backups exist, but must be guaranteed when retention expires.
    • Anonymization as a middle ground: For historical or analytical data, irreversible anonymization often satisfies GDPR while preserving data utility for business intelligence.
    • Audit trails without PII: Log deletion requests and outcomes using non-identifiable markers (user ID or request UUID) so compliance records don't reintroduce personal data.
    • Handler pattern for extensibility: Use interfaces like IDataDeletionHandler to decouple deletion logic across databases, caches, and external services, enabling independent scaling and horizontal scaling.

    Common follow-up questions

    • How do you verify deletion was successful across distributed systems? — Implement a reconciliation job that queries each system (database, cache, search index) using a non-PII identifier and reports gaps. Use idempotent handlers to safely retry failed deletions.
    • What if a backup contains the deleted user's data? — Document the backup retention policy in your privacy notice; schedule automated deletion jobs when backups reach end-of-life, or implement selective restoration without the user's records.
    • How do you handle soft deletes that leave audit trails? — Soft deletes don't satisfy GDPR unless you also anonymize identifiers. Hard delete user records but retain anonymized transaction logs for compliance audits, clearly separating personal from non-personal data.

    Related interview questions

    .NETCsharp
  299. 299How do you implement feature flags in .NET?

    Expert

    Feature flags in .NET are implemented using Microsoft.FeatureManagement, which evaluates conditional behavior at runtime based on configuration or a feature provider. You configure flags declaratively, evaluate them per request or user, and remove them after full rollout. Third-party providers like LaunchDarkly, Split, or Unleash offer more advanced targeting, analytics, and real-time updates without redeploying.

    How it works

    Feature flags are configuration-driven switches that enable or disable functionality without code changes. Microsoft.FeatureManagement is the standard .NET library that integrates with appsettings.json, Azure App Configuration, or custom providers. You register the service in dependency injection, inject IFeatureManager, and evaluate flags at decision points in your code. Flags can be static (on/off) or dynamic (evaluated per user, context, or tenant). Advanced implementations use feature filters—predicates that determine if a flag is active for a specific request, user, or environment. This enables gradual rollouts (canary deployments), A/B testing, and instant kill switches without redeployment.

    Example

    csharp
    // appsettings.json
    {
      "FeatureManagement": {
        "NewCheckoutFlow": true,
        "BetaAnalytics": {
          "EnabledFor": [
            {
              "Name": "PercentageFilter",
              "Parameters": {
                "Value": 50
              }
            }
          ]
        }
      }
    }
    
    // Startup
    services.AddFeatureManagement();
    
    // Controller or service
    public class CheckoutController
    {
        private readonly IFeatureManager _featureManager;
    
        public CheckoutController(IFeatureManager featureManager) 
            => _featureManager = featureManager;
    
        public async Task Checkout()
        {
            if (await _featureManager.IsEnabledAsync("NewCheckoutFlow"))
            {
                return Ok("Using new checkout experience");
            }
            return Ok("Using legacy checkout");
        }
    }
    

    This example checks if the NewCheckoutFlow flag is enabled; if so, it uses the new experience, otherwise falls back to legacy code. The PercentageFilter on BetaAnalytics enables the flag for 50% of requests, useful for canary testing.

    Key points to mention

    • Integration with configuration: Works with appsettings.json, environment-specific settings, and Azure App Configuration for centralized management across deployments.
    • Feature filters: Custom filters (e.g., percentage, time-based, user-targeting) enable targeted rollouts without code changes; implement IFeatureFilterMetadata for custom logic.
    • Dependency injection: Inject IFeatureManager into controllers, services, or middleware; async evaluation ensures non-blocking checks.
    • Lifecycle management: Flags should be removed after full rollout; track usage with feature telemetry to identify stale flags.
    • Third-party providers: LaunchDarkly and Unleash offer real-time updates, detailed analytics, and user-segment targeting without code redeploy; useful for high-scale operations.

    Common follow-up questions

    • How do you handle feature flag secrets or sensitive configurations? Store flag configuration in secure vaults like Azure Key Vault or use feature providers that support encrypted values.
    • How do you test code with feature flags? Mock IFeatureManager in unit tests, or use FeatureManagementTestHelper to set flag values per test; for integration tests, override appsettings via test configuration.
    • Can you use feature flags in middleware or at the routing level? Yes; evaluate flags early in the pipeline (middleware) to redirect requests, short-circuit expensive operations, or enable feature-specific routes conditionally.

    Related interview questions

    Further reading

    .NETCsharp
  300. 300How do you design plugin architectures in .NET?

    Expert

    Plugin architectures in .NET are built by isolating plugin code using AssemblyLoadContext, defining shared interfaces or abstract classes as contracts, and discovering/registering plugins dynamically at application startup through dependency injection. This approach enables runtime extensibility without recompiling the host application.

    How it works

    A plugin architecture separates the host application from independently developed plugins through three key layers:

    Contract Layer: A shared assembly defines interfaces or abstract classes that plugins must implement. This assembly is referenced by both the host and all plugins, creating a common language for interaction.

    Plugin Discovery: The host application scans a designated directory (or uses configuration) to find plugin assemblies. Each plugin assembly is loaded into its own AssemblyLoadContext rather than the default context, providing memory isolation and version conflict prevention. This prevents a plugin's dependencies from polluting the host's dependency chain.

    Initialization & Registration: Once loaded, plugins are instantiated and their services are registered with the host's dependency injection container, typically through a bootstrap interface that each plugin exposes. This allows the host application to resolve plugin functionality at runtime.

    This design provides flexibility, fault isolation, and the ability to enable/disable plugins without restarting the entire application in production scenarios.

    Example

    csharp
    // Contract assembly (shared by host and plugins)
    public interface IPlugin
    {
        string Name { get; }
        void Execute();
    }
    
    public interface IPluginInitializer
    {
        void RegisterServices(IServiceCollection services);
    }
    
    // Host application plugin loader
    public class PluginLoader
    {
        public static List<IPlugin> LoadPlugins(string pluginDirectory)
        {
            var plugins = new List<IPlugin>();
            
            foreach (var dllPath in Directory.GetFiles(pluginDirectory, "*.dll"))
            {
                try
                {
                    var alc = new AssemblyLoadContext(Path.GetFileNameWithoutExtension(dllPath), isCollectible: true);
                    var assembly = alc.LoadFromAssemblyPath(dllPath);
                    
                    var initializerType = assembly.GetTypes()
                        .FirstOrDefault(t => typeof(IPluginInitializer).IsAssignableFrom(t));
                    
                    if (initializerType != null)
                    {
                        var initializer = (IPluginInitializer)Activator.CreateInstance(initializerType)!;
                        // Plugin is now discoverable and can be initialized
                        
                        var pluginTypes = assembly.GetTypes()
                            .Where(t => typeof(IPlugin).IsAssignableFrom(t) && !t.IsInterface);
                        
                        foreach (var pluginType in pluginTypes)
                        {
                            if (Activator.CreateInstance(pluginType) is IPlugin plugin)
                                plugins.Add(plugin);
                        }
                    }
                }
                catch (Exception ex)
                {
                    Console.WriteLine($"Failed to load plugin {dllPath}: {ex.Message}");
                }
            }
            
            return plugins;
        }
    }
    
    // Example plugin implementation
    public class SamplePlugin : IPlugin, IPluginInitializer
    {
        public string Name => "SamplePlugin";
        
        public void Execute() => Console.WriteLine("Plugin executing!");
        
        public void RegisterServices(IServiceCollection services)
        {
            services.AddScoped<ISampleService, SampleService>();
        }
    }

    This code loads each plugin into an isolated AssemblyLoadContext, discovers implementations of IPluginInitializer, instantiates them, and makes them available to the host application.

    Key points to mention

    • AssemblyLoadContext isolation prevents version conflicts and allows unloading collapsible contexts for memory management
    • Shared contract assembly is the only coupling between host and plugins; use keyed services or named registrations if multiple plugin instances serve different purposes
    • Error handling is critical—a faulty plugin should not crash the host; wrap discovery and initialization in try-catch blocks
    • Reflection-based discovery is powerful but carry performance cost; consider metadata-driven approaches (attributes or configuration) for large plugin ecosystems
    • Unloading strategy—use collectible AssemblyLoadContext to allow plugins to be unloaded from memory at runtime

    Common follow-up questions

    • How do you handle version conflicts between a plugin's dependencies and the host application?
    • What are the security implications of dynamically loading untrusted plugin code, and how would you mitigate them?
    • How would you implement hot-reload for plugins without restarting the application?

    Related interview questions

    Further reading

    .NETCsharpArchitecture
  301. 301What is AssemblyLoadContext?

    Expert

    AssemblyLoadContext (ALC) is a .NET Core and modern .NET feature that isolates loaded assemblies into separate contexts, allowing multiple versions of the same dependency to coexist in a single application process and enabling assemblies to be unloaded when marked as collectible. It replaces the AppDomain isolation mechanism from .NET Framework and is essential for building plugin systems and dynamic module loading scenarios.

    How it works

    Each AssemblyLoadContext acts as a namespace for assembly resolution and binding. When you load an assembly into an ALC, it and its dependencies are resolved within that context's scope. By default, all assemblies load into the AssemblyLoadContext.Default context. However, you can create custom contexts to isolate plugins or dynamically loaded modules.

    The key innovation is collectibility. Traditional AppDomains required a full process restart to unload code; with ALCs, you can create a collectible ALC, load assemblies into it, use them, then unload the entire context when no longer needed. This allows plugins to be updated, hot-reloaded, or removed without restarting the application. The .NET runtime's garbage collector can then reclaim memory used by those assemblies.

    Assembly resolution within an ALC is customizable through the Resolving event, giving you fine-grained control over how dependencies are located—critical when a plugin needs a different version of a shared library than the host application.

    Example

    csharp
    using System;
    using System.IO;
    using System.Reflection;
    using System.Runtime.Loader;
    
    // Create an isolated, collectible context for a plugin
    var alc = new AssemblyLoadContext("PluginContext", isCollectible: true);
    
    // Custom resolution: look in a plugin directory
    alc.Resolving += (context, name) =>
    {
        string pluginPath = Path.Combine("/plugins", name.Name + ".dll");
        return File.Exists(pluginPath) ? context.LoadFromAssemblyPath(pluginPath) : null;
    };
    
    // Load plugin assembly into the custom context
    string pluginDll = "/plugins/MyPlugin.dll";
    Assembly pluginAssembly = alc.LoadFromAssemblyPath(pluginDll);
    
    // Use the plugin (instantiate types, call methods)
    var pluginType = pluginAssembly.GetType("MyPlugin.PluginClass");
    dynamic plugin = Activator.CreateInstance(pluginType);
    plugin.Execute();
    
    // Unload when done
    alc.Unload();
    // Assemblies and memory are eligible for garbage collection
    

    This creates an isolated context for a plugin, customizes how its dependencies are resolved, loads and executes the plugin, then cleanly unloads it—all without affecting the host application or other plugins.

    Key points to mention

    • Replaces AppDomains: AppDomains were heavy and process-wide; ALCs are lightweight and scoped, offering better performance and finer control.
    • Collectible contexts enable unloading: Mark isCollectible: true to allow garbage collection of the entire context and its assemblies after Unload().
    • Dependency version isolation: Different contexts can load different versions of the same assembly—essential for plugin architectures where the host and plugins may depend on incompatible library versions.
    • Custom resolution logic: Hook the Resolving event to control assembly binding, enabling scenarios like shadowing or directory-based plugin discovery.
    • Careful with static state: Static fields and singletons in a plugin may persist across reloads if they reference shared libraries; design plugins to be stateless where possible.

    Common follow-up questions

    • "How do you handle shared state between a plugin and host?" — Use interfaces defined in a shared assembly loaded in the Default context; plugins implement these interfaces within their own ALC, avoiding type conflicts.
    • "What happens if you try to unload a non-collectible ALC?" — The call to Unload() completes, but assemblies remain in memory because the runtime cannot collect non-collectible contexts.
    • "How does this relate to plugin architecture design?" — ALCs are the fundamental mechanism enabling safe, isolated plugin loading; they provide the isolation layer necessary for robust plugin systems.

    Related interview questions

    .NETCsharp
  302. 302How do you build a high-throughput API?

    Expert

    Building a high-throughput API requires minimizing allocations, using async I/O throughout, pooling expensive resources like database connections, and leveraging source-generated JSON serialization. Deploy behind a load balancer with horizontal scaling, use Minimal APIs to reduce reflection overhead, and implement caching at multiple layers. These practices allow you to handle thousands of concurrent requests efficiently on modern hardware.

    How it works

    High throughput is achieved by reducing waste at every layer. Allocations cause garbage collection pauses that block threads; using ArrayPool<T>, value types, and avoiding LINQ in hot paths keeps memory pressure low. Async I/O (database, HTTP calls, file I/O) frees threads to handle other requests instead of blocking, multiplying effective concurrency. Connection pooling reuses expensive resources rather than creating new ones per request.

    Caching at multiple levels—HTTP caching headers, in-memory caching for frequently accessed data, distributed caching for shared state—reduces database load dramatically. Kestrel's request pipeline is already optimized; Minimal APIs skip controller reflection overhead, and source-generated JSON (System.Text.Json with source generators in .NET 8+) eliminates runtime reflection. Finally, horizontal scaling behind a load balancer distributes traffic across multiple instances, preventing any single server from becoming a bottleneck.

    Example

    csharp
    var builder = WebApplication.CreateBuilder();
    builder.Services.AddSingleton<IMemoryCache>(new MemoryCache(new MemoryCacheOptions()));
    builder.Services.AddHttpClient<ExternalApiClient>()
        .ConfigureHttpClient(c => c.Timeout = TimeSpan.FromSeconds(5));
    builder.Services.AddDataProtection(); // for distributed caching if needed
    
    var app = builder.Build();
    
    app.MapGet("/users/{id}", async (int id, IMemoryCache cache, ExternalApiClient client) =>
    {
        var cacheKey = $"user_{id}";
        if (!cache.TryGetValue(cacheKey, out var user))
        {
            user = await client.GetUserAsync(id); // async I/O, no blocking
            cache.Set(cacheKey, user, TimeSpan.FromMinutes(5));
        }
        return Results.Ok(user);
    })
    .WithName("GetUser")
    .WithOpenApi()
    .Produces<User>(StatusCodes.Status200OK);
    
    app.Run();
    
    // User is trimmed and source-generated by System.Text.Json
    [System.Text.Json.Serialization.JsonSerializable]
    public partial class User
    {
        public int Id { get; set; }
        public string Name { get; set; }
    }

    This Minimal API endpoint uses caching to avoid redundant external calls, async/await to keep threads free, and omits reflection overhead from controller routing. The JsonSerializable attribute enables source generation, eliminating JSON serialization bottlenecks.

    Key points to mention

    • Async all the way: Use async Task for all I/O-bound operations; blocking threads destroys throughput.
    • Allocation hygiene: Reuse buffers via ArrayPool<T>, avoid string concatenation and LINQ in hot paths, use structs judiciously, and tune GC settings if needed.
    • Connection pooling: Let connection pools (DbConnection, HttpClientFactory) manage resource lifecycle automatically; avoid creating new connections per request.
    • Caching strategy: Cache at HTTP layer (headers), application layer (IMemoryCache), and distributed layer (Redis) to avoid redundant computation.
    • Horizontal scaling: A single server has limits; add load balancing (nginx, Azure Application Gateway) and run multiple instances to multiply throughput linearly.

    Common follow-up questions

    • How do you measure and monitor throughput? Use tools like k6, Apache JMeter, or wrk for load testing; monitor CPU, memory, and request latency with Application Insights or Prometheus. Profile hot paths with BenchmarkDotNet.
    • What about database bottlenecks? Query optimization, indexing, and read replicas help; caching reduces database load. Consider CQRS for read-heavy workloads and async database drivers (EF Core with async methods).
    • When should you use Minimal APIs vs. controllers? For high-throughput APIs, Minimal APIs reduce reflection overhead, making them ideal for greenfield projects prioritizing performance.

    Related interview questions

    Further reading

    .NETCsharpaspnetcoreasync
  303. 303How do you choose between Minimal APIs and controllers for large systems?

    Expert

    For large systems, choose Minimal APIs if your team prioritizes performance, rapid iteration, and explicit endpoint control; choose Controllers if you need established architectural conventions, broad filter ecosystems, and team familiarity. Both scale equally well—the decision hinges on team expertise, codebase consistency, and whether you're building greenfield or maintaining legacy systems.

    How it works

    Minimal APIs and Controllers represent two architectural philosophies for handling HTTP requests in ASP.NET Core. Controllers inherit from a base class, use attributes for routing and filters, and follow MVC conventions—making them well-suited to teams with established patterns and complex middleware needs. Minimal APIs are lightweight, delegate-based, and configured entirely in Program.cs or via route groups, offering less abstraction overhead and clearer request-to-handler mapping.

    For large systems, "large" typically means multiple teams, hundreds of endpoints, long-term maintenance, and onboarding of new developers. Controllers excel because their convention-based approach reduces decision-making fatigue: developers know where validation logic goes (filters), where routing is declared (attributes), and how dependency injection flows. Minimal APIs demand more discipline: without conventions, route organization can become chaotic across multiple files unless you enforce strict patterns via route groups and extensions.

    However, Minimal APIs shine in high-performance scenarios, microservices, or when your team is motivated by explicit, localized endpoint logic. They compile faster, have lower memory overhead, and eliminate reflection-heavy attribute scanning at startup—meaningful gains when you have thousands of endpoints.

    Example

    csharp
    // Controllers approach—conventional, familiar
    [ApiController]
    [Route("api/[controller]")]
    public class OrdersController : ControllerBase
    {
        private readonly IOrderService _service;
        public OrdersController(IOrderService service) => _service = service;
    
        [HttpGet("{id}")]
        [Authorize]
        public async Task<ActionResult<OrderDto>> GetOrder(int id)
            => Ok(await _service.GetOrderAsync(id));
    }
    
    // Minimal API approach—explicit, localized
    var orders = app.MapGroup("/api/orders")
        .RequireAuthorization()
        .WithName("Orders")
        .WithOpenApi();
    
    orders.MapGet("{id}", GetOrderAsync)
        .WithName("GetOrder")
        .Produces<OrderDto>();
    
    async Task<OrderDto> GetOrderAsync(int id, IOrderService service)
        => await service.GetOrderAsync(id);
    

    The Controller approach uses inheritance and attributes; the Minimal API groups related endpoints declaratively with fluent configuration, making the entire pipeline visible at one glance.

    Key points to mention

    • Consistency over features: In large systems, the choice that your entire team can follow consistently matters more than marginal performance gains. Pick one and standardize.
    • Controllers = better for complex filters: If you need authorization filters, exception filters, and action filters across dozens of endpoints, Controllers provide a proven, attribute-based pattern that scales.
    • Minimal APIs = better for transparency: Each endpoint's dependencies, middleware, and authorization are explicit in Program.cs or route-group extensions, reducing hidden magic and simplifying debugging.
    • Hybrid approach is valid: Many large systems use Controllers for CRUD/traditional endpoints and Minimal APIs for performance-critical or event-driven paths (e.g., webhooks, real-time APIs).
    • Onboarding and documentation matter: Controllers require less documentation because conventions are universal; Minimal APIs require clear guidelines on folder structure, naming, and route-group organization.

    Common follow-up questions

    • How do you organize Minimal APIs across multiple files in a large project without chaos?
    • Can you mix Controllers and Minimal APIs in the same application? What are the trade-offs?
    • How does performance actually differ between Controllers and Minimal APIs at scale, and is it a realistic concern?

    Related interview questions

    Further reading

    .NETCsharpaspnetcore
  304. 304How do you handle long-running requests in ASP.NET Core?

    Expert

    Long-running requests in ASP.NET Core should return 202 Accepted immediately, moving the actual work to a background processor like a queue or BackgroundService. The client then polls a status endpoint or receives updates via SignalR/webhooks rather than blocking the HTTP connection. This pattern prevents timeout issues, improves responsiveness, and allows horizontal scaling of work processing.

    How it works

    When a client submits a long-running operation (file processing, report generation, complex calculations), the endpoint immediately validates the request, creates a work item with a unique identifier, enqueues it to a durable queue or in-memory channel, and returns 202 Accepted with a Location header pointing to a status endpoint. A background worker processes items from the queue asynchronously, updating a persistent store (database, cache) with progress or completion status. The client can then poll the status endpoint or receive real-time updates through SignalR when work completes.

    Key to this pattern is separation of concerns: the HTTP layer handles request validation and queueing, while workers handle computation. This enables independent scaling—adding more worker instances doesn't affect your API layer. Using durable queues (Azure Service Bus, RabbitMQ, or even a database-backed queue) ensures work isn't lost if the application restarts.

    Example

    csharp
    // Minimal API endpoint that accepts a long-running request
    app.Post("/process-report", async (ReportRequest req, IBackgroundJobClient jobClient) =>
    {
        var jobId = Guid.NewGuid().ToString();
        
        // Enqueue work immediately
        jobClient.Enqueue(() => ProcessReportJob(jobId, req.DataSource, req.Format));
        
        return Results.Accepted($"/status/{jobId}", new { jobId });
    });
    
    // Status endpoint to check progress
    app.Get("/status/{jobId}", async (string jobId, IJobStatusRepository statusRepo) =>
    {
        var status = await statusRepo.GetJobStatusAsync(jobId);
        
        return status switch
        {
            null => Results.NotFound(),
            { IsComplete: true } => Results.Ok(new { status = "completed", result = status.Result }),
            _ => Results.Ok(new { status = "processing", progress = status.Progress })
        };
    });
    
    // Background job (runs in a worker/background service)
    public static async Task ProcessReportJob(string jobId, string dataSource, string format, 
        IJobStatusRepository statusRepo, IReportService reportService)
    {
        try
        {
            await statusRepo.UpdateProgressAsync(jobId, 0);
            
            var data = await reportService.FetchDataAsync(dataSource);
            await statusRepo.UpdateProgressAsync(jobId, 50);
            
            var report = await reportService.GenerateReportAsync(data, format);
            
            await statusRepo.CompleteAsync(jobId, report);
        }
        catch (Exception ex)
        {
            await statusRepo.FailAsync(jobId, ex.Message);
        }
    }
    

    This example uses Hangfire (or you can use Channel<T> for simpler scenarios) to enqueue the report generation job, immediately returning 202 with a job ID so the client can poll /status/{jobId} for progress.

    Key points to mention

    • 202 Accepted is the correct HTTP status for fire-and-forget operations; it signals the server accepted the request but hasn't completed it yet.
    • Use durable queues (Service Bus, RabbitMQ) or BackgroundService implementations to ensure work survives restarts and can be retried on failure.
    • Polling vs. Push: Status endpoints work well for single clients; for real-time updates to many clients, SignalR or webhooks are more efficient.
    • Store job state in a persistent medium (database, Redis) so status can be queried across application instances and after restarts.
    • Idempotency keys help prevent duplicate processing if the client retries the initial request.

    Common follow-up questions

    • How do you handle eventual consistency in user-facing features when background jobs complete asynchronously?
    • What's the difference between using Channel<T> versus a message queue like Azure Service Bus for background work?
    • How do you implement retry logic and deadletter handling for failed background jobs?

    Related interview questions

    Further reading

    .NETCsharpaspnetcore
  305. 305How do you design a robust background job system?

    Expert

    A robust background job system requires durable persistence (queue or database), idempotent job handlers, automatic retries with exponential backoff, distributed locking for singleton jobs, and comprehensive monitoring. Use established libraries like Hangfire or Quartz.NET rather than building from scratch, ensuring jobs survive application restarts and failures.

    How it works

    A robust background job system decouples long-running or resource-intensive work from your main request flow. Jobs are stored durably in a queue or database, allowing them to persist across application restarts. A worker process (or multiple workers in a distributed setup) polls for pending jobs and executes them. Failures trigger automatic retries with exponential backoff to handle transient errors gracefully. Idempotency is critical—each job must be safe to execute multiple times, since retries and distributed execution may cause duplicates. For jobs that must run only once globally (e.g., nightly reconciliation), distributed locks ensure a single instance executes. Finally, dashboards and logging provide visibility into job health, success rates, and failures.

    Example

    csharp
    // Using Hangfire with SQL Server persistence
    public class JobSetup
    {
        public static void ConfigureJobs(WebApplicationBuilder builder)
        {
            builder.Services.AddHangfire(config =>
                config.UseSqlServerStorage("DefaultConnection")
                    .UseSimpleAssemblyNameTypeSerializer()
                    .UseRecommendedSerializerSettings());
            
            builder.Services.AddHangfireServer();
        }
    }
    
    // Idempotent job handler with logging
    public class EmailJobHandler
    {
        private readonly ILogger _logger;
        private readonly IEmailService _emailService;
    
        public EmailJobHandler(ILogger logger, IEmailService emailService)
        {
            _logger = logger;
            _emailService = emailService;
        }
    
        [AutomaticRetry(Attempts = 5, DelaysInSeconds = new[] { 60, 300, 900, 3600 })]
        public async Task SendBatchEmailAsync(int batchId)
        {
            _logger.LogInformation("Processing email batch {BatchId}", batchId);
            
            try
            {
                // Idempotency: check if already sent before processing
                if (await _emailService.IsBatchSentAsync(batchId))
                {
                    _logger.LogInformation("Batch {BatchId} already processed, skipping", batchId);
                    return;
                }
    
                await _emailService.SendBatchAsync(batchId);
                _logger.LogInformation("Batch {BatchId} sent successfully", batchId);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Failed to send batch {BatchId}", batchId);
                throw; // Hangfire will retry
            }
        }
    }
    
    // Enqueue a job
    public class OrderService
    {
        private readonly IBackgroundJobClient _backgroundJobs;
    
        public OrderService(IBackgroundJobClient backgroundJobs) 
            => _backgroundJobs = backgroundJobs;
    
        public void CreateOrder(Order order)
        {
            // Process order synchronously
            _backgroundJobs.Enqueue(
                h => h.SendBatchEmailAsync(order.BatchId));
        }
    }
    
    // Recurring job for scheduled work
    RecurringJob.AddOrUpdate(
        "daily-reconciliation",
        h => h.ReconcileAccountsAsync(),
        Cron.Daily(2, 0), // 2 AM UTC
        new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });

    This example shows Hangfire with SQL Server storage, automatic retries with exponential backoff delays, idempotency checks before processing, and a recurring job configuration for scheduled work that runs daily.

    Key points to mention

    • Durable persistence: Use a database or message queue (SQL Server, Redis, RabbitMQ) so jobs survive crashes and restarts.
    • Idempotency: Design jobs to safely handle duplicate executions; check for prior completion before processing.
    • Retry strategy: Implement exponential backoff (60s, 300s, 900s, 3600s) to avoid hammering failing services.
    • Distributed locking: For singleton jobs or critical operations, use distributed locking to ensure only one instance runs globally.
    • Monitoring & dashboards: Hangfire and Quartz provide built-in dashboards; log job outcomes and integrate with APM tools for observability.

    Common follow-up questions

    • How do you handle job ordering or dependencies? Use job chains or sagas; Hangfire supports ContinueJobWith() for sequential workflows, or implement a state machine for complex orchestration.
    • How do you scale background jobs horizontally? Deploy multiple worker instances pointing to the same queue database; configure them as a horizontally scaled pool with load balancing.
    • What about dead letter queues (DLQ)? After max retries, move failed jobs to a DLQ for manual inspection and replay; most libraries support this out of the box.

    Related interview questions

    Further reading

    .NETCsharpasyncdependency-injection
  306. 306How do you handle time zones correctly?

    Expert

    The correct approach is to store all timestamps in UTC and use DateTimeOffset (or DateTime with DateTimeKind.Utc), convert to the user's time zone only at the presentation edge, and inject TimeProvider for testability rather than relying on DateTime.Now or server local time.

    How it works

    Time zone handling requires a clear separation of concerns: storage, logic, and presentation. Internally, your application should think entirely in UTC—this eliminates ambiguity and makes distributed systems, logging, and data consistency straightforward. DateTimeOffset is the recommended type because it captures both the instant in time and the offset from UTC, preserving the original context. When you receive user input in a specific time zone, convert it to UTC immediately. When you need to display or export data, convert from UTC to the user's (or system's) time zone using TimeZoneInfo. This approach scales: whether you have one server or ten across regions, UTC is the single source of truth.

    For testability, inject TimeProvider (.NET 8+) instead of calling DateTime.Now directly. This lets you substitute a test double that returns fixed times, deterministic behavior, and verifiable deadlines without sleeping or mocking system clocks.

    Example

    csharp
    // Dependency injection: inject TimeProvider
    public class EventService
    {
        private readonly TimeProvider _timeProvider;
        
        public EventService(TimeProvider timeProvider)
        {
            _timeProvider = timeProvider;
        }
        
        // Store events in UTC
        public DateTimeOffset ScheduleEvent(string name, DateTimeOffset userTime)
        {
            // userTime is already DateTimeOffset with user's zone info
            // Convert to UTC for storage
            var utcTime = userTime.ToUniversalTime();
            // Save to database
            return utcTime;
        }
        
        // Retrieve and convert to user's time zone
        public DateTimeOffset GetEventInUserTimeZone(
            DateTimeOffset utcEvent, 
            string timeZoneId)
        {
            var tz = TimeZoneInfo.FindSystemTimeZoneById(timeZoneId);
            var userTime = TimeZoneInfo.ConvertTime(utcEvent, tz);
            return userTime;
        }
        
        // Use injected TimeProvider for current time
        public bool IsEventUpcoming(DateTimeOffset utcEvent, int minutesBuffer = 0)
        {
            var now = _timeProvider.GetUtcNow();
            var buffer = TimeSpan.FromMinutes(minutesBuffer);
            return utcEvent > now && utcEvent <= now.AddMinutes(minutesBuffer);
        }
    }
    
    // Usage
    var provider = TimeProvider.System;
    var service = new EventService(provider);
    
    // User in US/Eastern provides local time (e.g., 2:00 PM EST = UTC-5)
    var userInput = new DateTimeOffset(2024, 1, 15, 14, 0, 0, 
        TimeSpan.FromHours(-5));
    var stored = service.ScheduleEvent("Meeting", userInput);
    // stored is now UTC: 2024-01-15 19:00:00 +00:00
    
    // Later, retrieve and show in user's time zone
    var displayTime = service.GetEventInUserTimeZone(stored, "US/Eastern");
    // displayTime is back to 2:00 PM EST
    

    This example shows storing the event in UTC, converting user input correctly, and using TimeProvider for the current time so tests can inject a fixed clock.

    Key points to mention

    • Always store UTC: Use DateTimeOffset.UtcNow or TimeProvider.GetUtcNow(), never DateTime.Now or local server time in databases.
    • Use DateTimeOffset over DateTime: It encodes the offset, preserving the original instant and avoiding ambiguity during DST transitions.
    • Convert only at the edge: All business logic operates on UTC; conversion to user or display time zones happens in controllers, API responses, or UI layers.
    • Inject TimeProvider: Never hardcode DateTime.Now in business logic; inject TimeProvider for testability and consistency.
    • Use TimeZoneInfo.FindSystemTimeZoneById(): This is the safe, cross-platform way to resolve IANA or Windows time zone identifiers; avoid string concatenation or magic strings.

    Common follow-up questions

    • How do you handle DST (Daylight Saving Time)? — TimeZoneInfo handles DST rules automatically when you convert; UTC has no DST, which is why storing UTC eliminates this problem.
    • What if a user's time zone is unknown? — Default to UTC or the organization's home time zone; offer an explicit time zone selection in settings. Assume UTC for back-end logs and system events.
    • How do you test time-dependent code? — Use a test implementation of TimeProvider that returns fixed or incrementing times, so tests are deterministic and fast.

    Related interview questions

    .NETCsharpTesting
  307. 307How do you prevent thundering herds after deployments or cache flushes?

    Expert

    Prevent thundering herds by combining cache warming before deployment, adding randomized jitter to expiration times, implementing request coalescing to batch backend calls, and rate-limiting concurrent requests during cold starts. These techniques ensure that when caches expire or services restart, traffic doesn't overwhelm backends with synchronized spike requests.

    How it works

    A thundering herd occurs when many cached items expire simultaneously or when a service restarts, causing all waiting requests to hit the backend at once. This creates a spike that can overwhelm databases, APIs, or external services, causing cascading failures.

    Cache warming pre-populates critical data before deployment, so users never experience a cold cache. Jitter randomizes expiration times so expirations spread across a time window rather than clustering. Request coalescing (also called request deduplication) ensures multiple identical concurrent requests only hit the backend once, with results shared among waiters. Concurrency limiting uses semaphores or circuit breakers to throttle how many requests can reach the backend simultaneously during recovery.

    Together, these create resilience: requests are served from warm caches, expirations are staggered, redundant work is eliminated, and surviving requests are paced to sustainable rates.

    Example

    csharp
    public class ThunderingHerdPrevention
    {
        private readonly IMemoryCache _cache;
        private readonly HttpClient _httpClient;
        private readonly SemaphoreSlim _backendLimiter;
        private readonly ConcurrentDictionary<string, TaskCompletionSource<string>> _inFlightRequests;
        private readonly Random _jitterRandom;
    
        public ThunderingHerdPrevention(IMemoryCache cache, HttpClient httpClient)
        {
            _cache = cache;
            _httpClient = httpClient;
            _backendLimiter = new SemaphoreSlim(10); // Max 10 concurrent backend calls
            _inFlightRequests = new();
            _jitterRandom = new Random();
        }
    
        public async Task<string> GetDataWithCoalescingAsync(string key)
        {
            // Check cache first
            if (_cache.TryGetValue(key, out string cachedValue))
                return cachedValue;
    
            // Request coalescing: multiple calls share one backend fetch
            if (_inFlightRequests.TryGetValue(key, out var existing))
                return await existing.Task;
    
            var tcs = new TaskCompletionSource<string>();
            if (!_inFlightRequests.TryAdd(key, tcs))
                return await _inFlightRequests[key].Task; // Race: another thread added it
    
            try
            {
                // Rate-limit backend access
                await _backendLimiter.WaitAsync();
                try
                {
                    var result = await _httpClient.GetStringAsync($"/api/data/{key}");
    
                    // Add jitter to expiration: base 5min ± 30sec
                    int jitterMs = _jitterRandom.Next(-30000, 30001);
                    var expiration = TimeSpan.FromMinutes(5).Add(TimeSpan.FromMilliseconds(jitterMs));
    
                    _cache.Set(key, result, expiration);
                    tcs.SetResult(result);
                    return result;
                }
                finally
                {
                    _backendLimiter.Release();
                }
            }
            catch (Exception ex)
            {
                tcs.SetException(ex);
                throw;
            }
            finally
            {
                _inFlightRequests.TryRemove(key, out _);
            }
        }
    
        public async Task WarmCacheAsync(IEnumerable<string> keys)
        {
            // Pre-populate cache before deployment
            foreach (var key in keys)
            {
                try
                {
                    var data = await _httpClient.GetStringAsync($"/api/data/{key}");
                    _cache.Set(key, data, TimeSpan.FromMinutes(5));
                }
                catch { /* Log and continue */ }
            }
        }
    }
    

    This example uses request coalescing so concurrent calls for the same key share one backend fetch, a semaphore to rate-limit backend concurrency, jitter to spread cache expirations, and a cache warming method to pre-load data at deployment time.

    Key points to mention

    • Request coalescing deduplicates in-flight requests: multiple threads waiting for the same cache miss send only one backend call, dramatically reducing load spikes.
    • Jitter in expiration is crucial—use randomized time windows (e.g., base TTL ± random offset) so millions of items don't expire simultaneously.
    • Cache warming at deployment eliminates the cold-start problem entirely by pre-loading critical data before traffic arrives.
    • Concurrency limiting with semaphores or circuit breakers prevents backends from being overwhelmed even if jitter and coalescing fail.
    • Monitor cache hit rates and backend request spikes; tools like Application Insights help detect thundering herds early.

    Common follow-up questions

    • What happens if a request times out while coalescing is waiting? How do you handle retry logic to avoid cascading failures?
    • How do you warm caches for millions of keys without causing a separate thundering herd during the warming phase?
    • How does this relate to cache stampedes, and when is one strategy preferred over another?

    Related interview questions

    Further reading

    .NETCsharpArchitectureDevOps
  308. 308How do you upgrade a large .NET Framework app to modern .NET?

    Expert

    Upgrading a large .NET Framework application to modern .NET requires a systematic approach: use the .NET Upgrade Assistant to analyze your codebase and identify migration blockers, incrementally refactor libraries to .NET Standard or multi-target, and employ strangler fig patterns with YARP to run both versions side-by-side. Replace Framework-specific APIs (WCF server, Web Forms, remoting) with modern equivalents before full migration.

    How it works

    The upgrade journey typically follows this strategy:

    1. Assessment Phase: Run the .NET Upgrade Assistant (a command-line tool) against your solution to generate a detailed report of unsupported APIs, NuGet package compatibility, and estimated effort. This tool identifies WCF server endpoints, Web Forms pages, HttpHandlers, and other Framework-specific constructs that need replacement.

    2. Dependency Decoupling: Refactor shared libraries to .NET Standard 2.0 or 2.1 or use multi-targeting (targeting both net472 and net8.0). This allows modern projects to consume your libraries immediately without waiting for full Framework retirement.

    3. Strangler Pattern with YARP: Deploy a reverse proxy (using YARP – Yet Another Reverse Proxy) in front of your application. Route new requests to the modern .NET Core/5+ version while legacy requests fall back to the Framework version. Gradually move business logic and routes to the new application until the old version can be retired.

    4. API Replacement: Replace unsupported APIs: WCF services become gRPC or ASP.NET Core endpoints; Web Forms migrate to Razor Pages or Blazor; HttpHandlers become middleware. Database access layers can often be retained if they already use EF6 or ADO.NET abstraction.

    5. Testing & Validation: Run comprehensive integration and load tests. Modern .NET typically performs 2–3× better, but behavioral differences (async contexts, thread pool behavior) can surface bugs.

    Example

    csharp
    // YARP configuration in Program.cs (modern .NET host)
    var builder = WebApplicationBuilder.CreateBuilder(args);
    
    builder.Services.AddReverseProxy()
        .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
    
    var app = builder.Build();
    
    // Route to modern .NET handlers first
    app.MapGet("/api/new/{**catch-all}", async (HttpContext ctx) =>
    {
        await ctx.Response.WriteAsJsonAsync(new { message = "Modern .NET handler" });
    });
    
    // Fallback to Framework app for unmigrated routes
    app.MapReverseProxy();
    
    app.Run();
    

    This configuration allows a modern .NET application to accept requests and route unmigrated endpoints back to the legacy Framework application, enabling parallel execution during migration.

    Key points to mention

    • .NET Upgrade Assistant automates initial analysis and provides migration guidance, saving weeks of manual investigation.
    • Multi-targeting allows libraries to work on both .NET Framework and modern .NET, reducing rework and enabling incremental adoption.
    • Strangler fig pattern with YARP eliminates the "big bang" migration risk; you migrate routes gradually while both versions run in production.
    • API replacement is mandatory: WCF server-side, Web Forms, and HttpHandlers have no direct equivalents in modern .NET; plan migrations early.
    • Performance testing is critical because modern .NET's threading, async/await defaults, and GC behavior differ from Framework; behavioral changes can emerge under load.

    Common follow-up questions

    • "What if we have unsupported third-party libraries?" – Evaluate alternatives (NuGet, GitHub), fork the library and maintain a version, or isolate it in a Windows service consumed via HTTP/gRPC.
    • "How long does a typical migration take?" – Small apps: weeks; large enterprise apps: 6–18 months depending on complexity, team size, and parallelization of migration efforts.
    • "Should we migrate to ASP.NET Core immediately?" – If your app is Web Forms or WCF-heavy, consider ASP.NET Core with Razor Pages or Blazor; if it's a business logic service, modern .NET console/worker service may suffice.

    Related interview questions

    Further reading

    .NETCsharpArchitecture
  309. 309How do you design a library for broad consumption?

    Expert

    Designing a library for broad consumption requires multi-targeting frameworks, minimal dependencies, nullable annotations for type safety, semantic versioning discipline, and AOT/trimming compatibility. The goal is to maximize adoption by reducing friction—whether that's compatibility issues, runtime surprises, or integration pain—while maintaining long-term stability.

    How it works

    A consumable library is one that integrates seamlessly into diverse projects without forcing breaking changes or pulling in heavy dependency trees. This starts with multi-targeting: build against multiple .NET versions (e.g., net6.0, net8.0, net9.0) so consumers on different frameworks can use your library without forcing an upgrade path. Use minimal and well-vetted dependencies—each dependency increases your transitive dependency graph and amplifies the risk of version conflicts in downstream consumers.

    Nullable reference types signal intent clearly. Annotate your public API with ? where values can be null, helping consumers write safer code and reducing null-reference surprises. Adopt semantic versioning (SemVer): MAJOR.MINOR.PATCH increments signal the nature of changes. Breaking API changes warrant a major version bump; consumers who pin to a major version know they won't face unexpected breaking changes in minor/patch updates.

    Backward compatibility is sacred. Deprecate instead of remove; mark old members with [Obsolete] attributes to guide users before removal in a future major version. This gives consumers a migration path and respects their investment in your library. Additionally, design for trimming and AOT compatibility so your library works in constrained environments like containers and serverless functions—use source generators where reflection is tempting, and annotate code with [DynamicallyAccessedMembers] to help the AOT analyzer understand your reflection patterns.

    Example

    csharp
    // Library project file: multi-target and set nullable reference types
    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <TargetFrameworks>net6.0;net8.0;net9.0</TargetFrameworks>
        <Nullable>enable</Nullable>
        <PublishTrimmed>true</PublishTrimmed>
        <PublishAot>true</PublishAot>
        <PackageVersion>2.1.0</PackageVersion>
      </PropertyGroup>
    </Project>
    
    // Public API with nullable annotations and deprecation
    public class DataProcessor
    {
        public string? ProcessData(string? input)
        {
            return string.IsNullOrEmpty(input) ? null : input.ToUpper();
        }
    
        [Obsolete("Use ProcessData instead. Removed in v3.0.0", false)]
        public string LegacyProcess(string input) 
            => ProcessData(input) ?? string.Empty;
    }

    This example shows multi-targeting in the project file, nullable annotations in the API, semantic versioning discipline, and an obsolete deprecation path that doesn't break existing code but guides migration.

    Key points to mention

    • Multi-target frameworks using <TargetFrameworks> to reach consumers across .NET versions without forcing upgrades.
    • Nullable reference types enabled (<Nullable>enable</Nullable>) signal trust and reduce null-reference errors in consuming code.
    • Minimal dependencies—every transitive dependency is a version conflict risk; prefer standard library when feasible.
    • Semantic versioning and backward compatibility—use [Obsolete] attributes for graceful deprecation rather than hard breaks.
    • Trimming and AOT support via source generators and [DynamicallyAccessedMembers] to work in constrained environments.

    Common follow-up questions

    • How do you version and release libraries? Discuss CI/CD automation, package versioning strategy, and changelog discipline.
    • How do you approach API backward compatibility? Expand on deprecation patterns, versioning contracts, and consumer migration paths.
    • How do you handle dependency version conflicts in your consumer base? Discuss dependency resolution strategies and semver constraints in package metadata.

    Related interview questions

    Further reading

    .NETCsharpPerformanceDevOps
  310. 310How do you reduce cold start for .NET in containers and serverless?

    Expert

    Cold start latency in .NET containers and serverless is reduced primarily through Native AOT compilation, aggressive trimming, ReadyToRun images, and lazy initialization of heavy services. The goal is to minimize JIT compilation time, reduce memory footprint, and defer non-critical startup work until after the runtime is responsive. This is especially critical for Functions-as-a-Service (FaaS) platforms like AWS Lambda or Azure Functions where every millisecond matters.

    How it works

    Cold start consists of several phases: container/process startup, runtime initialization, assembly loading, JIT compilation, and application bootstrap. Each phase adds latency. Native AOT eliminates JIT by ahead-of-time compiling your entire application to native machine code, reducing cold start from seconds to tens of milliseconds. ReadyToRun (R2R) pre-compiles IL to native code but retains the runtime, offering a middle ground. Trimming removes unused code and dependencies that won't execute, shrinking the image and reducing what the runtime must load. Lazy initialization defers expensive operations—database connections, HTTP clients, logging infrastructure—until first use rather than during startup. Chiseled base images (like Ubuntu Chiseled) remove unnecessary OS utilities, cutting image size and attack surface.

    Example

    csharp
    // Startup code with lazy initialization pattern
    public class SlowService
    {
        private static HttpClient? _httpClient;
        private static readonly Lazy<SlowService> _instance = 
            new(() => new SlowService());
    
        public static SlowService Instance => _instance.Value;
    
        private SlowService()
        {
            // Heavy initialization deferred until first access
            System.Threading.Thread.Sleep(2000); // Simulate setup
        }
    }
    
    // Minimal startup
    var builder = WebApplication.CreateBuilder(args);
    
    // Do NOT call SlowService.Instance here
    builder.Services.AddSingleton(_ => SlowService.Instance); // Lazy registration
    
    var app = builder.Build();
    
    app.MapGet("/", () => new { message = "Fast startup!" });
    app.Run();
    

    This code registers SlowService lazily so it only initializes on first HTTP request, not during container startup. Combined with a project file configured for Native AOT (<InvariantGlobalization>true</InvariantGlobalization>) and trimming (<TrimMode>link</TrimMode>), cold start drops dramatically.

    Key points to mention

    • Native AOT vs. ReadyToRun: Native AOT is fastest (no runtime) but has limitations with reflection and dynamic code; R2R is a pragmatic compromise.
    • Trimming requires testing: Aggressive trimming can break code if reflection is used for type discovery; use [DynamicallyAccessedMembers] attributes carefully.
    • Image size matters: Alpine or Chiseled base images can halve container size, improving pull time and cold start on resource-constrained platforms.
    • Measure baseline: Use CloudWatch Logs (AWS Lambda) or Application Insights to track cold start duration; iterate on the biggest wins first.
    • Trade-offs exist: Native AOT sacrifices some flexibility (no Reflection.Emit, limited serialization) for speed; assess your workload.

    Common follow-up questions

    • What's the difference between Native AOT and ReadyToRun, and when would you use each? Native AOT is ideal for latency-critical workloads (Lambda, Kubernetes edge); ReadyToRun suits standard web apps where the runtime is acceptable overhead.
    • How do you handle reflection in trimmed applications? Use [DynamicallyAccessedMembers] on method parameters or property types to tell the trimmer what to preserve; run the app with trimming enabled during development.
    • What's the impact on runtime performance after cold start? Native AOT applications often run faster overall (no JIT warm-up), but peak throughput may be slightly lower than a fully JIT-optimized app due to loss of runtime optimizations.

    Related interview questions

    Further reading

    .NETCsharpDevOps
  311. 311How do you size and limit memory for .NET in Kubernetes?

    Expert

    Set Kubernetes container memory limits in your Pod spec, enable GC cgroup awareness so the .NET runtime respects those limits, and use GCHeapHardLimit or DATAS to constrain heap growth. Monitor working set memory against your limits to prevent OOM kills; leave headroom between GC limit and container limit for non-heap allocations.

    How it works

    In Kubernetes, memory limits are enforced via Linux cgroups at the container level. By default, the .NET GC may not be aware of these limits and could attempt to allocate memory beyond what the container is allowed, triggering an OOM (Out of Memory) kill. Modern .NET runtimes (6.0+) include cgroup-aware GC that automatically detects memory limits and adjusts heap size accordingly.

    When you set a container memory limit in your Kubernetes Pod spec, the runtime should detect it through /sys/fs/cgroup/memory.limit_in_bytes (cgroups v1) or /sys/fs/cgroup/memory.max (cgroups v2). The GC then uses this as a soft ceiling. For tighter control, you can explicitly set environment variables like DOTNET_GCHeapHardLimit (hard limit) or enable DATAS (Dynamically Adaptive Tenant Sizing) in .NET 8+ to automatically tune heap limits based on container constraints.

    The key distinction is heap limit versus working set. The GC heap is managed memory; working set includes the heap, stacks, native allocations, and JIT code. Your container limit must accommodate both. Set GCHeapHardLimit slightly below container limit (e.g., 80–90%) to leave buffer for non-heap allocations.

    Example

    csharp
    // Program.cs - .NET 8+ console app
    using System;
    
    class MemoryDemo
    {
        static void Main()
        {
            // Check if GC is cgroup-aware
            Console.WriteLine($"GC is server mode: {System.Runtime.GCSettings.IsServerGC}");
            Console.WriteLine($"GC heap hard limit: {GCSettings.GetHeapHardLimit()}");
            Console.WriteLine($"Total available memory: {GC.GetTotalMemory(false) / (1024 * 1024)} MB");
    
            // Allocate to demonstrate pressure
            var buffer = new byte[50_000_000]; // 50 MB
            Console.WriteLine($"After allocation: {GC.GetTotalMemory(false) / (1024 * 1024)} MB");
            GC.Collect();
            Console.WriteLine($"After GC.Collect: {GC.GetTotalMemory(false) / (1024 * 1024)} MB");
        }
    }
    

    This example queries the GC's awareness of heap limits and demonstrates memory pressure; run it with DOTNET_GCHeapHardLimit=104857600 (100 MB) to see the runtime enforce a 100 MB heap cap.

    Key points to mention

    • Cgroup awareness: Ensure .NET 6+ and verify the runtime detects cgroup limits; older runtimes do not.
    • GCHeapHardLimit vs. container limit: Set hard limit 80–90% of pod memory limit to reserve space for stacks and native code.
    • Working set monitoring: Track RSS (Resident Set Size) or working set metrics (via /proc/self/stat or Prometheus); this is the true memory pressure indicator.
    • DATAS and adaptive sizing: In .NET 8+, DATAS automatically adjusts heap limits; consider enabling it for multi-tenant or density-constrained scenarios.
    • Requests vs. limits: Set requests (guaranteed) lower than limits; GC uses the limit for planning, but Kubernetes scheduler uses requests.

    Common follow-up questions

    • What happens if GC hits the hard limit? — The GC throws OutOfMemoryException; the app crashes, and Kubernetes restarts the pod. This is why monitoring working set metrics is critical.
    • How do cgroups v1 and v2 differ? — v2 uses unified hierarchy and different file paths; both are supported, but v2 is more accurate. Most modern clusters use v2 or hybrid.
    • Should I use DATAS or a fixed hard limit? — DATAS is better for variable workloads; fixed hard limit is predictable for steady-state services. Combine with request/limit ratios for best results.

    Related interview questions

    .NETCsharpPerformanceDevOps
  312. 312What metrics would you alert on for a .NET service?

    Expert

    The most critical metrics to alert on for a .NET service fall into three categories: application performance (request rate, error rate, latency percentiles), runtime health (GC pause time, thread pool queue length, exceptions per second), and resource consumption (CPU and memory against configured limits). Together, these signals provide early warning of degradation before users experience outages.

    How it works

    Modern .NET services run within a bounded resource envelope. Application-level metrics tell you whether your code is behaving correctly: request rate (throughput) establishes a baseline, error rate (5xx, timeouts, unhandled exceptions) signals correctness issues, and latency percentiles (p50, p95, p99) reveal user experience. These metrics directly answer "is my service working?"

    Runtime metrics reveal whether the .NET runtime itself is under stress. Garbage collection pause time matters because even brief GC stop-the-world events can cause tail latency spikes and timeout cascades in distributed systems. Thread pool queue length indicates whether work is backing up; sustained queuing suggests either insufficient threads or CPU starvation. Exceptions per second (distinct from error rate) captures the raw rate of thrown exceptions, which often precedes user-visible failures.

    Resource metrics enforce operational limits. CPU and memory consumption must stay within configured thresholds; breaching them triggers cascading failures (Linux OOM killer, container eviction). On Kubernetes or cloud platforms, these metrics directly influence autoscaling and pod eviction decisions.

    Example

    csharp
    using System.Diagnostics;
    using System.Diagnostics.Metrics;
    
    public class ServiceMetrics
    {
        private readonly Meter _meter;
        private readonly Counter<long> _requestCount;
        private readonly Histogram<double> _requestDuration;
        private readonly Counter<long> _errorCount;
        private readonly ObservableGauge<long> _gcPauseMs;
        private readonly ObservableGauge<int> _threadPoolQueue;
    
        public ServiceMetrics()
        {
            _meter = new Meter("MyService");
            _requestCount = _meter.CreateCounter<long>("http.requests", description: "Total requests");
            _requestDuration = _meter.CreateHistogram<double>("http.request.duration", unit: "ms");
            _errorCount = _meter.CreateCounter<long>("http.errors", description: "5xx + exceptions");
            _gcPauseMs = _meter.CreateObservableGauge<long>("gc.pause.ms", 
                () => new[] { new Measurement<long>(GC.GetTotalPauseTime().TotalMilliseconds) });
            _threadPoolQueue = _meter.CreateObservableGauge<int>("threadpool.queue.length",
                () => 
                {
                    ThreadPool.GetAvailableThreads(out int workerThreads, out _);
                    ThreadPool.GetMaxThreads(out int maxWorkers, out _);
                    return new[] { new Measurement<int>(maxWorkers - workerThreads) };
                });
        }
    
        public void RecordRequest(double durationMs, bool isError)
        {
            _requestCount.Add(1);
            _requestDuration.Record(durationMs);
            if (isError) _errorCount.Add(1);
        }
    }
    

    This code instruments a .NET 8 service using the Meter API to export request rate, latency histogram, error count, GC pause time, and thread pool queue depth—exactly what monitoring systems like Prometheus and Application Insights scrape to build dashboards and fire alerts.

    Key points to mention

    • Percentile latency matters more than averages: mean latency can hide tail spikes; alert on p95 and p99 to catch outliers that affect real users.
    • GC pause time is a hidden killer: workstation vs. server GC mode drastically affects pause duration; monitor both GC frequency and pause length.
    • Ratio-based alerts are more reliable than absolute thresholds: error rate (errors/total requests) and exception rate per second scale with load; absolute counts cause false positives.
    • Resource limits must account for headroom: alert at 75–80% of memory/CPU limits, not at 100%, to allow graceful degradation and autoscaling.
    • Correlate metrics to root cause: high error rate + high GC pause suggests memory pressure; high error rate + high thread pool queue suggests CPU starvation or lock contention.

    Common follow-up questions

    • How would you alert on GC pause time in production without adding overhead? (Answer: use EventPipe or built-in perfcounter diagnostics; sample at low frequency.)
    • What's the difference between alerting on exceptions per second vs. error rate? (Exceptions/sec catches even logged exceptions that don't surface as 5xx; error rate is coarser but more user-visible.)
    • How do you prevent alert fatigue from noisy metrics? (Use sliding windows, dampen transient spikes, tune thresholds based on historical percentiles, and correlate signals before firing.)

    Related interview questions

    .NETCsharpasyncPerformance
  313. 313How do you investigate high CPU in production?

    Expert

    Investigating high CPU in production requires capturing runtime traces, analyzing hot stacks to identify the code consuming most cycles, and correlating spikes with application behavior—typically using dotnet-trace, perfview, or commercial profilers. The goal is to move from "the app is slow" to "function X calls Y in a tight loop doing Z millions of times per second."

    How it works

    CPU profiling works by sampling the call stack at regular intervals (typically 1000 times per second). After collecting thousands of samples, you aggregate them to see which functions and code paths appear most frequently, revealing where the CPU is actually spending time. In production, you use low-overhead sampling rather than instrumentation to avoid significant performance impact.

    The typical workflow is:

    1. Capture a trace using dotnet-trace or a performance profiler while the high-CPU condition occurs (usually 30–60 seconds).
    2. Load the trace into a viewer like PerfView, dotnet-trace, or Visual Studio Enterprise.
    3. Examine the call stack flame graph or stack tree to find the hottest functions—those consuming the most samples.
    4. Correlate with metrics: Check request volume, latency, and GC activity in the same time window to understand context.
    5. Dig into root causes: Look for tight loops, regex backtracking, excessive serialization, lock contention, or algorithmic inefficiency.

    Once you identify the culprit, you can reproduce it in a test environment and drill deeper with allocation analysis or more targeted profiling.

    Example

    csharp
    // Problematic code: CPU-heavy regex in a loop
    public class CpuHogService
    {
        private readonly Regex _regex = new Regex(@"^([a-z]+)*$", 
            RegexOptions.Compiled);
    
        public void ProcessRecords(IEnumerable<string> records)
        {
            foreach (var record in records)
            {
                // Catastrophic backtracking on non-matching input
                if (_regex.IsMatch(record))
                {
                    Console.WriteLine($"Match: {record}");
                }
            }
        }
    }
    
    // Better approach: use a simpler regex or early validation
    public class OptimizedService
    {
        // Simpler regex avoids backtracking
        private readonly Regex _regex = new Regex(@"^[a-z]+$", 
            RegexOptions.Compiled);
        
        private readonly TimeSpan _timeout = TimeSpan.FromSeconds(1);
    
        public void ProcessRecords(IEnumerable<string> records)
        {
            foreach (var record in records)
            {
                // Timeout prevents catastrophic backtracking
                try
                {
                    if (_regex.IsMatch(record, _timeout))
                    {
                        Console.WriteLine($"Match: {record}");
                    }
                }
                catch (RegexMatchTimeoutException)
                {
                    Console.WriteLine($"Timeout on: {record}");
                }
            }
        }
    }

    The first example can spike CPU when given pathological input due to regex backtracking; the second avoids the trap with a simpler pattern and a safety timeout, both detected easily in a CPU trace.

    Key points to mention

    • Use sampling profilers in production: dotnet-trace collect is low-overhead and safe to run on live servers; instrumentation profilers add too much overhead.
    • Correlate with application metrics: High CPU during traffic spikes suggests an algorithmic issue under load; during idle suggests background work or a memory pressure trigger (e.g., GC).
    • Watch for common culprits: regex backtracking, inefficient LINQ queries (N+1 patterns), serialization in hot paths, lock contention, and tight loops over large collections.
    • Reproduce locally before fixing: Once you have a hypothesis, create a minimal test case to confirm the issue and validate the fix; CPU profiling in production should guide investigation, not replace testing.
    • Monitor over time: Use Application Insights, Prometheus, or similar to track CPU trends; spikes at specific hours or after deployments narrow down the cause.

    Common follow-up questions

    • What if I see high CPU but can't narrow down a single function? — Check if it's GC pressure or JIT compilation; look at garbage collection stats in the trace, and if CPU spikes coincide with Gen2 collections, focus on allocation analysis.
    • How do you handle high CPU in a multithreaded application? — Look at lock contention in the trace; if many threads are waiting on locks, switch to lock-free data structures or partitioning; see investigating deadlocks and hangs for related techniques.
    • Should I profile locally or in production? — Always try to reproduce in a staging environment first; production tracing should be brief, targeted, and coordinated with your ops team to avoid customer impact.

    Related interview questions

    Further reading

    .NETCsharpasync
  314. 314How do you investigate a deadlock or hang?

    Expert

    Investigating a deadlock or hang requires capturing a memory dump of the running process, then analyzing thread stacks and synchronization objects to identify circular wait conditions, sync-over-async blocking, or tasks that never complete. Use dotnet-dump to collect the dump, then inspect it with clrstack and syncblk commands to find threads blocked on locks or waiting on tasks.

    How it works

    A deadlock occurs when two or more threads are waiting indefinitely for resources held by each other, or when a thread blocks waiting for a task that cannot execute because the thread pool is saturated. A hang is similar—the application appears frozen because critical threads are blocked.

    The investigation process involves:

    • Capture: Use dotnet-dump collect -p <PID> to create a dump file while the hang is occurring.
    • Load the dump: Open it with dotnet-dump analyze <dump-file> on Windows or Linux.
    • Examine threads: Run clrstack -all to see call stacks for all managed threads.
    • Check synchronization: Run syncblk to see which threads own locks and which are waiting.
    • Identify patterns: Look for circular dependencies, threads blocked on lock acquisitions, or async deadlocks where a thread is calling .Result on a task whose continuation cannot run.

    Example

    csharp
    public class DeadlockDemo
    {
        private object _lockA = new();
        private object _lockB = new();
    
        public void ThreadA_WorkFlow()
        {
            lock (_lockA)
            {
                Thread.Sleep(100); // Simulate work
                lock (_lockB) // Waits here if ThreadB holds _lockB
                {
                    Console.WriteLine("ThreadA acquired both locks");
                }
            }
        }
    
        public void ThreadB_WorkFlow()
        {
            lock (_lockB)
            {
                Thread.Sleep(100); // Simulate work
                lock (_lockA) // Waits here if ThreadA holds _lockA
                {
                    Console.WriteLine("ThreadB acquired both locks");
                }
            }
        }
    
        public static void Main()
        {
            var demo = new DeadlockDemo();
            var t1 = new Thread(demo.ThreadA_WorkFlow);
            var t2 = new Thread(demo.ThreadB_WorkFlow);
            
            t1.Start();
            t2.Start();
            
            // Application hangs here—neither thread can proceed
            t1.Join(timeout: 5000);
            t2.Join(timeout: 5000);
            
            Console.WriteLine(t1.IsAlive ? "ThreadA still blocked" : "ThreadA completed");
        }
    }
    

    This code demonstrates classic lock-ordering deadlock: ThreadA acquires _lockA then waits for _lockB, while ThreadB acquires _lockB then waits for _lockA. Running dotnet-dump on this hang would show both threads in lock-wait states, with syncblk revealing the ownership chain.

    Key points to mention

    • Lock ordering: Always acquire locks in the same order across all code paths to prevent circular dependencies.
    • Sync-over-async antipattern: Calling .Result or .Wait() on a task can deadlock if the task's continuation needs a thread that is blocked by the waiting call.
    • Dump analysis is practical: dotnet-dump works on live production servers and provides the ground truth of which threads are blocked and why.
    • Task starvation: If all thread pool threads are blocked waiting on locks or I/O, new tasks cannot run, causing a hang even without a traditional deadlock.
    • Timeout and fallback: Use Monitor.TryEnter with timeouts instead of blocking locks, and avoid .Result in favor of async/await.

    Common follow-up questions

    • What commands would you run in dotnet-dump analyze to find the root cause? (clrstack -all, syncblk, dumpheap -mt <MethodTable>)
    • How does the thread pool contribute to hangs, and how would you monitor it? (Check ThreadPool.GetAvailableThreads(); if it returns zero, thread starvation is likely.)
    • How do you prevent deadlocks in production code? (Use SemaphoreSlim, async/await, ReaderWriterLockSlim, and enforce consistent lock ordering.)

    Related interview questions

    .NETCsharpasync
  315. 315How do you approach API backward compatibility?

    Expert

    Backward compatibility in APIs is maintained through additive-only changes to request/response contracts, semantic versioning with explicit breaking-change policies, deprecation headers with clear timelines, and automated contract testing against known consumer implementations. This approach allows clients to upgrade at their own pace while enabling the API to evolve safely.

    How it works

    API backward compatibility centers on the principle that existing clients must never break when you deploy new versions. This is achieved through a multi-layered strategy:

    Additive Changes: New fields, endpoints, or optional parameters are safe because old clients ignore unknown properties (via JsonIgnore or similar). Never remove fields, rename them, or change their types—deprecate instead.

    Versioning Strategy: Use semantic versioning (MAJOR.MINOR.PATCH). Increment MAJOR only for breaking changes, MINOR for backward-compatible additions, and PATCH for bug fixes. Communicate breaking changes far in advance—typically 12+ months—giving clients time to migrate.

    Deprecation Headers: Signal intent with HTTP headers like Deprecation: true, Sunset: Fri, 31 Dec 2025 23:59:59 GMT, and Link: </docs/migration>; rel="deprecation". Include deprecation notices in API documentation and changelog.

    Contract Testing: Run automated tests against actual consumer payloads and expected behaviors. Use tools like Pact to verify that your changes don't break known client contracts. This catches accidental breaking changes before deployment.

    Versioning Mechanism: Implement URL-based versioning (/api/v2/users), header-based versioning (Accept-Version: 2.0), or query-string versioning. URL versioning is most explicit and easiest to monitor and sunset.

    Example

    csharp
    // User v1 endpoint - existing clients depend on this
    public class UserResponseV1
    {
        [JsonPropertyName("id")]
        public int Id { get; set; }
        
        [JsonPropertyName("name")]
        public string Name { get; set; }
    }
    
    // User v2 endpoint - adds new field, keeps old ones
    public class UserResponseV2
    {
        [JsonPropertyName("id")]
        public int Id { get; set; }
        
        [JsonPropertyName("name")]
        public string Name { get; set; }
        
        // New field - old clients simply ignore it
        [JsonPropertyName("email")]
        public string? Email { get; set; }
        
        // Deprecated field signaled via header on v1 response
        [JsonPropertyName("legacyStatus")]
        [Obsolete("Use statusCode instead")]
        public string? LegacyStatus { get; set; }
        
        // Replacement field for breaking semantic change
        [JsonPropertyName("statusCode")]
        public int? StatusCode { get; set; }
    }
    
    // Controller routing both versions
    [ApiController]
    [Route("api")]
    public class UsersController : ControllerBase
    {
        [HttpGet("v1/users/{id}")]
        public ActionResult GetUserV1(int id)
        {
            var user = _service.GetUser(id);
            return new UserResponseV1 { Id = user.Id, Name = user.Name };
        }
        
        [HttpGet("v2/users/{id}")]
        public ActionResult GetUserV2(int id)
        {
            var user = _service.GetUser(id);
            Response.Headers.Add("Deprecation", "true");
            Response.Headers.Add("Sunset", "Fri, 31 Dec 2025 23:59:59 GMT");
            
            return new UserResponseV2
            {
                Id = user.Id,
                Name = user.Name,
                Email = user.Email,
                StatusCode = user.StatusCode
            };
        }
    }
    

    This example shows coexisting v1 and v2 endpoints; v1 remains stable for old clients, while v2 adds email and uses statusCode semantically, with deprecation headers on v1 signaling its sunset date.

    Key points to mention

    • Never remove or rename fields—add new ones instead; use deprecation headers to signal intent to move clients forward gradually.
    • Treat your API like a library for broad consumption—you control the release schedule, not your consumers.
    • Run contract tests against known consumer payloads to catch accidental breaking changes before they ship.
    • Use semantic versioning and communicate breaking changes 12+ months in advance via multiple channels (headers, email, changelog, documentation).
    • Monitor adoption of old versions; use analytics to confirm clients have migrated before fully sunsetting an endpoint.

    Common follow-up questions

    • How do you handle breaking changes in authentication or security models? (Answer: Treat as MAJOR version; enforce new auth on old v1 endpoints with clear migration period.)
    • What if you must change an enum or constant value? (Answer: Create a new enum, map old values to new ones internally, return new enum in v2; return legacy value in v1.)
    • How do you version events safely in event-driven systems? (Answer: Add new event types, mark old ones as deprecated, and implement adapters to translate between versions.)

    Related interview questions

    Further reading

    .NETCsharpTesting
  316. 316How do you design a system to handle 10x traffic growth?

    Expert

    Designing for 10x traffic growth requires a multi-layered approach: identify bottlenecks through load testing, scale stateless application tiers horizontally, implement caching and database read replicas, offload expensive work to background queues, and partition data to distribute load. The key is building loosely coupled, independently scalable components rather than trying to scale a monolith vertically.

    How it works

    Traffic growth exposes weak points across your system. Start by running load tests against your current architecture to measure response times, CPU, memory, and database query patterns under stress. Identify whether the bottleneck is compute, I/O, memory, or network.

    For compute-bound bottlenecks, use horizontal scaling: deploy multiple stateless API instances behind a load balancer. Store session state in Redis or a distributed cache, not in-process. This lets you add instances without worrying about state synchronization.

    Database reads often become the constraint first. Add read replicas using EF Core's connection string routing to direct queries to replicas while writes go to the primary. Implement caching layers—Redis for hot data—to reduce database hits. Consider designing for horizontal scaling in ASP.NET Core by making each instance stateless and using distributed caching.

    Move long-running operations off the request path. Queue them in RabbitMQ or Azure Service Bus and process asynchronously. This frees request threads and lets you scale workers independently from your API tier.

    Finally, partition data by tenant, region, or user range to distribute load across multiple databases. This prevents a single database from becoming the ceiling on growth.

    Example

    csharp
    // Simulated scaled architecture with multiple layers
    public class ScalableOrderService
    {
        private readonly IDistributedCache _cache;
        private readonly IDbConnectionFactory _connFactory;
        private readonly IMessagePublisher _queue;
        private readonly ILogger<ScalableOrderService> _logger;
    
        public ScalableOrderService(
            IDistributedCache cache,
            IDbConnectionFactory connFactory,
            IMessagePublisher queue,
            ILogger<ScalableOrderService> logger)
        {
            _cache = cache;
            _connFactory = connFactory;
            _queue = queue;
            _logger = logger;
        }
    
        // Read from cache or read replica
        public async Task<Order?> GetOrderAsync(int orderId)
        {
            var cacheKey = $"order:{orderId}";
            var cached = await _cache.GetStringAsync(cacheKey);
            if (!string.IsNullOrEmpty(cached))
                return JsonSerializer.Deserialize<Order>(cached);
    
            // Route to read replica
            var conn = _connFactory.CreateReadReplicaConnection();
            var order = await conn.QueryFirstOrDefaultAsync<Order>(
                "SELECT * FROM Orders WHERE Id = @Id", new { Id = orderId });
    
            if (order != null)
            {
                await _cache.SetStringAsync(cacheKey, 
                    JsonSerializer.Serialize(order), 
                    new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) });
            }
    
            return order;
        }
    
        // Write to primary, queue heavy work
        public async Task<Order> CreateOrderAsync(CreateOrderCommand cmd)
        {
            var conn = _connFactory.CreatePrimaryConnection();
            var order = new Order { CustomerId = cmd.CustomerId, Total = cmd.Total };
            
            await conn.ExecuteAsync(
                "INSERT INTO Orders (CustomerId, Total) VALUES (@CustomerId, @Total)", order);
    
            // Off-load invoice generation and email to background worker
            await _queue.PublishAsync(new OrderCreatedEvent { OrderId = order.Id });
    
            await _cache.RemoveAsync("orders:recent"); // Invalidate cache
            return order;
        }
    }
    
    // Load balancer would route across multiple instances of this service
    // Each instance is stateless and shares the same Redis and database

    This example shows how to route reads to replicas and cache, keep writes on primary, and queue expensive operations—three pillars of horizontal scaling.

    Key points to mention

    • Load testing first: Measure where bottlenecks actually are before optimizing. Use tools like k6, JMeter, or NBomber to simulate 10x load and identify the limiting factor.
    • Stateless design: Store session and state in distributed caches (Redis, Memcached) so any instance can handle any request. This enables seamless horizontal scaling.
    • Database stratification: Separate reads from writes using replicas, caching, and read models (CQRS pattern). Single database writes become the bottleneck far faster than reads.
    • Async work offloading: Move batch processing, reporting, email, and heavy computation to background queues. Reduces request latency and lets you scale workers independently.
    • Data partitioning: When a single database hits limits, partition by tenant or sharding key. Each partition scales independently.

    Common follow-up questions

    • How do you handle session state at scale? Store in distributed cache (Redis) rather than in-process memory. Use sticky sessions or cache-aside pattern so any instance can serve any user.
    • What happens when your cache itself becomes a bottleneck? Add a second-level cache (CDN for static content), use cache hierarchies, or shard the cache layer across multiple Redis instances by key hash.
    • How do you coordinate database migrations across replicas and partitions? Use feature flags to deploy code that works with both old and new schema, run migrations during low-traffic windows, and test on a replica first before touching production primary.

    Related interview questions

    Further reading

    .NETCsharpTesting
  317. 317How do you lead architectural decisions on a .NET team?

    Expert

    Leading architectural decisions on a .NET team requires creating a structured, collaborative process where you document trade-offs systematically, prototype solutions to validate assumptions, establish shared standards, and remain flexible as requirements evolve. This approach ensures buy-in from engineers, reduces risk, and creates a living record that informs future decisions.

    How it works

    Effective architectural leadership combines evidence-based decision-making with team alignment. Start by establishing an Architecture Decision Record (ADR) process—a lightweight, version-controlled document (often using Michael Nygard's template) that captures the problem, options considered, decision, and rationale. Before deciding, run small prototypes or spikes to gather concrete metrics: performance benchmarks, complexity measures, team learning curve, and maintenance burden.

    Facilitate architectural reviews where engineers present options and trade-offs. A senior engineer's role is to ask clarifying questions, highlight hidden constraints (deployment, compliance, team skill), and guide consensus without imposing dictates. Create architecture decision templates and style guides—for example, when to use Domain-Driven Design, when to favor microservices versus monoliths, or dependency injection container standards. Document these as ARCHITECTURE.md in your repository so new team members understand the "why" behind patterns.

    Crucially, revisit decisions quarterly or when requirements shift. Technology choices that made sense at scale 100K users may not at 10M. Build a culture where changing a decision based on new evidence is treated as a win, not a failure. This also means distinguishing yourself as a senior engineer by mentoring junior developers through the decision-making process rather than simply issuing mandates.

    Example

    csharp
    // ADR Template in your architecture repository
    public record ArchitectureDecisionRecord
    {
        public int Id { get; set; }
        public string Title { get; set; } // "Use Minimal APIs vs. Controller-based"
        public string Status { get; set; } // "Pending", "Accepted", "Superseded"
        public DateTime DecisionDate { get; set; }
        public string Problem { get; set; } // Clear context and constraint
        public List

    This template stores decisions with full context; when a new engineer asks "why do we use Minimal APIs?", you point them to the ADR with the original trade-off analysis and can re-evaluate if assumptions change.

    Key points to mention

    • Prototype before deciding: Run proof-of-concepts with representative workloads to gather performance, learning curve, and maintenance data rather than relying on theory.
    • Document and version: Store ADRs in git alongside code so decisions are discoverable, searchable, and auditable; mark superseded decisions clearly.
    • Align on standards: Establish shared templates for logging, DI configuration, testing patterns, and database access so teams don't reinvent approaches.
    • Involve the team: Architectural reviews should include mid-level and junior engineers; their perspectives catch blind spots and build ownership.
    • Revisit regularly: Schedule quarterly architecture reviews or tie them to major milestones; be willing to reverse decisions when evidence or constraints change.

    Common follow-up questions

    • "How do you handle disagreement on an architecture choice?" – Acknowledge valid concerns, propose a spike or prototype to gather data, and set a review date; use evidence to resolve debates rather than rank.
    • "How do you balance innovation with stability?" – Reserve a percentage of sprints (e.g., 10%) for architectural exploration; pilot new tech on non-critical paths before broad adoption.
    • "When should you revisit a major architectural decision?" – When business requirements shift (scaling, compliance), when a new technology significantly lowers risk or cost, or when team feedback surfaces hidden maintenance pain.

    Related interview questions

    .NETCsharpArchitecture
  318. 318What makes a senior .NET engineer different from a mid-level one?

    Expert

    A senior .NET engineer owns outcomes end-to-end—from design through production operations—rather than just writing code that passes review. They design for operability and change, mentor others actively, communicate trade-offs clearly, and critically, know when not to add complexity. The shift is from executing tasks to multiplying team effectiveness and architectural thinking.

    How it works

    The distinction is not about coding speed or knowing more APIs. A mid-level engineer excels at converting requirements into working code within their assigned scope. A senior engineer thinks about the entire lifecycle: How will this be monitored in production? How will the next engineer understand and modify this? What assumptions might break in two years? Will this scale operationally, or will it create on-call pain?

    Seniors operate across multiple dimensions simultaneously. They mentor junior developers by reviewing code with teaching intent, not just approval authority. They influence architectural decisions by articulating trade-offs: "We could use Event Sourcing here, but it adds complexity to queries; let's use a simpler event log pattern instead." They communicate upward and laterally about risk, dependencies, and realistic timelines. They recognize that saying "no" to a feature—or "not yet"—is often the right call.

    This requires deep systems thinking, emotional intelligence, and long-term perspective. Seniors have seen what happens when quick wins become maintenance nightmares. They've experienced the cost of poor observability, tight coupling, and over-engineered solutions. They apply this wisdom proactively.

    Example

    Consider a new caching requirement. A mid-level engineer implements it functionally:

    csharp
    // Mid-level: works, but ignores operability
    public class ProductService
    {
        private static Dictionary<int, Product> _cache = new();
    
        public Product GetProduct(int id)
        {
            if (!_cache.ContainsKey(id))
                _cache[id] = _db.Products.Find(id);
            return _cache[id];
        }
    }
    

    A senior redesigns for observability, invalidation, and team maintenance:

    csharp
    // Senior: owns operability and change
    public class ProductService(IDistributedCache cache, IProductRepository repo, ILogger<ProductService> logger)
    {
        private const string CacheKeyPrefix = "product:";
        private const int CacheDurationSeconds = 300;
    
        public async Task<Product> GetProductAsync(int id)
        {
            var cacheKey = $"{CacheKeyPrefix}{id}";
            var cached = await cache.GetStringAsync(cacheKey);
            
            if (cached != null)
            {
                logger.LogDebug("Cache hit for product {ProductId}", id);
                return JsonSerializer.Deserialize<Product>(cached)!;
            }
    
            var product = await repo.GetAsync(id);
            await cache.SetStringAsync(
                cacheKey,
                JsonSerializer.Serialize(product),
                new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(CacheDurationSeconds) }
            );
            
            logger.LogInformation("Product {ProductId} cached", id);
            return product;
        }
    
        public async Task InvalidateProductAsync(int id)
        {
            await cache.RemoveAsync($"{CacheKeyPrefix}{id}");
            logger.LogInformation("Cache invalidated for product {ProductId}", id);
        }
    }
    

    The senior version enables monitoring (logs), provides invalidation hooks, uses IDistributedCache (testable, scalable), documents assumptions via constants, and integrates with the team's observability stack. This reduces production surprises and on-call burden.

    Key points to mention

    • Outcome ownership: Seniors are accountable for solutions working in production, not just passing code review. They engage with support, monitoring, and incidents.
    • Design for operability: Structured logging, instrumentation, graceful degradation, and clear failure modes are built in, not retrofitted.
    • Mentoring and multiplication: They scale impact by elevating others' capabilities through intentional teaching, code review guidance, and knowledge sharing.
    • Trade-off articulation: Seniors explain why they chose simplicity over features, sync over async, or a familiar pattern over a novel one—and stakeholders trust their reasoning.
    • Complexity discipline: The hardest skill is knowing when to reject a pattern, framework, or approach because it adds more risk than value. This requires confidence and perspective.

    Comparison: Mid-level vs. Senior mindset

    Dimension Mid-Level Engineer Senior Engineer
    Scope Assigned feature or module End-to-end system behavior, team velocity, and production impact
    Code review Does this compile and pass tests? Will this be maintainable in 18 months? How will we debug it? Is the person who wrote this learning?
    Technology choice Use the tool recommended or most popular Evaluate fit for the problem, team skill, and operational cost; sometimes choose boring over exciting
    Risk assessment Identify obvious bugs Identify failure modes, scaling limits, observability gaps, and cascading dependencies
    Communication Status updates and technical explanations Articulate trade-offs to stakeholders, manage expectations, escalate risks early
    Complexity response Implement if asked; explore advanced patterns Push back on complexity unless the problem demands it; advocate for simplicity
    What makes a senior .NET engineer different from a mid-level one diagram

    Common follow-up questions

    • How do you balance shipping speed with code quality and maintainability? (Seniors often slow down deliberately to avoid compound technical debt.)
    • How do you lead architectural decisions on a .NET team? (This tests whether you think systemically and involve others.)
    • Tell me about a time you said "no" to a feature request or technology proposal. What was your reasoning? (Demonstrates conviction and judgment under pressure.)

    Related interview questions

    Further reading

    What makes a senior .NET engineer different from a mid-level one?
    .NETCsharp
  319. 319How do you design a rate limiter for a distributed system?

    Expert

    A distributed rate limiter uses a shared store (Redis) with atomic algorithms like token bucket or sliding window counters, partitions limits by tenant or user ID, and implements graceful degradation when the store is unreachable. This ensures consistent enforcement across all instances while maintaining high throughput and low latency.

    How it works

    In a distributed system, multiple instances cannot rely on in-memory state because each would maintain its own counter, allowing users to bypass limits by distributing requests across servers. Instead, you centralize state in Redis and use Lua scripts to ensure atomic operations—critical because incrementing and comparing a counter must happen as one indivisible action.

    Token Bucket: A bucket holds tokens (e.g., 100 tokens for 1,000 requests/minute). Each request consumes 1 token; if tokens exist, the request proceeds. Tokens refill at a fixed rate. Redis scripts check tokens atomically, decrement if available, and calculate refill time.

    Sliding Window: Track request timestamps in a sorted set, removing old entries outside the window. Count remaining requests; if below the limit, add the new request timestamp. This is precise but more memory-intensive.

    Partition by tenant or user ID to isolate limits. Store keys like rate_limit:{tenant_id}:{endpoint} to support different tiers. When Redis is unavailable, fail open (allow requests) or use local fallback buckets with eventual consistency.

    Example

    csharp
    public class RedisRateLimiter
    {
        private readonly IDatabase _redis;
        private const string TokenBucketScript = @"
            local key = KEYS[1]
            local limit = tonumber(ARGV[1])
            local refill_rate = tonumber(ARGV[2])
            local now = tonumber(ARGV[3])
            
            local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
            local tokens = tonumber(bucket[1]) or limit
            local last_refill = tonumber(bucket[2]) or now
            
            local elapsed = math.max(0, now - last_refill)
            tokens = math.min(limit, tokens + elapsed * refill_rate)
            
            if tokens >= 1 then
                tokens = tokens - 1
                redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
                redis.call('EXPIRE', key, 3600)
                return 1
            end
            return 0
        ";
        
        public async Task TryAllowAsync(string userId, string endpoint, 
            int limit, double refillRate)
        {
            try
            {
                var key = $"rate_limit:{userId}:{endpoint}";
                var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
                
                var result = await _redis.ScriptEvaluateAsync(
                    TokenBucketScript,
                    new[] { (RedisKey)key },
                    new RedisValue[] { limit, refillRate, now }
                );
                
                return (long)result == 1;
            }
            catch (RedisConnectionException)
            {
                // Graceful degradation: allow request, log for monitoring
                return true;
            }
        }
    }
    

    This Lua script atomically checks tokens, refills based on elapsed time, deducts 1 token if available, and returns 1 (allowed) or 0 (denied). The try-catch allows requests when Redis is down, preventing cascading failures.

    Key points to mention

    • Lua atomicity: Ensures the check-and-decrement happens as one operation; without it, race conditions allow overcounting in high-concurrency scenarios.
    • Partitioning strategy: Use composite keys (user ID + endpoint + tenant ID) to enforce per-resource, per-user, and per-tier limits simultaneously.
    • Graceful degradation: Distinguish between Redis timeouts (fail open) and rejections (fail closed); monitor library exhaustion to detect cascading failures early.
    • TTL and cleanup: Set expiration on Redis keys to prevent memory bloat; use consistent TTLs so stale buckets don't skew calculations.
    • Observability: Log rejected requests, track Redis latency, and alert on high rejection rates to detect abuse or misconfiguration.

    Common follow-up questions

    • How do you handle burst traffic or allow customers to purchase higher limits? (Implement tier-based limits; adjust bucket size or refill rate per customer tier; use Redis sorted sets to track tier expiry.)
    • What's the difference between client-side and server-side rate limiting, and when use both? (Client-side saves bandwidth and backend load; server-side enforces hard limits. Use both—client-side is a courtesy, server-side is the guarantee. See rate limiting in .NET 8.)
    • How do you handle clock skew across servers? (Use Redis server time (via TIME command) instead of local clocks; or use NTP to keep servers synchronized and assume acceptable drift.)

    Related interview questions

    Further reading

    .NETCsharpSecurity
  320. 320How do you reduce GC pauses in latency-sensitive services?

    Expert

    To reduce GC pauses in latency-sensitive services, minimize heap allocations, avoid allocating large objects, use object pooling for frequently-created buffers, and strategically configure GCSettings.LatencyMode to SustainedLowLatency during critical windows. Measure pause times using tools like EventPipe or PerfView before and after optimizations to validate improvements.

    How it works

    Garbage collection pauses occur when the GC stops application threads to reclaim unused memory. In latency-sensitive services (trading systems, real-time processing, online games), even millisecond-scale pauses can violate SLA requirements. The .NET GC provides several strategies to minimize pause duration:

    Allocation reduction directly decreases the amount of garbage that must be collected. Fewer objects mean shorter collection cycles and fewer full collections. Large object heap (LOH) awareness helps because allocations ≥85 KB trigger separate, less-frequently-collected segments; fragmentation here causes long blocking pauses. Object pooling reuses instances instead of allocating fresh ones, keeping the live set stable and predictable.

    LatencyMode tuning changes how the runtime schedules collections. SustainedLowLatency mode enables concurrent GC phases and defers full collections, trading peak throughput for consistent pause times. GCHeapAffinitizeMask pins GC threads to specific cores, reducing context switching overhead. Measurement is critical—pause times vary by workload; profiling with EventPipe ensures your changes actually help.

    Example

    csharp
    using System;
    using System.Runtime;
    
    public class LatencySensitiveService
    {
        private readonly ArrayPool<byte> _pool = ArrayPool<byte>.Shared;
        private readonly Queue<ProcessingBuffer> _bufferCache = new();
    
        public LatencySensitiveService()
        {
            // For critical latency windows
            GCSettings.LatencyMode = GCLatencyMode.SustainedLowLatency;
            GCSettings.IsCompactionEnabled = false; // Avoid compaction pauses
        }
    
        public void ProcessRequest(byte[] data)
        {
            // Rent buffer from pool instead of allocating
            byte[] workBuffer = _pool.Rent(data.Length);
            try
            {
                Array.Copy(data, workBuffer, data.Length);
                // Process without intermediate allocations
                AnalyzeBuffer(workBuffer, data.Length);
            }
            finally
            {
                _pool.Return(workBuffer);
            }
        }
    
        private void AnalyzeBuffer(byte[] buffer, int length)
        {
            // Reuse stack-based buffers; avoid Linq or other allocation-heavy APIs
            Span<int> histogram = stackalloc int[256];
            for (int i = 0; i < length; i++)
            {
                histogram[buffer[i]]++;
            }
            // Process histogram...
        }
    }
    

    This example uses ArrayPool to eliminate repeated buffer allocations, SustainedLowLatency to reduce pause duration, and stackalloc to keep temporary data on the stack instead of the heap.

    Key points to mention

    • Allocation-free code paths are the highest-impact optimization—every object not created is garbage that never needs collection.
    • Object pooling (ArrayPool, custom pools) stabilizes heap size and collection frequency, making pause times predictable.
    • Avoid the Large Object Heap by splitting large buffers into smaller chunks or using streaming; LOH collections are often non-concurrent and cause noticeable pauses.
    • GCLatencyMode.SustainedLowLatency enables concurrent marking and defers full collections, but uses more CPU; measure before enabling in production.
    • Profile with EventPipe or PerfView to measure Gen2 collection frequency and pause duration; optimizations without data are guesses.

    Common follow-up questions

    • What is the difference between SustainedLowLatency and Interactive latency modes? (SustainedLowLatency prioritizes consistent pauses; Interactive triggers more aggressive full collections to reduce overall pause time in short windows.)
    • How does the Large Object Heap affect pause times, and how do you avoid it? (LOH segments use a different collection strategy; objects ≥85 KB go there. Split large allocations or use streaming instead.)
    • Can you completely eliminate GC pauses? (No—even concurrent GC has stop-the-world phases for marking roots and sweeping. You can only reduce frequency and duration; for ultra-low latency, consider value types and stack allocation.)

    Related interview questions

    Further reading

    .NETCsharpPerformance
ShareXLinkedInReddit
300+ .NET Interview Questions and Answers for 2026 · Expona