LEARN BUILD GROW TOGETHER
DEVELOPER LEARNING HUB
Learn. Build. Grow Together.

Practical tutorials, real-world examples and interview preparation.

.NETC#SQL SERVERANGULAR
Home /.NET /C# Developer Learning Guide – Part 2: Advanced C# for .NET Developers

C# Developer Learning Guide – Part 2: Advanced C# for .NET Developers

C#: Complete Developer Learning Guide – Part 2 (Intermediate to Advanced)

Master advanced C# 14 and modern .NET development through practical examples covering LINQ, generics, async programming, memory, performance, dependency injection, concurrency, and application design.

Introduction

Part 1 introduced C# fundamentals and object-oriented programming: variables, types, methods, collections, classes, interfaces, inheritance, polymorphism, and encapsulation. Part 2 moves beyond syntax and explores the language and runtime capabilities that .NET developers use to build maintainable, responsive, and efficient applications.

This guide is designed for developers who already understand basic C# and want to become more confident with intermediate and advanced concepts. Each topic explains the purpose of a feature and shows a practical example that can be adapted to real projects.

Version baseline: Examples target C# 14 with .NET 10. C# 14 language features are identified explicitly. Some examples use ASP.NET Core and Entity Framework Core APIs; add the relevant package and namespace references in the project where required. Always verify provider-specific behavior and current framework documentation.
Important: This is a learning guide, not a recommendation to use every advanced feature in every project. Prefer clear, maintainable code. Measure performance before optimizing, and apply security and reliability practices at application boundaries.

Generics: Reusable and Type-Safe Code

Why Generics Exist

Generics let a type or method work with a family of types while preserving compile-time type safety. Instead of storing everything as object and casting later, a generic API expresses the type it accepts and returns.

public sealed class Box<T>
{
    public T Value { get; }

    public Box(T value) => Value = value;
}

var nameBox = new Box<string>("Ada");
var countBox = new Box<int>(3);
Console.WriteLine(nameBox.Value.ToUpperInvariant());

Generic Methods

static T FirstOrDefault<T>(IReadOnlyList<T> items, T fallback)
{
    return items.Count > 0 ? items[0] : fallback;
}

var firstName = FirstOrDefault(new[] { "Mira", "Sam" }, "Unknown");

Generic Constraints

Constraints describe capabilities required from a type argument. Common constraints include where T : class, struct, notnull, new(), and a base class or interface.

public static T CreateDefault<T>() where T : new()
{
    return new T();
}

public static decimal Total<T>(IEnumerable<T> items)
    where T : IHasPrice
{
    return items.Sum(item => item.Price);
}

public interface IHasPrice
{
    decimal Price { get; }
}
Developer guidance: Prefer a meaningful generic abstraction over a highly clever one. A generic type should make reuse safer and clearer, not hide the domain model.

Delegates, Func, Action and Predicate

What Is a Delegate?

A delegate is a type-safe reference to a method with a particular parameter list and return type. Delegates allow behavior to be passed as data—for example, a sorting rule, callback, or validation operation.

public delegate decimal DiscountRule(decimal amount);

static decimal TenPercent(decimal amount) => amount * 0.10m;

DiscountRule rule = TenPercent;
decimal discount = rule(500m); // 50

Built-In Delegate Types

TypePurposeExample
Action<T>Accepts parameters and returns voidAction<string>
Func<T, TResult>Accepts parameters and returns a valueFunc<int, bool>
Predicate<T>Tests a value and returns boolPredicate<Employee>
Action<string> log = message => Console.WriteLine(message);
Func<decimal, decimal> addTax = price => price * 1.18m;
Predicate<int> isAdult = age => age >= 18;

log("Order processed");
Console.WriteLine(addTax(100m));
Console.WriteLine(isAdult(21));
A delegate describes a callable signature. It does not automatically make the operation asynchronous, thread-safe, or safe to execute with untrusted input.

Lambda Expressions and Closures

A lambda is a concise way to write an anonymous function. Lambdas are used heavily with LINQ, callbacks, event handlers, and asynchronous code.

Func<int, int, int> add = (left, right) => left + right;
Func<string, bool> hasText = value => !string.IsNullOrWhiteSpace(value);

var result = add(10, 20);
Console.WriteLine(hasText("C#"));

Statement Lambdas

Func<int, string> classify = score =>
{
    if (score >= 80) return "High";
    if (score >= 50) return "Medium";
    return "Low";
};

Closures

A lambda can capture a local variable from its surrounding scope. The captured variable remains available to the delegate. This is called a closure.

static Func<int> CreateCounter()
{
    int count = 0;
    return () => ++count;
}

var next = CreateCounter();
Console.WriteLine(next()); // 1
Console.WriteLine(next()); // 2
Watch captured state: A closure that captures mutable state can produce surprising results when reused or invoked concurrently. Keep captured state small and make synchronization explicit when multiple threads can access it.

Events and the Publisher–Subscriber Pattern

