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.
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; }
}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); // 50Built-In Delegate Types
| Type | Purpose | Example |
|---|---|---|
Action<T> | Accepts parameters and returns void | Action<string> |
Func<T, TResult> | Accepts parameters and returns a value | Func<int, bool> |
Predicate<T> | Tests a value and returns bool | Predicate<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));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()); // 2Events 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.
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
| Operator | Use |
|---|---|
Where | Filter matching elements |
Select | Transform each element |
Any / All | Test whether some or all elements match |
First / FirstOrDefault | Get the first matching element |
Single / SingleOrDefault | Assert zero-or-one or exactly-one match |
GroupBy | Group elements by a key |
Join | Combine sequences using matching keys |
Sum / Average | Aggregate numeric values |
Distinct | Remove 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 againToList() 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.
| Concept | Typical 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);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"
};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); // FalseRecord Class vs Record Struct
| Declaration | Semantics | Typical use |
|---|---|---|
record / record class | Reference type, value-based equality | DTOs and immutable message models |
record struct | Value type, value-based equality | Small value-like data |
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.
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 type | Meaning |
|---|---|
Task | Asynchronous 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();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);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);
});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);
}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);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
StringBuilderwhen appropriate. - Do not materialize large query results when streaming or paging is sufficient.
- Reuse long-lived services such as
HttpClientthroughIHttpClientFactory. - Dispose streams, database connections, and other disposable resources deterministically.
- Measure allocations before introducing pooling or low-level optimizations.
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-0042Memory 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);
}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());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.
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.
??=). 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.
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);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
| Lifetime | Created | Typical use |
|---|---|---|
| Transient | Each time requested | Lightweight, stateless services |
| Scoped | Once per scope; usually one HTTP request | Request-level services and EF Core DbContext |
| Singleton | Once for the application container | Shared, 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);
}
}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.
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);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);
});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);
}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
.Resultor.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
- Create a generic
RepositoryResult<T>type with success and failure states. - Write a delegate that calculates a tax amount and pass different implementations to a method.
- Build an event that publishes when an invoice is approved; subscribe and unsubscribe a handler.
- Use LINQ to group orders by customer and calculate total revenue per customer.
- Rewrite a nested conditional using property patterns and a switch expression.
- Create an async service method that accepts and forwards a cancellation token.
- Read a large text file line by line rather than loading it all at once.
- Use
Span<char>to parse a delimited string without unnecessary substring allocations. - Register a scoped service and inject it through constructor injection.
- Write unit and integration tests for the order-processing project.
Official Documentation and Further Learning
- C# documentation
- What is new in C# 14
- Generics in C#
- LINQ documentation
- Asynchronous programming
- Garbage collection fundamentals
- Dependency injection in ASP.NET Core
- IHttpClientFactory
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);
}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.
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.