Delegates, Events, and Lambdas – The Foundation of Modern C#
Every time Selenium's WebDriverWait polls for an element, a delegate is doing the work. Every time a LINQ query filters a collection, a lambda is being invoked under the hood. Every time a test framework raises a "test completed" notification, an event is firing. Delegates, events, and lambda expressions aren't separate features – they're three faces of the same foundational mechanism, and virtually every advanced C# pattern builds on them.
This lesson explores all three in depth: how delegates work as type-safe method references, how the built-in Action and Func types eliminated an entire category of boilerplate, how lambda expressions compress delegate creation into elegant one-liners, and how closures capture variables in ways that enable powerful patterns – or subtle bugs when misunderstood.
Most importantly, you'll connect these concepts to test automation scenarios that appear in practice: custom WebDriver wait conditions, parameterized retry utilities, test lifecycle event hooks, and callback-based test data builders. Understanding this machinery is what separates C# you write from C# you truly command.
What Delegates Actually Are
A delegate is a type – not an instance, but a type – that defines the signature of a method. Once you have that type, you can create instances of it that point to any method matching that signature. Think of it as a contract: "any method taking these parameters and returning this type can be held by this variable."
Consider a simple example that might appear in a test validation scenario:
// Declare a delegate type: any method taking a string, returning bool
public delegate bool ValidationRule(string input);
// Create instances pointing to specific methods
bool IsNotEmpty(string value) => !string.IsNullOrWhiteSpace(value);
bool IsReasonableLength(string value) => value.Length is > 0 and <= 255;
ValidationRule checkEmpty = IsNotEmpty;
ValidationRule checkLength = IsReasonableLength;
// Invoke through the delegate – identical syntax to a direct method call
bool valid = checkEmpty("[email protected]"); // true
bool tooLong = checkLength(new string('x', 500)); // false
The delegate type declaration defines the contract. Any method satisfying that contract – regardless of where it lives, whether it's static or instance – can be assigned. This is what makes delegates so flexible: the code that holds and invokes the delegate doesn't need to know which specific method it's calling.
Multicast Delegates
One of C#'s most distinctive delegate features is multicasting: a single delegate variable can reference multiple methods, each invoked in sequence when the delegate is called. This is the mechanism that powers events.
// Combine delegates with +=
ValidationRule allChecks = null;
allChecks += IsNotEmpty;
allChecks += IsReasonableLength;
// Invokes both methods in order; return value comes from the last method
bool result = allChecks("hello"); // Runs IsNotEmpty, then IsReasonableLength
// Remove a specific method with -=
allChecks -= IsNotEmpty; // Now only IsReasonableLength remains
For delegates with return types, only the last invoked method's return value is visible to the caller. For void-returning delegates, all return values are discarded – which makes multicast especially useful for notification and logging scenarios where you want multiple independent reactions to one event.
Under the Hood – Delegates Are Classes
When you declare public delegate bool ValidationRule(string input), the C# compiler generates a sealed class that inherits from System.MulticastDelegate. This class contains an invocation list – an array of method references – and an Invoke method with the declared signature. Calling allChecks("hello") is syntactic sugar for allChecks.Invoke("hello"). The null-conditional allChecks?.Invoke("hello") is the safe pattern because it reads the delegate reference atomically: even if another thread removes all handlers between the null check and the invocation, no NullReferenceException occurs.
Custom delegate declarations like ValidationRule were standard practice in early C# codebases, but they've largely given way to the built-in generic delegate types that ship with .NET – which is where the real leverage comes from.
Action, Func, and Predicate
Before .NET 3.5, every library declared its own delegate types, creating an explosion of single-use definitions for fundamentally identical shapes. The .NET team solved this with three generic delegate types that cover the vast majority of use cases without any custom declarations.
Action – Operations That Return Nothing
Action represents any method that performs an operation and returns void. It comes in variants from Action (no parameters) through Action<T1, T2, ... T16>, though anything beyond three or four parameters is a design smell worth questioning.
// Action with no parameters
Action clearCache = () => Cache.Flush();
// Action – one parameter
Action logMessage = message => Console.WriteLine($"[LOG] {message}");
// Action – two parameters
Action logError = (message, ex) =>
Console.Error.WriteLine($"[ERROR] {message}: {ex.Message}");
// Composing actions: pass behavior as arguments
void RunWithSetup(Action setup, Action test, Action teardown)
{
setup();
try { test(); }
finally { teardown(); }
}
// Usage: each phase is a named method or lambda
RunWithSetup(
setup: () => database.InsertTestUser(),
test: () => loginPage.LoginAs("[email protected]"),
teardown: () => database.DeleteTestUser());
Func – Operations That Return Values
Func represents any method that computes and returns a value. The last type parameter is always the return type; preceding parameters are inputs.
// Func – no inputs, produces a value (useful for lazy evaluation)
Func getTimestamp = () => DateTime.UtcNow;
// Func – one input, one output
Func isValidEmail = email =>
email.Contains('@') && email.Contains('.');
// Func – two inputs, one output
Func applyDiscount = (price, pct) =>
price * (1 - pct / 100);
// A common test automation pattern: a lazily-evaluated test data factory
Func createTestUser = () => new User
{
Email = $"test-{Guid.NewGuid():N}@example.com",
FirstName = "Test",
LastName = "User",
IsActive = true
};
// Only creates the User object when called – not when the Func is defined
User user = createTestUser();
Predicate – The Boolean Specialist
Predicate<T> is essentially Func<T, bool> with a more descriptive name. It predates generic delegate proliferation and still appears in older .NET APIs like List<T>.Find and List<T>.RemoveAll.
// Predicate surfaces in older .NET APIs
Predicate isPending = order => order.Status == "Pending";
var orders = new List { /* ... */ };
// Find returns first match
Order first = orders.Find(isPending);
// RemoveAll modifies the list in-place, returns count removed
int removed = orders.RemoveAll(o => o.Status == "Cancelled");
// Predicate and Func are not the same type,
// but converting between them is straightforward
Func funcVersion = order => isPending(order);
Choosing Between Action, Func, and Predicate
| Type | Returns | Best Used For |
|---|---|---|
Action |
void | Side-effecting operations: logging, clicking, setup/teardown steps |
Func<T, TResult> |
TResult | Transformations, condition checks, factories, wait conditions |
Predicate<T> |
bool | List<T> APIs only; prefer Func<T, bool> in new code |
In practice, Action and Func are the only delegate types you need to declare in most test automation code. Custom delegate types are a pattern to recognise in existing codebases, not something to introduce in new ones.
Lambda Expressions
Before C# 3.0, passing a method as a value required either a named method (defined elsewhere in the class) or a verbose anonymous delegate syntax that buried intent in boilerplate. Lambda expressions changed that, compressing delegate creation into syntax so concise it fundamentally altered how C# is written.
The Evolution
Seeing the three stages side by side clarifies what lambdas actually are – and why they were so significant:
// Stage 1: Named method – behavior is separate from where it's used
private bool IsActive(User user)
{
return user.IsActive;
}
Func predicate = IsActive;
// Stage 2: Anonymous delegate (C# 2.0) – inline, but verbose
Func predicate = delegate(User user)
{
return user.IsActive;
};
// Stage 3: Expression lambda (C# 3.0) – type inferred, return implicit
Func predicate = user => user.IsActive;
The compiler infers the parameter type from the delegate type context. For single-expression bodies, the return keyword is implicit. The result is called an expression lambda, and it's the form you'll write most often.
Statement Lambdas
When the body requires multiple statements, use curly braces and explicit return. This is a statement lambda:
// Statement lambda – multiple lines, explicit return
Func isEligibleForPromotion = user =>
{
if (!user.IsActive) return false;
if (user.AccountBalance <= 0) return false;
// Compound rule applied during test data validation
return user.TotalOrders >= 5 && user.MembershipMonths >= 3;
};
// Statement lambda for UI interactions with multiple steps
Action loginAsAdmin = driver =>
{
driver.FindElement(By.Id("email")).SendKeys("[email protected]");
driver.FindElement(By.Id("password")).SendKeys("AdminPassword1!");
driver.FindElement(By.Id("login-button")).Click();
};
Discards for Unused Parameters
When a delegate signature requires parameters you don't use in the body, the discard pattern with underscore communicates that intent explicitly:
// EventHandler always provides (sender, e) – sender is often unused
button.Click += (_, e) => HandleButtonClick(e);
// Timer elapsed event – neither parameter is needed
timer.Elapsed += (_, _) => RefreshTestStatus();
// WebDriverWait condition where driver isn't referenced
Func pageIsLoaded = _ =>
javascript.ExecuteScript("return document.readyState").Equals("complete");
Lambdas aren't magic – the compiler transforms them into anonymous methods or, for certain patterns involving expression trees, into data structures that represent code rather than execute it. That transformation matters most when understanding closures, which is where most C# developers encounter their first subtle lambda bug.
Closures and Variable Capture
A closure is a lambda that references variables from its enclosing scope. The lambda "closes over" those variables – capturing not a copy of their value, but the variable itself. This means if the variable changes after the lambda is created, the lambda sees the updated value when invoked.
Intentional Capture
Capture by reference is often exactly what you want. It's what makes closures useful for adapting behavior based on surrounding context:
// The lambda captures 'environment' – reads its current value on each invocation
string environment = "staging";
Func getBaseUrl = () => $"https://{environment}.myapp.com";
Console.WriteLine(getBaseUrl()); // "https://staging.myapp.com"
environment = "production";
Console.WriteLine(getBaseUrl()); // "https://production.myapp.com"
This capability is what powers builder patterns – configuration passed during construction is captured by lambdas that apply it later, after the builder is fully assembled.
The Loop Closure Trap
The most notorious closure pitfall involves for loop variables. There is one loop variable shared across all iterations. Lambdas created inside the loop all capture that same variable, and read it when they're invoked – not when they were created.
// TRAP: Building a collection of message-producing lambdas
var messages = new List>();
for (int attempt = 1; attempt <= 3; attempt++)
{
// All three lambdas capture the same 'attempt' variable
messages.Add(() => $"Retry attempt {attempt}");
}
// Loop ends with attempt == 4 (the condition that broke the loop)
foreach (var getMessage in messages)
{
Console.WriteLine(getMessage()); // Prints "Retry attempt 4" three times
}
// CORRECT: Capture a per-iteration copy
var fixedMessages = new List>();
for (int attempt = 1; attempt <= 3; attempt++)
{
int captured = attempt; // Each iteration declares a new, distinct variable
fixedMessages.Add(() => $"Retry attempt {captured}");
}
// Now prints: "Retry attempt 1", "Retry attempt 2", "Retry attempt 3"
foreach Loops Are Safe – for Loops Are Not
C# 5 fixed closure capture in foreach loops – each iteration receives its own distinct variable, so lambdas capture independently. The for loop was left unchanged because fixing it would have been a breaking change. If you're building lambda collections inside a for loop, introduce a local copy of the loop variable. This is not a workaround – it's the correct C# idiom.
Closures in Test Builder Patterns
Understanding capture enables confident use of closures in builder patterns, where each configuration step stores a lambda that applies its captured value later:
// Builder that defers configuration via closures
public class TestContextBuilder
{
private readonly List> _configurations = new();
public TestContextBuilder WithEnvironment(string env)
{
// Captures 'env' parameter – a new variable for each call
_configurations.Add(ctx => ctx.BaseUrl = $"https://{env}.myapp.com");
return this;
}
public TestContextBuilder WithTimeout(int seconds)
{
// Captures 'seconds' parameter
_configurations.Add(ctx => ctx.TimeoutSeconds = seconds);
return this;
}
public TestContext Build()
{
var context = new TestContext();
// Each lambda applies its captured configuration
foreach (var configure in _configurations)
configure(context);
return context;
}
}
// Usage: clean, fluent, and closure-powered
var context = new TestContextBuilder()
.WithEnvironment("production")
.WithTimeout(60)
.Build();
Closures are a feature, not a hazard – as long as you understand that they capture variables by reference. Once that distinction is clear, they enable patterns that would otherwise require verbose class definitions or complex parameter threading.
Events and the Observer Pattern
Events build on multicast delegates to implement the observer pattern: one object notifies any number of subscribers when something of interest occurs, without knowing who those subscribers are. This decoupling makes events the right mechanism for test lifecycle hooks, result reporting, and diagnostic instrumentation.
The event Keyword
The event keyword wraps a delegate field with two important access restrictions: only the declaring class can invoke it, and external code may only use += and -=. This prevents the most common multicast delegate bugs, like one subscriber accidentally replacing all others by assigning with =.
// Event argument data – carries information to subscribers
public class TestResultEventArgs : EventArgs
{
public string TestName { get; init; }
public bool Passed { get; init; }
public TimeSpan Duration { get; init; }
public Exception Failure { get; init; }
}
// Class that raises events
public class TestOrchestrator
{
// Standard .NET event declaration using EventHandler
public event EventHandler TestCompleted;
// Protected virtual raise method – allows derived classes to intercept
protected virtual void OnTestCompleted(TestResultEventArgs args)
=> TestCompleted?.Invoke(this, args);
public void Execute(string testName, Action testBody)
{
var stopwatch = Stopwatch.StartNew();
try
{
testBody();
OnTestCompleted(new TestResultEventArgs
{
TestName = testName,
Passed = true,
Duration = stopwatch.Elapsed
});
}
catch (Exception ex)
{
OnTestCompleted(new TestResultEventArgs
{
TestName = testName,
Passed = false,
Duration = stopwatch.Elapsed,
Failure = ex
});
throw; // Re-throw so the test framework records the failure
}
}
}
Subscribing with Independent Concerns
The power of events is that each subscriber is independent. Adding a new reporting destination requires no changes to the orchestrator or any other subscriber:
var orchestrator = new TestOrchestrator();
// Subscriber 1: Console output
orchestrator.TestCompleted += (_, result) =>
{
var status = result.Passed ? "PASS" : "FAIL";
Console.WriteLine($"[{status}] {result.TestName} ({result.Duration.TotalSeconds:F1}s)");
};
// Subscriber 2: Dashboard reporter (completely separate concern)
orchestrator.TestCompleted += (_, result) =>
dashboardReporter.RecordResult(result.TestName, result.Passed);
// Subscriber 3: Screenshot archiver (only active on failure)
orchestrator.TestCompleted += (_, result) =>
{
if (!result.Passed)
screenshotArchiver.Save(result.TestName, result.Failure);
};
// Execute a test – all three subscribers are notified automatically
orchestrator.Execute("UserCanPlaceOrder", () => { /* test body */ });
Preventing Event-Based Memory Leaks
Subscribing to an event creates a reference from the event source to the subscriber. If the source outlives the subscriber, the subscriber is kept alive in memory. In long-running test processes, always unsubscribe in teardown. Storing the handler in a field makes unsubscription reliable – lambda expressions create new delegate instances each time, so -= with an inline lambda doesn't match the += subscription.
private EventHandler<TestResultEventArgs> _resultHandler;
[SetUp]
public void Setup()
{
_resultHandler = (_, result) => RecordResult(result);
orchestrator.TestCompleted += _resultHandler;
}
[TearDown]
public void Teardown()
{
orchestrator.TestCompleted -= _resultHandler;
}
The observer pattern through events is the right choice when you need multiple independent consumers to react to state changes – particularly in test lifecycle scenarios where reporting, logging, and cleanup are genuinely separate concerns.
Delegates in Test Automation
With the theory established, three patterns illustrate how delegates, lambdas, and the concepts surrounding them translate directly into test automation code: custom wait conditions, parameterized retry logic, and callback-based test utilities.
Custom Wait Conditions
Selenium's WebDriverWait.Until accepts a Func<IWebDriver, TResult> delegate. It calls the function repeatedly until it returns a truthy result – or the timeout expires. This design lets you express any wait condition as a lambda, without subclassing or implementing interfaces.
var wait = new WebDriverWait(driver, TimeSpan.FromSeconds(10));
// Simple: wait until element exists
var element = wait.Until(d => d.FindElement(By.Id("submit-button")));
// Custom: wait until AJAX spinner disappears
wait.Until(d =>
{
var spinners = d.FindElements(By.CssSelector(".loading-spinner"));
return !spinners.Any() || !spinners[0].Displayed;
});
// Custom: wait until navigation completes
wait.Until(d => d.Url.Contains("/dashboard"));
// Reusable condition factory – returns a Func rather than invoking one directly
public static Func ElementIsClickable(By locator)
{
return driver =>
{
try
{
var element = driver.FindElement(locator);
// Return null signals "not ready yet" to WebDriverWait
return (element.Displayed && element.Enabled) ? element : null;
}
catch (NoSuchElementException)
{
return null;
}
};
}
// Clean, reusable, no duplication
var button = wait.Until(ElementIsClickable(By.Id("submit-button")));
button.Click();
Parameterized Retry Logic
A retry utility that accepts Action and Func<T> delegates is reusable across the entire test suite. The test-specific behavior is passed in; the retry mechanics are encapsulated once and tested once.
public static class Retry
{
// Retry a void operation
public static void Execute(
Action operation,
int maxAttempts = 3,
int delayMs = 1000,
Func shouldRetry = null)
{
// Default: retry on any exception
shouldRetry ??= _ => true;
for (int attempt = 1; attempt <= maxAttempts; attempt++)
{
try
{
operation();
return;
}
catch (Exception ex) when (attempt < maxAttempts && shouldRetry(ex))
{
Thread.Sleep(delayMs * attempt); // Linear backoff
}
}
}
// Retry an operation that returns a value
public static T Execute(
Func operation,
int maxAttempts = 3,
Func shouldRetry = null)
{
shouldRetry ??= _ => true;
for (int attempt = 1; attempt < maxAttempts; attempt++)
{
try { return operation(); }
catch (Exception ex) when (shouldRetry(ex))
{
Thread.Sleep(1000 * attempt);
}
}
return operation(); // Final attempt – exceptions propagate to the caller
}
}
// Retry any click operation
Retry.Execute(() => driver.FindElement(By.Id("submit")).Click());
// Only retry stale element exceptions – fail fast on anything else
Retry.Execute(
() => GetOrderTotal(),
maxAttempts: 5,
shouldRetry: ex => ex is StaleElementReferenceException);
// Fetch data with retry, returning the result
var user = Retry.Execute(() => apiClient.GetUser(userId));
Callback-Based Test Utilities
Delegates make it possible to build utilities that accept behavior as configuration. This is the template method pattern expressed through function parameters rather than inheritance – more flexible, with no class hierarchy required.
// A database test helper that manages the full connection and transaction lifecycle
public static class DatabaseTest
{
public static void Execute(
string connectionString,
Action testBody)
{
using var connection = new SqlConnection(connectionString);
connection.Open();
// Begin a transaction – all changes will be rolled back
using var transaction = connection.BeginTransaction();
try
{
testBody(connection, transaction);
}
finally
{
// Always rollback – the test database returns to its initial state
transaction.Rollback();
}
}
}
// The database lifecycle is handled; the test focuses entirely on its assertions
DatabaseTest.Execute(connectionString, (connection, transaction) =>
{
var command = connection.CreateCommand();
command.Transaction = transaction;
// Insert test data within the transaction scope
command.CommandText = "INSERT INTO Users (Email, IsActive) VALUES (@Email, 1)";
command.Parameters.AddWithValue("@Email", "[email protected]");
command.ExecuteNonQuery();
// Verify the insertion
command.CommandText = "SELECT COUNT(*) FROM Users WHERE Email = @Email";
var count = (int)command.ExecuteScalar();
Assert.That(count, Is.EqualTo(1), "User should have been inserted");
// Rollback runs automatically – database returns to its pre-test state
});
These three patterns – wait conditions, retry logic, and callback utilities – appear throughout professional test automation codebases precisely because delegates make behavior a first-class value you can pass, store, and compose. Once you're comfortable thinking in delegates, you'll see opportunities to apply these patterns everywhere.
Key Takeaways
- Delegates are types, not values – declaring a delegate defines a method signature contract, and any matching method can be assigned to a delegate variable.
- Action and Func replace custom delegate declarations in modern C#: use
Actionfor void operations andFunc<T, TResult>for operations that return values. - Lambda expressions are concise syntax for creating delegate instances inline; the compiler infers parameter types from the surrounding delegate type context.
- Closures capture variables by reference, not by value – a lambda sees the current value of a captured variable at the time of invocation, not at the time of lambda creation.
- The loop closure trap in
forloops requires introducing a local copy of the loop variable;foreachloops handle this correctly since C# 5. - Events enforce the observer pattern by restricting direct delegate invocation to the declaring class, requiring external subscribers to use
+=and-=. - Unsubscribe from events in teardown to prevent memory leaks in long-running test processes; store handlers in fields to ensure
-=removes the correct delegate instance. - Test automation applies delegates through custom wait conditions (
Func<IWebDriver, TResult>), retry helpers (Action,Func<T>), and callback-based utilities that separate lifecycle mechanics from test-specific behavior.
Further Reading
- Delegates – C# Programming Guide (Microsoft) The official reference covering delegate declaration, instantiation, invocation, and the multicast model with clear examples.
- Lambda Expressions – C# Language Reference (Microsoft) Complete reference for lambda syntax including expression and statement forms, discards, and the rules governing variable capture and closures.
- Events – C# Programming Guide (Microsoft) Explains the event keyword, the EventHandler<T> pattern, publisher and subscriber design, and best practices for raising events safely.
- Observer Design Pattern – .NET Guide (Microsoft) Covers the formal observer pattern in .NET, comparing the event model with IObservable<T>/IObserver<T> for reactive and push-based notification scenarios.