Events provide a controlled notification mechanism. A publisher announces that something happened; subscribers register handlers without the publisher needing to know their concrete implementations.

public sealed class OrderPlacedEventArgs(int orderId) : EventArgs
{
    public int OrderId { get; } = orderId;
}

public sealed class OrderService
{
    public event EventHandler<OrderPlacedEventArgs>? OrderPlaced;

    public void PlaceOrder(int orderId)
    {
        // Save the order before announcing success.
        OrderPlaced?.Invoke(this, new OrderPlacedEventArgs(orderId));
    }
}

var service = new OrderService();
service.OrderPlaced += (_, e) =>
    Console.WriteLine($"Order {e.OrderId} placed");
service.PlaceOrder(1042);

Subscribe and Unsubscribe

Use += to subscribe and -= to unsubscribe. Long-lived publishers can keep subscribers alive through event references, so remember to unsubscribe when a subscriber no longer needs notifications.

Do not use an in-process C# event as a replacement for durable messaging between separate services or processes. For distributed workflows, use a message broker or another explicit integration mechanism.

LINQ: Querying Collections and Data

Language Integrated Query (LINQ) provides a consistent way to filter, project, sort, group, and aggregate data. LINQ works with in-memory collections and, through providers such as Entity Framework Core, can translate supported expressions into database queries.

Filtering, Projection and Ordering

var activeNames = employees
    .Where(employee => employee.IsActive)
    .OrderBy(employee => employee.LastName)
    .ThenBy(employee => employee.FirstName)
    .Select(employee => $"{employee.FirstName} {employee.LastName}")
    .ToList();

Common LINQ Operators

OperatorUse
WhereFilter matching elements
SelectTransform each element
Any / AllTest whether some or all elements match
First / FirstOrDefaultGet the first matching element
Single / SingleOrDefaultAssert zero-or-one or exactly-one match
GroupByGroup elements by a key
JoinCombine sequences using matching keys
Sum / AverageAggregate numeric values
DistinctRemove duplicate values

Grouping and Aggregation

var summary = orders
    .Where(order => order.Status == OrderStatus.Completed)
    .GroupBy(order => order.CustomerId)
    .Select(group => new
    {
        CustomerId = group.Key,
        OrderCount = group.Count(),
        Revenue = group.Sum(order => order.Total)
    })
    .OrderByDescending(item => item.Revenue)
    .ToList();

Deferred and Immediate Execution

Many LINQ operators build a query that executes when enumerated. This is deferred execution. Operators such as ToList, ToArray, Count, and First execute the query to produce a result.

var query = employees.Where(e => e.IsActive); // Not executed yet

var firstRead = query.ToList(); // Executes now
var secondRead = query.ToList(); // Executes again
EF Core performance: Do not call ToList() too early. Keep filtering, projection, ordering, and paging on the IQueryable side so the database can do the work. Inspect generated SQL and avoid accidental N+1 queries.

IEnumerable, IQueryable and Deferred Execution

IEnumerable<T> represents a sequence that can be enumerated. IQueryable<T> extends this idea with an expression tree and a query provider that may translate the query into another language, such as SQL.

ConceptTypical behavior
IEnumerable<T>Iteration over objects; LINQ-to-Objects runs .NET delegates.
IQueryable<T>Builds an expression-based query interpreted by a provider.
// In-memory collection
IEnumerable<Employee> local = employees
    .Where(e => e.IsActive);

// EF Core query (conceptual example)
IQueryable<Employee> databaseQuery = dbContext.Employees
    .Where(e => e.IsActive)
    .OrderBy(e => e.LastName);

var page = await databaseQuery
    .Take(20)
    .ToListAsync(cancellationToken);
An IQueryable is not inherently faster. It is useful when the provider can translate the expression efficiently. Some .NET methods cannot be translated by a database provider; keep query expressions provider-compatible and materialize deliberately.

Advanced Pattern Matching and Switch Expressions

Pattern matching checks whether a value has a particular shape and can extract information from it. It often replaces nested conditionals with concise, readable logic.

Type, Relational and Logical Patterns

static string Describe(object? value) => value switch
{
    null => "No value",
    int number when number < 0 => "Negative integer",
    int number => $"Integer: {number}",
    string { Length: 0 } => "Empty text",
    string text => $"Text ({text.Length} chars)",
    DateTime => "Date and time",
    _ => "Other value"
};

Property and List Patterns

static bool IsPriorityOrder(Order order) => order is
{
    Status: OrderStatus.Pending,
    Total: > 1000m,
    Items.Count: > 0
};

static string FirstTwo(int[] values) => values switch
{
    [var first, var second, ..] => $"{first}, {second}",
    [var only] => $"Only {only}",
    [] => "Empty",
    _ => "Not matched"
};
Pattern matching is most valuable when it makes domain rules visible. Avoid giant switch expressions that combine unrelated business decisions.

Records, Immutability and with Expressions

