Welcome to the C# Programming Tips sheet. This guide focuses on modern features and essential practices to help developers and administrators write more efficient, maintainable, and idiomatic C# code.
You have an excellent grasp of how to use C# delegates (Func) and LINQ to treat validation rules as composable data rather than rigid, imperative code blocks. This approach significantly boosts code flexibility and maintainability.
The core idea is to define each rule as a function, place these functions into a container (like an array or list), and then use LINQ extension methods to orchestrate how they are executed.
Your initial example is the clearest way to implement an AND composition (all rules must be successful). By using the All() LINQ method, you get automatic short-circuiting: if the first rule fails, the rest are immediately skipped, saving execution time.
Here is the pattern:
private readonly Func<string, bool> rule1 = x => x.Length == 10;
private readonly Func<string, bool> rule2 = x => new Regex("[a-zA-Z]").IsMatch(x);
private readonly Func<string, bool> rule3 = x => x.Contains("Hello");
public bool ValdateAllRules(string data)
{
// The array acts as the composition container.
// 'All' executes each Func, stopping immediately (short-circuiting)
// if any rule returns false.
return new Func<string, bool>[]
{
rule1,
rule2,
rule3
}.All(fnc => fnc(data));
}
The real power of this functional approach is how easy it is to change the evaluation logic simply by swapping the LINQ extension method applied to the rule array:
OR Composition (Any Must Pass): If you need the validation to pass as long as at least one rule is successful, you swap out All() for Any():
// Short-circuits on the first rule that returns true.
return rules.Any(fnc => fnc(data));
NOT OR Composition (None Must Pass): If you need to ensure none of the rules are successful (e.g., block the input if it matches any item on a blacklist), you can combine All() with a simple negation:
// Only returns true if all rules return false.
return rules.All(fnc => !fnc(data));
For robust domain validation, a simple bool isn't enough; you need to know which rules failed and why. We achieve this by redefining the rule function to return a more useful type, such as an optional error message (a string?). A null return means the rule passed.
We'll use a custom delegate, ValidationRule, to enforce the contract:
// The rule now returns an error string (or null if passed)
public delegate string? ValidationRule(string input);
private readonly ValidationRule rule1 = x => x.Length != 10 ? "Must be exactly 10 characters." : null;
private readonly ValidationRule rule2 = x => x.Contains("bad") ? "Cannot contain 'bad' words." : null;
private readonly ValidationRule[] rules = new ValidationRule[] { rule1, rule2 };
When executing, we use Select to run all rules (no short-circuiting here, as we need to find all errors) and then use Where to filter out the successful results (null values):
public string[] ValidateTheRulesAndGetErrors(string data)
{
// 1. Execute all rules, producing an IEnumerable<string?>
// 2. Filter out all the 'null' (passing) results using .Where()
// 3. Return the array of remaining error messages
return rules
.Select(fnc => fnc(data))
.Where(error => error != null)
.ToArray()!;
}
This structure clearly separates the rule definition (the delegate) from the composition logic (the LINQ pipeline), which is the hallmark of effective functional programming in C#.
You can take this a step further by defining higher-order functions—functions that take other functions as input and return a new, composed function. This lets you encapsulate complex logic like conditional execution.
A Compose function, for instance, could chain two rules together, ensuring the second only runs if the first passes:
// A higher-order function that takes two rules and returns a new combined rule
public static ValidationRule Compose(ValidationRule first, ValidationRule second)
{
// The returned function is the composed rule
return input => {
string? error = first(input);
// If the first rule failed, return its error and short-circuit.
if (error != null)
{
return error;
}
// Otherwise, run the second rule.
return second(input);
};
}
// Usage: The new rule is a sequential composition of the two simple rules.
public readonly ValidationRule composedRule = Compose(rule1, rule2);
By leveraging these techniques, you use C# features to create highly flexible, pipeline-oriented, and domain-driven validation systems.
These types provide a modern, highly efficient way to deal with slices of contiguous memory (like parts of an array or string) without copying the underlying data. They are crucial for high-performance string processing and buffer handling, directly avoiding unnecessary heap allocations.
Value types (struct) are allocated on the stack (or inline within a heap object), which significantly reduces pressure on the Garbage Collector (GC). Use them for types that are immutable and hold eight fields or fewer.
Use the in modifier for large struct parameters to pass them safely by reference, avoiding expensive data copying while guaranteeing that the struct remains immutable inside the method.
Use the ref modifier when you must modify a value type (like an integer or a struct) directly in place.
Introduced in C# 10, record struct combines the performance benefits of a stack-allocated struct with the enhanced features of records (immutability, concise initialization using with expressions, and built-in value-equality).
Record types (introduced in C# 9) are ideal for defining immutable data transfer objects (DTOs). They automatically provide crucial capabilities like value equality (two records are equal if their properties match, not just their references) and non-destructive mutation using the with expression.
This is extremely useful when you need to create a copy of an object while changing only a subset of the data. This approach avoids shared data state issues, which is vital in concurrent or parallel programming.
public record Person
{
// Positional syntax is often preferred for conciseness:
// public record Person(string Name, int Age);
public string Name { get; init; }
public int Age { get; init; }
}
// p1 is immutable
var p1 = new Person { Name = "Ben", Age = 47 };
// The 'with' expression creates an entirely new record (p2)
// based on p1, but with Age changed. p1 remains unmodified.
var p2 = p1 with { Age = 35 };
Utilize pattern matching features like switch expressions, property patterns, and relational patterns (<, >, <=, etc.) to simplify conditional logic, check types, and decompose objects with far more clarity and conciseness than traditional if/else chains.
Use the init keyword on property setters (e.g., public string Name { get; init; }). This allows the property to be set only during object construction (initialization) and ensures it remains immutable afterward. This is the preferred way to create immutable data objects.
For cleaner code and less horizontal indentation, use file-scoped namespaces (e.g., namespace MyProject.Api;) instead of block-scoped namespaces.
Use the global using directive (usually configured in a single _Imports.cs file) to declare common namespaces once for an entire project. This dramatically reduces the number of using statements required at the top of every code file.
For all library code or non-UI applications (including ASP.NET Core), always use await SomeTask().ConfigureAwait(false). This practice prevents deadlocks and improves performance by allowing the asynchronous continuation to resume on any available thread, avoiding the capture of the synchronization context.
If an asynchronous operation is likely to complete synchronously (e.g., checking a cache before making a network request), using ValueTask<T> can avoid the heap allocation associated with creating a new Task<T> object every time, leading to better performance in high-traffic paths.
Except for UI or event handlers, never use async void. Always use async Task or async ValueTask. This ensures exceptions can be caught and allows the caller to correctly await the completion of the asynchronous operation.
Configure your project to use NRTs (e.g., via #nullable enable or project settings). This is crucial for reliability, as it enforces explicit handling of potential null values at compile time, virtually eliminating common NullReferenceException bugs.
Use IQueryable (LINQ to SQL) when working with databases; this translates the operations into efficient SQL queries to be executed by the database server.
Use IEnumerable (LINQ to Objects) for in-memory operations. Avoid calling methods like .ToList() or .AsEnumerable() too early, as this forces the database query to execute prematurely and fetch potentially massive amounts of data into memory.
Use the concise => operator for methods, properties, constructors, and indexers that contain a single statement, which vastly improves code readability and density.
Prefer catching specific exceptions (e.g., TimeoutException, IOException) over the base Exception type. Always utilize the using statement (or using declarations) for any disposable resources (streams, database connections) to guarantee proper cleanup.
The Monad pattern is a design pattern used to chain computational steps while encapsulating or managing a specific side effect or context. In C#, the Task<T> (managing asynchronous execution), IEnumerable<T> (managing sequences), and Nullable<T> (managing the absence of a value) types are all built-in monads.
The Identity Monad is the simplest pattern and helps illustrate the mechanics:
The Identity (The Box): The Identity is like a box, or a generic container (Identity<T>), wrapping any value of type T. It shields the value and maintains the context (in this case, just the value itself).
The Bind Operation (Chaining Logic): The Bind operation (often implemented as the SelectMany extension method in C# for LINQ compatibility) is the key to chaining. Bind takes the current Identity box and a function (Func<T, Identity<U>>). It safely extracts the value T, executes the function on T, and wraps the result back into a new Identity box (Identity<U>).
This chainable mechanism allows you to combine data and logic in a fluent, compositional way. By composing multiple monads, you can create higher-order functions that reliably manage complex flows like asynchronous error handling or sequence manipulation.
At the end of the Bind chain, a final extraction mechanism is needed to release the final result from the Identity box.
// Conceptual C# Identity Record
public record Identity<T>(T Value)
{
// The 'Bind' method, which enables chaining functions.
// It extracts T, executes the function (f), and returns a new Identity<U>.
public Identity<U> Bind<U>(Func<T, Identity<U>> f)
{
// Executes the function on the inner value and returns the new container
return f(Value);
}
}