Async & Functional Patterns
Master asynchronous programming, delegates, and error handling strategies
Async & Functional Patterns in C#
Master asynchronous programming and functional patterns in C# with free flashcards and spaced repetition practice. This lesson covers async/await mechanics, Task-based Asynchronous Pattern (TAP), functional programming concepts like immutability and higher-order functions, and LINQ for declarative data manipulationโessential skills for writing modern, efficient C# applications.
Welcome
Welcome to the world of asynchronous and functional programming in C#! ๐ป Modern applications demand responsiveness and efficiency, whether you're building web APIs, desktop apps, or mobile solutions. This lesson bridges two powerful paradigms: async programming for handling I/O-bound operations without blocking threads, and functional patterns for writing cleaner, more maintainable code through immutability and pure functions.
๐ฏ What You'll Learn:
- How
async/awaittransforms asynchronous code - Task management and concurrency patterns
- Functional programming principles in C#
- LINQ for powerful data transformations
- Combining async and functional approaches
Core Concepts
Understanding Async/Await ๐
The async/await keywords revolutionized asynchronous programming in C# by making async code look and behave like synchronous code. Before async/await, developers struggled with callback hell and complex state machines.
How It Works:
Synchronous Execution Asynchronous Execution
โโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโ
โโโโโโโโโโโ โโโโโโโโโโโ
โ Method โ โ Method โ
โ Starts โ โ Starts โ
โโโโโโฌโโโโโ โโโโโโฌโโโโโ
โ โ
โผ โผ
โโโโโโโโโโโ โโโโโโโโโโโ
โ I/O โ โโ BLOCKS โ await โ โโ YIELDS
โOperationโ THREAD โ Task โ THREAD
โโโโโโฌโโโโโ โโโโโโฌโโโโโ
โ โ (thread free)
โ (thread busy) โ (other work)
โผ โผ
โโโโโโโโโโโ โโโโโโโโโโโ
โContinue โ โContinue โ
โ Code โ โ Code โ
โโโโโโโโโโโ โโโโโโโโโโโ
The async Keyword:
- Marks a method as asynchronous
- Enables use of
awaitinside the method - Changes method return type:
Task,Task<T>, orValueTask<T>
The await Keyword:
- Suspends method execution until the awaited task completes
- Returns control to the caller (frees the thread)
- Captures execution context for continuation
๐ก Key Insight: await doesn't create a new threadโit releases the current thread back to the thread pool, improving scalability!
Task-Based Asynchronous Pattern (TAP) โก
TAP is the recommended pattern for async operations in .NET, built on the Task and Task<T> types.
Task Characteristics:
| Aspect | Description | Example |
|---|---|---|
| Hot vs Cold | Tasks start immediately (hot) | Task.Run(() => Work()) |
| Result Access | Use .Result or await | var data = await task; |
| Status | Created, Running, Completed, Faulted, Canceled | task.Status |
| Composition | Combine with WhenAll, WhenAny | await Task.WhenAll(tasks) |
Common Task Operations:
// Task.Run - Execute work on thread pool
var task = Task.Run(() => ExpensiveComputation());
// Task.Delay - Asynchronous wait
await Task.Delay(1000); // Wait 1 second without blocking
// Task.WhenAll - Wait for multiple tasks
var tasks = new[] { DownloadAsync(url1), DownloadAsync(url2) };
var results = await Task.WhenAll(tasks);
// Task.WhenAny - Wait for first completion
var completedTask = await Task.WhenAny(tasks);
// Task.FromResult - Create completed task
return Task.FromResult(42);
โ ๏ธ Common Mistake: Blocking on async code with .Result or .Wait() can cause deadlocks in UI and ASP.NET contexts!
ConfigureAwait and Context ๐ฏ
When you await a task, by default the continuation runs on the captured synchronization context (UI thread, ASP.NET request context, etc.).
// Captures context - continuation runs on original context
await SomeTaskAsync();
// Does NOT capture context - continuation runs on thread pool
await SomeTaskAsync().ConfigureAwait(false);
๐ก Best Practice: Use .ConfigureAwait(false) in library code to improve performance and avoid deadlocks. Only capture context when you need it (updating UI, accessing request data).
Functional Programming Principles ๐งฎ
Functional programming emphasizes immutability, pure functions, and declarative code. C# isn't purely functional, but it supports many functional patterns.
Core Concepts:
๐ Functional Programming Pillars
| Immutability | Data cannot be modified after creation |
| Pure Functions | Same input always produces same output, no side effects |
| First-Class Functions | Functions as values (parameters, return values) |
| Higher-Order Functions | Functions that take or return functions |
| Declarative Style | Express WHAT, not HOW |
Immutability in C#:
// Mutable (imperative)
public class Person
{
public string Name { get; set; }
public int Age { get; set; }
}
var person = new Person { Name = "Alice", Age = 30 };
person.Age = 31; // Mutation
// Immutable (functional)
public record Person(string Name, int Age);
var person = new Person("Alice", 30);
var olderPerson = person with { Age = 31 }; // Creates new instance
Pure Functions:
// Impure - has side effects, depends on external state
private int counter = 0;
public int IncrementCounter()
{
counter++; // Modifies state
Console.WriteLine(counter); // Side effect
return counter;
}
// Pure - no side effects, deterministic
public int Add(int a, int b)
{
return a + b; // Always same output for same input
}
๐ก Why Pure Functions? They're easier to test, reason about, parallelize, and cache (memoization).
Higher-Order Functions and Delegates ๐ญ
C# treats functions as first-class citizens through delegates, lambdas, and Func/Action types.
// Func<T, TResult> - function with return value
Func<int, int> square = x => x * x;
var result = square(5); // 25
// Action<T> - function with no return value
Action<string> log = message => Console.WriteLine(message);
log("Hello");
// Higher-order function - takes function as parameter
public static List<T> Filter<T>(List<T> items, Func<T, bool> predicate)
{
var result = new List<T>();
foreach (var item in items)
{
if (predicate(item))
result.Add(item);
}
return result;
}
// Usage
var numbers = new List<int> { 1, 2, 3, 4, 5, 6 };
var evens = Filter(numbers, n => n % 2 == 0); // [2, 4, 6]
Function Composition:
// Compose two functions
public static Func<T, TResult> Compose<T, TIntermediate, TResult>(
Func<T, TIntermediate> f,
Func<TIntermediate, TResult> g)
{
return x => g(f(x));
}
Func<int, int> addOne = x => x + 1;
Func<int, int> double = x => x * 2;
var addOneThenDouble = Compose(addOne, double);
var result = addOneThenDouble(5); // (5 + 1) * 2 = 12
LINQ: Language Integrated Query ๐
LINQ brings functional query capabilities to C#, enabling declarative data manipulation.
LINQ Query Operators:
| Category | Operators | Purpose |
|---|---|---|
| Filtering | Where, OfType | Select items matching criteria |
| Projection | Select, SelectMany | Transform items |
| Ordering | OrderBy, ThenBy | Sort sequences |
| Grouping | GroupBy, ToLookup | Group items by key |
| Aggregation | Count, Sum, Average | Compute values |
| Set | Distinct, Union, Intersect | Set operations |
| Partitioning | Take, Skip, Chunk | Select subsets |
LINQ Syntax Styles:
var numbers = new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
// Method syntax (fluent)
var evenSquares = numbers
.Where(n => n % 2 == 0)
.Select(n => n * n)
.ToList(); // [4, 16, 36, 64, 100]
// Query syntax (SQL-like)
var evenSquares2 = (from n in numbers
where n % 2 == 0
select n * n)
.ToList();
๐ก Performance Tip: LINQ uses deferred executionโqueries aren't executed until enumerated. Chain operations before materializing with ToList(), ToArray(), etc.
Combining Async and Functional Patterns ๐
The real power comes from combining async and functional approaches for clean, efficient code.
Async LINQ with IAsyncEnumerable:
// Async stream processing
public async IAsyncEnumerable<string> FetchPagesAsync(string[] urls)
{
foreach (var url in urls)
{
var content = await httpClient.GetStringAsync(url);
yield return content;
}
}
// Consume async stream
await foreach (var page in FetchPagesAsync(urls))
{
ProcessPage(page);
}
Parallel Async Operations:
// Sequential - slow (each waits for previous)
var results = new List<Data>();
foreach (var id in ids)
{
var data = await FetchDataAsync(id);
results.Add(data);
}
// Parallel - fast (all run concurrently)
var tasks = ids.Select(id => FetchDataAsync(id));
var results = await Task.WhenAll(tasks);
// Parallel with LINQ transformation
var processedData = await Task.WhenAll(
ids.Select(async id =>
{
var data = await FetchDataAsync(id);
return ProcessData(data);
})
);
Option/Maybe Pattern:
// Functional error handling without exceptions
public record Option<T>
{
private readonly T? _value;
private readonly bool _hasValue;
private Option(T? value, bool hasValue)
{
_value = value;
_hasValue = hasValue;
}
public static Option<T> Some(T value) => new(value, true);
public static Option<T> None() => new(default, false);
public TResult Match<TResult>(
Func<T, TResult> some,
Func<TResult> none) =>
_hasValue ? some(_value!) : none();
}
// Async option usage
public async Task<Option<User>> FindUserAsync(int id)
{
var user = await database.Users.FindAsync(id);
return user != null ? Option<User>.Some(user) : Option<User>.None();
}
var result = await FindUserAsync(42);
var message = result.Match(
some: user => $"Found: {user.Name}",
none: () => "User not found"
);
Railway-Oriented Programming (Result Pattern):
public record Result<T>
{
public T? Value { get; init; }
public string? Error { get; init; }
public bool IsSuccess => Error == null;
public static Result<T> Success(T value) =>
new() { Value = value };
public static Result<T> Failure(string error) =>
new() { Error = error };
}
// Chain operations that might fail
public async Task<Result<Order>> ProcessOrderAsync(OrderRequest request)
{
var validationResult = ValidateRequest(request);
if (!validationResult.IsSuccess)
return Result<Order>.Failure(validationResult.Error!);
var inventoryResult = await CheckInventoryAsync(request.Items);
if (!inventoryResult.IsSuccess)
return Result<Order>.Failure(inventoryResult.Error!);
var order = await CreateOrderAsync(request);
return Result<Order>.Success(order);
}
Examples
Example 1: Async Web Data Aggregation
Scenario: Fetch data from multiple APIs and combine results efficiently.
public class DataAggregator
{
private readonly HttpClient _httpClient;
public async Task<AggregatedData> FetchDataAsync(string userId)
{
// Start all requests concurrently
var profileTask = FetchProfileAsync(userId);
var ordersTask = FetchOrdersAsync(userId);
var preferencesTask = FetchPreferencesAsync(userId);
// Wait for all to complete
await Task.WhenAll(profileTask, ordersTask, preferencesTask);
// Combine results functionally
return new AggregatedData(
Profile: await profileTask,
Orders: await ordersTask,
Preferences: await preferencesTask
);
}
private async Task<Profile> FetchProfileAsync(string userId)
{
var json = await _httpClient
.GetStringAsync($"https://api.example.com/users/{userId}")
.ConfigureAwait(false);
return JsonSerializer.Deserialize<Profile>(json)!;
}
private async Task<List<Order>> FetchOrdersAsync(string userId)
{
var json = await _httpClient
.GetStringAsync($"https://api.example.com/orders?user={userId}")
.ConfigureAwait(false);
return JsonSerializer.Deserialize<List<Order>>(json)!;
}
private async Task<UserPreferences> FetchPreferencesAsync(string userId)
{
var json = await _httpClient
.GetStringAsync($"https://api.example.com/preferences/{userId}")
.ConfigureAwait(false);
return JsonSerializer.Deserialize<UserPreferences>(json)!;
}
}
public record AggregatedData(
Profile Profile,
List<Order> Orders,
UserPreferences Preferences
);
Key Points:
- ๐ Three API calls run concurrently, not sequentially
- โก Total time โ slowest request (not sum of all requests)
- ๐ฆ
recordtypes provide immutability - ๐ฏ
.ConfigureAwait(false)for library code
Example 2: Functional Data Transformation Pipeline
Scenario: Process and analyze sales data using LINQ.
public class SalesAnalyzer
{
public record Sale(DateTime Date, string Product, decimal Amount, string Region);
public record ProductSummary(string Product, int Count, decimal Total, decimal Average);
public List<ProductSummary> AnalyzeSales(List<Sale> sales, int year)
{
return sales
// Filter to target year
.Where(s => s.Date.Year == year)
// Filter out invalid amounts
.Where(s => s.Amount > 0)
// Group by product
.GroupBy(s => s.Product)
// Transform to summary
.Select(g => new ProductSummary(
Product: g.Key,
Count: g.Count(),
Total: g.Sum(s => s.Amount),
Average: g.Average(s => s.Amount)
))
// Order by total descending
.OrderByDescending(p => p.Total)
// Materialize
.ToList();
}
public Dictionary<string, decimal> TopProductsByRegion(
List<Sale> sales,
int topN = 3)
{
return sales
.GroupBy(s => s.Region)
.ToDictionary(
g => g.Key,
g => g.GroupBy(s => s.Product)
.Select(p => new { Product = p.Key, Total = p.Sum(s => s.Amount) })
.OrderByDescending(p => p.Total)
.Take(topN)
.Sum(p => p.Total)
);
}
}
Key Points:
- ๐ Method chaining creates readable pipeline
- ๐ Declarative styleโexpress what, not how
- ๐ฏ Single pass through data for efficiency
- ๐งฎ LINQ handles complex aggregations elegantly
Example 3: Retry Pattern with Async and Functional Style
Scenario: Implement resilient API calls with exponential backoff.
public class ResilientHttpClient
{
private readonly HttpClient _httpClient;
public async Task<T> GetWithRetryAsync<T>(
string url,
int maxRetries = 3,
int baseDelayMs = 1000)
{
return await Retry(
operation: async () =>
{
var response = await _httpClient.GetAsync(url);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<T>(json)!;
},
maxAttempts: maxRetries,
delayStrategy: attempt => TimeSpan.FromMilliseconds(
baseDelayMs * Math.Pow(2, attempt - 1)
)
);
}
private async Task<T> Retry<T>(
Func<Task<T>> operation,
int maxAttempts,
Func<int, TimeSpan> delayStrategy)
{
Exception? lastException = null;
for (int attempt = 1; attempt <= maxAttempts; attempt++)
{
try
{
return await operation();
}
catch (Exception ex) when (attempt < maxAttempts)
{
lastException = ex;
var delay = delayStrategy(attempt);
await Task.Delay(delay);
}
}
throw new Exception(
$"Operation failed after {maxAttempts} attempts",
lastException
);
}
}
// Usage
var client = new ResilientHttpClient();
var data = await client.GetWithRetryAsync<UserData>(
"https://api.example.com/user/123",
maxRetries: 5,
baseDelayMs: 500
);
Exponential Backoff Timeline:
Attempt 1: Request โ โ Fail
โ Wait 500ms
Attempt 2: Request โ โ Fail
โ Wait 1000ms (500 ร 2ยน)
Attempt 3: Request โ โ Fail
โ Wait 2000ms (500 ร 2ยฒ)
Attempt 4: Request โ โ Fail
โ Wait 4000ms (500 ร 2ยณ)
Attempt 5: Request โ โ
Success
Key Points:
- ๐ Higher-order function
Retrytakes operation as parameter - โฑ๏ธ Strategy pattern via
delayStrategyfunction - ๐ฏ Generic implementation works with any return type
- ๐ช Exponential backoff prevents overwhelming failing services
Example 4: Async Stream Processing with IAsyncEnumerable
Scenario: Process large datasets without loading everything into memory.
public class LogProcessor
{
// Async generator - yields results as they're ready
public async IAsyncEnumerable<LogEntry> StreamLogsAsync(
string filePath,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
using var reader = new StreamReader(filePath);
while (!reader.EndOfStream)
{
cancellationToken.ThrowIfCancellationRequested();
var line = await reader.ReadLineAsync();
if (line == null) break;
if (TryParseLogEntry(line, out var entry))
{
yield return entry;
}
}
}
// Transform async stream functionally
public async Task<Dictionary<string, int>> CountErrorsByTypeAsync(
string filePath)
{
var errorCounts = new Dictionary<string, int>();
await foreach (var log in StreamLogsAsync(filePath))
{
if (log.Level == LogLevel.Error)
{
var errorType = ExtractErrorType(log.Message);
errorCounts[errorType] =
errorCounts.GetValueOrDefault(errorType) + 1;
}
}
return errorCounts;
}
// Process with LINQ-like operations
public async Task<List<LogEntry>> FindSuspiciousActivityAsync(
string filePath)
{
var suspicious = new List<LogEntry>();
await foreach (var log in StreamLogsAsync(filePath)
.Where(l => l.Level >= LogLevel.Warning)
.Where(l => l.Message.Contains("unauthorized") ||
l.Message.Contains("failed login")))
{
suspicious.Add(log);
}
return suspicious;
}
private bool TryParseLogEntry(string line, out LogEntry entry)
{
// Parsing logic
entry = default!;
return true;
}
private string ExtractErrorType(string message)
{
// Error type extraction logic
return "Unknown";
}
}
public record LogEntry(DateTime Timestamp, LogLevel Level, string Message);
public enum LogLevel { Debug, Info, Warning, Error, Critical }
Key Points:
- ๐ Streaming processes one item at a timeโmemory efficient
- ๐
IAsyncEnumerable<T>enables async iteration withawait foreach - ๐ Cancellation support via
CancellationToken - ๐ LINQ extensions work with async streams (System.Linq.Async)
Common Mistakes
โ ๏ธ Mistake 1: Async Void Methods
// โ WRONG - exceptions can't be caught, hard to test
public async void ProcessDataAsync()
{
await FetchDataAsync();
}
// โ
RIGHT - return Task for proper error handling
public async Task ProcessDataAsync()
{
await FetchDataAsync();
}
Exception: Only use async void for event handlers.
โ ๏ธ Mistake 2: Blocking on Async Code
// โ WRONG - can cause deadlocks
public void LoadData()
{
var data = FetchDataAsync().Result; // Blocks!
var data2 = FetchDataAsync().GetAwaiter().GetResult(); // Also blocks!
}
// โ
RIGHT - async all the way
public async Task LoadDataAsync()
{
var data = await FetchDataAsync();
}
โ ๏ธ Mistake 3: Not Awaiting Tasks
// โ WRONG - fire and forget, exceptions lost
public void StartProcess()
{
ProcessDataAsync(); // Task started but not awaited
}
// โ
RIGHT - await or explicitly handle
public async Task StartProcessAsync()
{
await ProcessDataAsync();
}
โ ๏ธ Mistake 4: Sequential When Parallel Would Work
// โ WRONG - slow, sequential execution
var user = await GetUserAsync(id);
var orders = await GetOrdersAsync(id);
var prefs = await GetPreferencesAsync(id);
// โ
RIGHT - parallel execution
var tasks = new[]
{
GetUserAsync(id),
GetOrdersAsync(id),
GetPreferencesAsync(id)
};
await Task.WhenAll(tasks);
โ ๏ธ Mistake 5: Capturing Variables in Loops
// โ WRONG - closure captures loop variable
for (int i = 0; i < 5; i++)
{
tasks.Add(Task.Run(() => Console.WriteLine(i))); // May print 5 five times!
}
// โ
RIGHT - capture local copy
for (int i = 0; i < 5; i++)
{
int localI = i;
tasks.Add(Task.Run(() => Console.WriteLine(localI)));
}
โ ๏ธ Mistake 6: Materializing LINQ Too Early
// โ WRONG - creates intermediate list
var filtered = data.Where(x => x.IsActive).ToList();
var sorted = filtered.OrderBy(x => x.Name).ToList();
var result = sorted.Take(10).ToList();
// โ
RIGHT - chain operations, single materialization
var result = data
.Where(x => x.IsActive)
.OrderBy(x => x.Name)
.Take(10)
.ToList();
โ ๏ธ Mistake 7: Ignoring ConfigureAwait in Libraries
// โ WRONG - library code capturing context unnecessarily
public async Task<Data> FetchDataAsync()
{
return await httpClient.GetAsync(url); // Captures context
}
// โ
RIGHT - avoid context capture in library code
public async Task<Data> FetchDataAsync()
{
return await httpClient.GetAsync(url).ConfigureAwait(false);
}
Key Takeaways
๐ฏ Async Programming:
- Use
async/awaitfor I/O-bound operations to improve scalability awaitreleases threads, doesn't create new ones- Always return
TaskorTask<T>, neverasync void(except event handlers) - Use
Task.WhenAllfor parallel operations,Task.WhenAnyfor first completion - Apply
.ConfigureAwait(false)in library code
๐ฏ Functional Patterns:
- Embrace immutability with
recordtypes and read-only properties - Write pure functions without side effects for testability
- Use higher-order functions (
Func,Action) for composability - Leverage LINQ for declarative data transformations
- Chain operations before materializing results
๐ฏ Combining Paradigms:
IAsyncEnumerable<T>combines async with streaming- LINQ works seamlessly with async operations
- Functional patterns like Option/Result improve error handling
- Compose small, pure functions for complex workflows
๐ฏ Best Practices:
- Avoid blocking on async code with
.Resultor.Wait() - Don't capture loop variables in closures
- Handle cancellation with
CancellationToken - Use appropriate patterns: retry, circuit breaker, timeout
- Profile performanceโasync isn't always faster for CPU-bound work
๐ Quick Reference Card
| Pattern | Syntax | Use Case |
|---|---|---|
| Async Method | async Task | I/O-bound operations |
| Await Task | await taskExpression | Wait without blocking |
| Parallel Tasks | await Task.WhenAll(tasks) | Concurrent operations |
| LINQ Filter | .Where(predicate) | Select matching items |
| LINQ Transform | .Select(selector) | Project to new form |
| LINQ Group | .GroupBy(keySelector) | Group by key |
| Pure Function | int Add(int a, int b) => a + b | Deterministic, no side effects |
| Immutable Record | record Person(string Name, int Age) | Value-based equality |
| Higher-Order | Func | Pass functions as values |
| Async Stream | IAsyncEnumerable | Stream large datasets |
๐ Further Study
- Microsoft Async/Await Best Practices - Essential patterns and anti-patterns
- Functional Programming in C# - Official LINQ and functional concepts guide
- System.Linq.Async - LINQ extensions for IAsyncEnumerable
๐ You now have the tools to write modern, efficient C# code combining the best of async and functional programming!