Records are data-oriented reference or value types that provide value-based equality and convenient non-destructive mutation syntax. They are useful for DTOs, messages, and immutable domain values when their semantics fit.

public record Address(string City, string Country);

public record Customer(
    int Id,
    string Name,
    Address Address);

var original = new Customer(7, "Asha",
    new Address("Delhi", "India"));

var updated = original with
{
    Name = "Asha Sharma"
};

Console.WriteLine(original == updated); // False

Record Class vs Record Struct

DeclarationSemanticsTypical use
record / record classReference type, value-based equalityDTOs and immutable message models
record structValue type, value-based equalitySmall value-like data
A record is not automatically deeply immutable. A record containing a mutable list can still expose mutable state. Prefer immutable members or defensive copies where immutability is required.

Iterators, yield return and Lazy Sequences

Iterator methods use yield return to produce values one at a time. This can avoid building a complete collection when a consumer only needs to enumerate values.

static IEnumerable<int> ReadEvenNumbers(int maximum)
{
    for (int number = 0; number <= maximum; number++)
    {
        if (number % 2 == 0)
            yield return number;
    }
}

foreach (int number in ReadEvenNumbers(10))
{
    Console.WriteLine(number);
}

When Iterators Help

  • Processing large sequences incrementally.
  • Creating a sequence without allocating a full list up front.
  • Representing a simple stateful enumeration.
An iterator body runs during enumeration, not when the iterator method is first called. If the sequence is enumerated twice, the iterator logic can run twice.

Async Programming: async and await

Asynchronous programming helps an application remain responsive while waiting for I/O operations such as database calls, HTTP requests, and file access. In ASP.NET Core, asynchronous I/O helps the server release a request thread while an operation is waiting.

A Basic Async Method

public async Task<Customer?> GetCustomerAsync(
    int customerId,
    CancellationToken cancellationToken)
{
    return await dbContext.Customers
        .AsNoTracking()
        .FirstOrDefaultAsync(
            customer => customer.Id == customerId,
            cancellationToken);
}

Task Return Types

Return typeMeaning
TaskAsynchronous operation with no result value
Task<T>Asynchronous operation that produces a T
ValueTask<T>Can reduce allocations in selected high-frequency paths; use only when justified
IAsyncEnumerable<T>Asynchronously streams a sequence of values

Avoid Blocking Async Code

// Prefer:
var customer = await GetCustomerAsync(id, cancellationToken);

// Avoid blocking an asynchronous operation:
// var customer = GetCustomerAsync(id, cancellationToken).Result;
// GetCustomerAsync(id, cancellationToken).Wait();
Important: async does not automatically create a new thread. Do not wrap ordinary asynchronous I/O in Task.Run inside a web request. Use async APIs provided by the database, HTTP, and file libraries.

Task.WhenAll, Task.WhenAny and Concurrency

When independent asynchronous operations can run concurrently, Task.WhenAll can reduce total waiting time. It does not make dependent operations parallel, and it does not guarantee that the underlying service can handle unlimited concurrency.

public async Task<DashboardDto> LoadDashboardAsync(
    CancellationToken cancellationToken)
{
    Task<IReadOnlyList<OrderDto>> ordersTask =
        orderClient.GetRecentAsync(cancellationToken);

    Task<IReadOnlyList<AlertDto>> alertsTask =
        alertClient.GetOpenAsync(cancellationToken);

    await Task.WhenAll(ordersTask, alertsTask);

    return new DashboardDto(
        await ordersTask,
        await alertsTask);
}

Task.WhenAny

Use Task.WhenAny when you need to react to the first task that completes—for example, racing a request against a timeout. A timeout does not automatically stop the underlying operation; pass a cancellation token to the operation and cancel it when appropriate.

using var timeoutCts = new CancellationTokenSource(
    TimeSpan.FromSeconds(3));

var response = await httpClient.GetAsync(
    "https://example.com/status",
    timeoutCts.Token);
For production systems, apply explicit concurrency limits, timeouts, retries for transient faults, and downstream capacity controls. Unbounded parallelism can overload databases and external services.

CancellationToken and Cooperative Cancellation

Cancellation in .NET is cooperative. A caller requests cancellation through a CancellationTokenSource; the operation observes the token and stops at safe points. Cancellation is not the same as forcibly terminating a thread.

public async Task<IReadOnlyList<ProductDto>> SearchAsync(
    string term,
    CancellationToken cancellationToken)
{
    cancellationToken.ThrowIfCancellationRequested();

    return await dbContext.Products
        .AsNoTracking()
        .Where(product => product.Name.Contains(term))
        .Select(product => new ProductDto(
            product.Id, product.Name, product.Price))
        .ToListAsync(cancellationToken);
}

Pass the Token Through the Call Chain

In ASP.NET Core, an endpoint can accept a CancellationToken parameter. The framework supplies a request-aborted token. Pass it to EF Core, HttpClient, stream operations, and other APIs that support cancellation.

app.MapGet("/products", async (
    string term,
    ProductService service,
    CancellationToken cancellationToken) =>
{
    var products = await service.SearchAsync(
        term, cancellationToken);
    return Results.Ok(products);
});
Do not swallow OperationCanceledException indiscriminately. Decide whether cancellation is expected in the current layer and allow request cancellation to propagate when appropriate.

Exception Design and Reliable Error Handling

Exceptions represent failures that prevent an operation from completing normally. Design exception handling around recovery boundaries: the layer that can meaningfully recover should handle the failure; otherwise, allow it to propagate to centralized handling.

Create Domain-Specific Exceptions Carefully

public sealed class InsufficientStockException(
    int productId, int requested, int available)
    : Exception($"Product {productId}: requested {requested}, available {available}.")
{
    public int ProductId { get; } = productId;
    public int Requested { get; } = requested;
    public int Available { get; } = available;
}

Use Specific Catch Blocks

try
{
    await paymentGateway.ChargeAsync(request, cancellationToken);
}
catch (PaymentDeclinedException ex)
{
    logger.LogInformation(ex, "Payment was declined");
    return PaymentResult.Declined();
}
catch (HttpRequestException ex)
{
    logger.LogError(ex, "Payment provider communication failed");
    throw; // Preserve the original exception and stack trace.
}

Result Types for Expected Outcomes

Not every unsuccessful business outcome needs an exception. Validation failures, missing search results, or a declined business operation may be better represented by a result type when they are expected parts of the workflow.

public sealed record Result<T>(
    bool IsSuccess, T? Value, string? Error)
{
    public static Result<T> Success(T value) =>
        new(true, value, null);

    public static Result<T> Failure(string error) =>
        new(false, default, error);
}
Never expose stack traces, SQL details, secrets, or internal exception messages in public API responses. Log diagnostic details securely and return a stable, appropriate error contract.

File Handling, Streams and System.IO

The System.IO APIs work with files, directories, and streams. A stream represents a sequence of bytes and allows data to be processed without loading an entire file into memory.

Read and Write Text Asynchronously

string path = Path.Combine(
    AppContext.BaseDirectory, "reports", "summary.txt");

Directory.CreateDirectory(Path.GetDirectoryName(path)!);

await File.WriteAllTextAsync(
    path, "Monthly report", cancellationToken);

string content = await File.ReadAllTextAsync(
    path, cancellationToken);

Stream Large Files

await using var input = new FileStream(
    sourcePath, FileMode.Open, FileAccess.Read,
    FileShare.Read, bufferSize: 81920, useAsync: true);

await using var output = new FileStream(
    destinationPath, FileMode.Create, FileAccess.Write,
    FileShare.None, bufferSize: 81920, useAsync: true);

await input.CopyToAsync(output, cancellationToken);

JSON Serialization

using System.Text.Json;

var options = new JsonSerializerOptions
{
    WriteIndented = true
};

await using var stream = File.Create("settings.json");
await JsonSerializer.SerializeAsync(
    stream, settings, options, cancellationToken);
Treat uploaded filenames and paths as untrusted input. Validate file size and type, generate server-side names, prevent path traversal, and store sensitive uploads outside publicly served directories.

Memory Management and Garbage Collection

.NET uses a managed heap and garbage collector (GC) to reclaim memory occupied by objects that are no longer reachable. Developers normally do not free ordinary managed objects manually, but they are responsible for releasing unmanaged resources and avoiding unnecessary allocations.

Generations and Large Object Heap

The GC groups many managed objects into generations. Short-lived objects are commonly collected in Generation 0; surviving objects may be promoted. Large allocations can be placed on the Large Object Heap. These are implementation details that help explain performance behavior, not rules for manually placing objects.

IDisposable and using

await using var connection = new SqlConnection(connectionString);
await connection.OpenAsync(cancellationToken);

// The connection is disposed when the scope exits.

Avoid Common Allocation Problems

  • Avoid repeated string concatenation in large loops; use StringBuilder when appropriate.
  • Do not materialize large query results when streaming or paging is sufficient.
  • Reuse long-lived services such as HttpClient through IHttpClientFactory.
  • Dispose streams, database connections, and other disposable resources deterministically.
  • Measure allocations before introducing pooling or low-level optimizations.
Do not call GC.Collect() as a routine performance fix. It usually interferes with the runtime’s collection strategy. Diagnose memory pressure with profiling and metrics.

Span, Memory and High-Performance Buffers

Span<T> and ReadOnlySpan<T> represent contiguous memory regions without requiring a new array. They are useful for parsing and processing data with fewer allocations. A span is stack-only and cannot be stored in ordinary heap objects or cross an await boundary in the usual way.

ReadOnlySpan<char> input = "INV-2026-0042";
ReadOnlySpan<char> prefix = input[..4];
ReadOnlySpan<char> number = input[5..];

Console.WriteLine(prefix); // INV
Console.WriteLine(number); // 2026-0042

Memory for Asynchronous Work

Memory<T> can be stored and passed across asynchronous boundaries. Use it when an API needs a memory segment that may remain alive while asynchronous work is pending.

static async Task<int> ReadIntoBufferAsync(
    Stream stream, Memory<byte> buffer,
    CancellationToken cancellationToken)
{
    return await stream.ReadAsync(buffer, cancellationToken);
}
These APIs are advanced tools. Prefer clear, ordinary arrays and strings until profiling shows allocation or throughput pressure.

C# 14: Extension Members

C# 14 introduces extension members, expanding extension functionality beyond traditional extension methods. They allow extension methods and extension properties to be grouped in an extension block. The feature is supported by the .NET 10 / C# 14 toolchain.

public static class StringExtensions
{
    extension(string value)
    {
        public bool HasContent =>
            !string.IsNullOrWhiteSpace(value);

        public string ToSafeLabel() =>
            value.Trim().Replace("_", "-");
    }
}

string title = "  order_status  ";
Console.WriteLine(title.HasContent);
Console.WriteLine(title.ToSafeLabel());
Extension members do not modify the original type or its instances. They are compile-time language features, and the extension container must be in scope. Check the current C# 14 documentation for exact syntax and limitations when adopting them in a codebase.

Traditional Extension Method Syntax Still Works

public static class DateTimeExtensions
{
    public static bool IsWeekend(this DateTime date) =>
        date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday;
}

C# 14: The field-Backed Property Keyword

C# 14 introduces the contextual field keyword for property accessors. It lets an accessor refer to the compiler-provided backing field without declaring a separate field manually.

public class UserProfile
{
    public string DisplayName
    {
        get;
        set => field = string.IsNullOrWhiteSpace(value)
            ? throw new ArgumentException("Name is required.")
            : value.Trim();
    } = "New user";
}

This can reduce boilerplate when a property needs validation or normalization while retaining compiler-managed storage. If a member named field already exists in the relevant scope, use the documented escaping syntax to avoid ambiguity.

Use this feature to simplify a property—not to hide complex business workflows inside a setter.

C# 14: Null-Conditional Assignment

C# 14 supports null-conditional assignment. It allows assignment through a possibly null receiver without a separate null check.

customer?.PreferredName = "Asha";
customer?.Address?.City = "Delhi";

If the receiver on the left side is null, the assignment is skipped. This is useful for concise optional updates, but it does not replace validation of required domain state.

Do not confuse null-conditional assignment with null-coalescing assignment (??=). They solve different problems.

Reflection and Runtime Type Inspection

Reflection allows code to inspect assemblies, types, members, attributes, and metadata at runtime. Frameworks use reflection for dependency injection, serialization, model binding, and test discovery.

Type type = typeof(Customer);

Console.WriteLine(type.FullName);

foreach (var property in type.GetProperties())
{
    Console.WriteLine(
        $"{property.Name}: {property.PropertyType.Name}");
}

Create an Instance Dynamically

object? instance = Activator.CreateInstance(typeof(SomePlugin));

Reflection can be powerful for plugin systems and infrastructure, but it is generally less direct than normal typed code. It can introduce runtime errors, trimming concerns, and performance overhead. Prefer compile-time abstractions when the required types are known.

In trimmed and Native AOT applications, reflection-based discovery may require explicit metadata preservation or source-generated alternatives. Test the published application, not only Debug builds.

Attributes and Custom Metadata

Attributes attach declarative metadata to program elements. They are commonly used by frameworks and tools to influence validation, serialization, routing, testing, and other behavior.

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public sealed class AuditAttribute(string category) : Attribute
{
    public string Category { get; } = category;
}

[Audit("Finance")]
public sealed class InvoiceService
{
    [Audit("SensitiveOperation")]
    public void ApproveInvoice(int invoiceId) { }
}

Read an Attribute

var attribute = typeof(InvoiceService)
    .GetCustomAttributes(typeof(AuditAttribute), inherit: true)
    .Cast<AuditAttribute>()
    .FirstOrDefault();

Console.WriteLine(attribute?.Category);
An attribute is metadata; it does not execute behavior by itself. A framework or your own code must inspect the attribute and decide what to do.

Expression Trees and Dynamic Query Composition

An expression tree represents code as data. Unlike a normal delegate, an expression tree can be inspected, transformed, or translated by a provider. Entity Framework Core uses expression trees to translate LINQ queries into SQL.

using System.Linq.Expressions;

Expression<Func<Product, bool>> predicate =
    product => product.Price >= 100m;

Console.WriteLine(predicate);

Func<Product, bool> compiled = predicate.Compile();
bool matches = compiled(new Product { Price = 150m });

Compose a Predicate

static Expression<Func<T, bool>> And<T>(
    Expression<Func<T, bool>> left,
    Expression<Func<T, bool>> right)
{
    var parameter = Expression.Parameter(typeof(T), "item");
    var leftBody = new ReplaceParameterVisitor(
        left.Parameters[0], parameter).Visit(left.Body)!;
    var rightBody = new ReplaceParameterVisitor(
        right.Parameters[0], parameter).Visit(right.Body)!;

    return Expression.Lambda<Func<T, bool>>(
        Expression.AndAlso(leftBody, rightBody), parameter);
}

sealed class ReplaceParameterVisitor(
    ParameterExpression oldParameter,
    ParameterExpression newParameter) : ExpressionVisitor
{
    protected override Expression VisitParameter(ParameterExpression node) =>
        node == oldParameter ? newParameter : base.VisitParameter(node);
}

The visitor replaces each lambda parameter with one shared parameter so the combined expression remains valid. Expression-tree composition is an advanced technique—use it when a reusable query builder genuinely needs to construct expressions dynamically.

Dependency Injection and Service Lifetimes

Dependency injection (DI) supplies a class with the dependencies it needs instead of having the class construct those dependencies itself. ASP.NET Core includes a built-in DI container.

Register Services

builder.Services.AddTransient<IPriceCalculator, PriceCalculator>();
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddSingleton<ISystemClock, SystemClock>();

Understand the Lifetimes

LifetimeCreatedTypical use
TransientEach time requestedLightweight, stateless services
ScopedOnce per scope; usually one HTTP requestRequest-level services and EF Core DbContext
SingletonOnce for the application containerShared, thread-safe services

Constructor Injection

public sealed class OrderService(
    IOrderRepository repository,
    ILogger<OrderService> logger) : IOrderService
{
    public async Task<Order?> FindAsync(
        int id, CancellationToken cancellationToken)
    {
        logger.LogInformation("Finding order {OrderId}", id);
        return await repository.FindAsync(id, cancellationToken);
    }
}
Lifetime rule: Do not inject a scoped service directly into a singleton. This can cause a scoped dependency to live much longer than intended. Create an explicit scope when a singleton must use scoped services.

Options Pattern and Configuration

The options pattern maps related configuration values to a strongly typed class. It improves discoverability, validation, and testability compared with reading configuration keys throughout the application.

public sealed class EmailOptions
{
    public const string SectionName = "Email";
    public string Host { get; set; } = "";
    public int Port { get; set; }
}
builder.Services
    .AddOptions<EmailOptions>()
    .Bind(builder.Configuration.GetSection(EmailOptions.SectionName))
    .Validate(options => !string.IsNullOrWhiteSpace(options.Host),
        "Email host is required")
    .ValidateOnStart();

Inject IOptions<EmailOptions>, IOptionsSnapshot<EmailOptions>, or IOptionsMonitor<EmailOptions> according to the required lifetime and reload behavior. Keep secrets in a secret store or managed identity-backed configuration source rather than committing them to source control.

HttpClientFactory and Resilient HTTP Calls

Creating and managing outbound HTTP clients incorrectly can lead to connection exhaustion or stale DNS behavior. ASP.NET Core provides IHttpClientFactory to centralize client configuration and handler lifetime management.

builder.Services.AddHttpClient<InventoryClient>(client =>
{
    client.BaseAddress = new Uri("https://inventory.example/");
    client.Timeout = TimeSpan.FromSeconds(10);
});

public sealed class InventoryClient(HttpClient httpClient)
{
    public async Task<ItemDto?> GetItemAsync(
        int id, CancellationToken cancellationToken)
    {
        return await httpClient.GetFromJsonAsync<ItemDto>(
            $"api/items/{id}", cancellationToken);
    }
}

For production, consider resilience policies for transient failures, but make retries safe. Retrying a non-idempotent operation can create duplicate effects unless the remote API supports idempotency keys or another deduplication strategy.

Set sensible timeouts. Do not retry every failure: validation errors, authentication failures, and most 4xx responses are not transient. Avoid retry storms by using backoff, jitter, and limits.

Thread Safety, Locks and Concurrent Collections

Thread safety means shared state remains correct when accessed concurrently. The simplest strategy is often to avoid shared mutable state. When sharing is necessary, use synchronization primitives or concurrent collections deliberately.

A Lock Protects a Critical Section

public sealed class RequestCounter
{
    private readonly object _sync = new();
    private int _count;

    public void Increment()
    {
        lock (_sync)
        {
            _count++;
        }
    }

    public int Value
    {
        get { lock (_sync) return _count; }
    }
}

ConcurrentDictionary

var counts = new ConcurrentDictionary<string, int>();

counts.AddOrUpdate(
    "orders",
    addValue: 1,
    updateValueFactory: (_, current) => current + 1);
Do not hold a lock across an await. A lock is thread-affine and cannot be used with asynchronous suspension. For async coordination, consider SemaphoreSlim or redesign to avoid shared mutable state.

Parallel Programming and PLINQ

Parallel programming can improve CPU-bound workloads when the work can be safely divided. It is not a universal performance improvement: scheduling, synchronization, memory pressure, and contention can make parallel code slower.

var results = numbers
    .AsParallel()
    .Where(IsExpensiveMatch)
    .Select(Transform)
    .ToArray();

Parallel.ForEachAsync

await Parallel.ForEachAsync(
    workItems,
    new ParallelOptions
    {
        MaxDegreeOfParallelism = 4,
        CancellationToken = cancellationToken
    },
    async (item, token) =>
    {
        await processor.ProcessAsync(item, token);
    });
Use parallelism for independent work and set a deliberate degree of concurrency. For I/O-bound operations, asynchronous APIs are usually the first tool; parallel execution is not a substitute for async I/O.

Testing Advanced C# Code

Good tests verify observable behavior rather than private implementation details. Keep business rules testable by isolating them from databases, network calls, clocks, and other external dependencies.

Arrange, Act, Assert

[Fact]
public void CalculateDiscount_ForPreferredCustomer_ReturnsTenPercent()
{
    // Arrange
    var calculator = new DiscountCalculator();

    // Act
    decimal result = calculator.Calculate(200m, isPreferred: true);

    // Assert
    Assert.Equal(20m, result);
}

Test Asynchronous Methods

[Fact]
public async Task FindAsync_WhenMissing_ReturnsNull()
{
    var repository = new FakeCustomerRepository();
    var service = new CustomerService(repository);

    var result = await service.FindAsync(999, CancellationToken.None);

    Assert.Null(result);
}

Testing Guidance

  • Unit-test domain rules and pure functions quickly.
  • Use integration tests for database mappings, HTTP pipelines, and serialization.
  • Use test doubles only where they clarify the boundary; avoid mocking every internal class.
  • Test cancellation, invalid input, duplicate requests, and failure paths.
  • Keep tests deterministic; control time and randomness through abstractions when needed.

Design Principles for Maintainable .NET Applications

Language features help express code, but maintainability depends on design. These principles provide a practical checklist for application-level C# development.

SOLID in Daily Development

  • Single Responsibility: keep a class focused on one cohesive responsibility.
  • Open/Closed: extend behavior through suitable abstractions instead of repeatedly editing stable code.
  • Liskov Substitution: implementations should honor the contracts of their base types.
  • Interface Segregation: keep interfaces focused on the capabilities clients actually need.
  • Dependency Inversion: high-level policy should depend on abstractions, not infrastructure details.

Composition Over Inheritance

Use composition when a class needs capabilities from other components but does not represent a true “is-a” relationship. Composition makes dependencies explicit and often allows simpler testing.

public interface INotificationSender
{
    Task SendAsync(string destination, string message,
        CancellationToken cancellationToken);
}

public sealed class OrderNotifier(
    INotificationSender sender)
{
    public Task NotifyAsync(Order order, CancellationToken token) =>
        sender.SendAsync(order.CustomerEmail,
            $"Order {order.Id} is confirmed", token);
}
Do not introduce interfaces, factories, repositories, or patterns automatically. Add an abstraction when it represents a meaningful boundary, variation point, or test seam.

Practical Project: Build a Small Order Processing Core

This project combines generics, records, interfaces, async methods, cancellation, DI, and validation. It is a learning example rather than a complete production payment or order system.

Domain Models

public enum OrderStatus
{
    Pending, Confirmed, Cancelled
}

public sealed record OrderLine(
    int ProductId, int Quantity, decimal UnitPrice);

public sealed record Order(
    Guid Id, string CustomerEmail,
    IReadOnlyList<OrderLine> Lines, OrderStatus Status)
{
    public decimal Total =>
        Lines.Sum(line => line.Quantity * line.UnitPrice);
}

Repository Contract

public interface IOrderRepository
{
    Task SaveAsync(Order order, CancellationToken token);
}

Application Service

public sealed class OrderProcessor(
    IOrderRepository repository,
    ILogger<OrderProcessor> logger)
{
    public async Task<Order> CreateAsync(
        string email, IReadOnlyList<OrderLine> lines,
        CancellationToken token)
    {
        if (string.IsNullOrWhiteSpace(email))
            throw new ArgumentException("Email is required", nameof(email));

        if (lines.Count == 0 || lines.Any(line =>
            line.Quantity <= 0 || line.UnitPrice < 0))
            throw new ArgumentException("Order lines are invalid", nameof(lines));

        var order = new Order(
            Guid.NewGuid(), email.Trim(), lines, OrderStatus.Pending);

        await repository.SaveAsync(order, token);
        logger.LogInformation(
            "Created order {OrderId} with total {OrderTotal}",
            order.Id, order.Total);

        return order;
    }
}

In a real application, the repository implementation would persist through EF Core or another data-access technology. The API layer would map request DTOs, validate the request, call this service, and return an appropriate HTTP response.

Common Advanced C# Mistakes to Avoid

  • Using .Result or .Wait() on asynchronous work instead of awaiting it.
  • Calling ToList() before database filtering and paging are complete.
  • Registering a stateful, non-thread-safe service as a singleton.
  • Swallowing exceptions or returning internal exception details to clients.
  • Creating unbounded parallel tasks for database or HTTP calls.
  • Using reflection or expression trees where ordinary typed code is simpler.
  • Assuming records make nested mutable objects immutable.
  • Optimizing allocations without measuring the real workload.
  • Ignoring cancellation tokens in long-running or I/O-heavy call chains.
  • Putting business logic in controllers, property setters, or event handlers without clear boundaries.

Learning Exercises and Practice Tasks

  1. Create a generic RepositoryResult<T> type with success and failure states.
  2. Write a delegate that calculates a tax amount and pass different implementations to a method.
  3. Build an event that publishes when an invoice is approved; subscribe and unsubscribe a handler.
  4. Use LINQ to group orders by customer and calculate total revenue per customer.
  5. Rewrite a nested conditional using property patterns and a switch expression.
  6. Create an async service method that accepts and forwards a cancellation token.
  7. Read a large text file line by line rather than loading it all at once.
  8. Use Span<char> to parse a delimited string without unnecessary substring allocations.
  9. Register a scoped service and inject it through constructor injection.
  10. Write unit and integration tests for the order-processing project.

Official Documentation and Further Learning

Generic Variance: Covariance and Contravariance

Variance describes how generic types can be converted when their type arguments are related by inheritance. It matters when designing reusable interfaces and delegates.

Covariance with out

public interface IReadOnlyBox<out T>
{
    T Value { get; }
}

public sealed class StringBox(string value) : IReadOnlyBox<string>
{
    public string Value { get; } = value;
}

IReadOnlyBox<string> strings = new StringBox("hello");
IReadOnlyBox<object> objects = strings;

Contravariance with in

public interface IHandler<in T>
{
    void Handle(T value);
}

public sealed class ObjectHandler : IHandler<object>
{
    public void Handle(object value) => Console.WriteLine(value);
}

IHandler<object> objectHandler = new ObjectHandler();
IHandler<string> stringHandler = objectHandler;

Covariant type parameters are produced (output); contravariant type parameters are consumed (input). Variance is supported on generic interfaces and delegates, not on ordinary generic classes.

Asynchronous Streams with IAsyncEnumerable

An asynchronous stream produces values over time. IAsyncEnumerable<T> is useful when records arrive gradually from a network, database, or long-running process and the consumer should process each item without waiting for the entire result set.

using System.Runtime.CompilerServices;

static async IAsyncEnumerable<int> ReadValuesAsync(
    [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
    for (int value = 1; value <= 3; value++)
    {
        await Task.Delay(300, cancellationToken);
        yield return value;
    }
}

await foreach (int value in ReadValuesAsync(cancellationToken)
    .WithCancellation(cancellationToken))
{
    Console.WriteLine(value);
}
The EnumeratorCancellation attribute is in System.Runtime.CompilerServices. Async streams are especially useful for streaming results; remember to propagate cancellation and handle failures that can occur during enumeration.

System.Threading.Channels for Producer–Consumer Workflows

A channel provides an asynchronous producer–consumer data structure. Producers write items and consumers read them asynchronously. Bounded channels can apply backpressure when producers are faster than consumers.

using System.Threading.Channels;

var channel = Channel.CreateBounded<string>(
    new BoundedChannelOptions(100)
    {
        FullMode = BoundedChannelFullMode.Wait,
        SingleReader = true,
        SingleWriter = false
    });

// Producer
await channel.Writer.WriteAsync("work-item", cancellationToken);
channel.Writer.TryComplete();

// Consumer
await foreach (string item in channel.Reader
    .ReadAllAsync(cancellationToken))
{
    await ProcessAsync(item, cancellationToken);
}

Channels are useful for in-process pipelines and background processing. They are not durable queues: process termination can lose items that have not been persisted. Use a message broker when work must survive process restarts or cross service boundaries.

Struct Design, ref struct and Readonly Data

Structs are value types and can be useful for small values with value semantics. They are copied by value in many operations, so large mutable structs can be expensive and confusing. Prefer small, immutable structs for value-like concepts.

public readonly struct Money(decimal amount, string currency)
{
    public decimal Amount { get; } = amount;
    public string Currency { get; } = currency;
}

ref struct

A ref struct is restricted from escaping to locations that could outlive the memory it refers to. Span<T> is a well-known example. These restrictions protect memory safety, but they also constrain storage, boxing, interfaces, and use across asynchronous suspension points. C# 13 and C# 14 have expanded some ref-like capabilities; consult the current language specification before relying on a specific allowance.

Do not choose a struct simply because it sounds faster. Measure copying, boxing, layout, and allocation behavior in the real workload.

What’s Next?

You have now explored advanced C# language features and important .NET development practices, from generics and LINQ to asynchronous programming, memory management, dependency injection, HTTP clients, concurrency, and testing.

The next step is to apply these concepts in a complete ASP.NET Core application: organize projects and layers, build secure APIs, use Entity Framework Core effectively, add validation and logging, write automated tests, and prepare the application for deployment.