Functional C#: A Practical Guide to Functional Programming in C#

Functional C# Cookbook: Solutions for Real-World Problems

Introduction

Functional programming techniques can make C# code more predictable, testable, and maintainable. This cookbook collects practical recipes—small, focused examples you can apply immediately—to solve common real-world problems using functional C# patterns: immutability, pure functions, higher-order functions, composition, and safe error handling.

1. Immutable DTOs and Value Objects

Problem: Mutable data structures cause hard-to-find bugs when shared across threads or passed between layers.

Recipe:

  • Use readonly auto-properties and init accessors.
  • Prefer records for value-based equality.
  • Enforce invariants in constructors or static factory methods.

Example:

csharp

public record Money(decimal Amount, string Currency); public record OrderItem { public Guid Id { get; init; } = Guid.NewGuid(); public string Name { get; init; } public Money Price { get; init; } private OrderItem() { } // for deserialization public OrderItem(string name, Money price) { Name = name ?? throw new ArgumentNullException(nameof(name)); Price = price; } }

When to use: DTOs passed between services, domain value objects, multi-threaded contexts.

2. Composition with Functions and Delegates

Problem: Large methods with multiple steps are hard to test and reuse.

Recipe:

  • Break logic into small pure functions.
  • Compose using Func or custom delegates.
  • Use extension methods for pipeline-style composition.

Example:

csharp

Func<Order, Order> applyDiscount = order => order with { Total = order.Total 0.9m }; Func<Order, Order> addTax = order => order with { Total = order.Total 1.08m }; Func<Order, Order> pipeline = order => addTax(applyDiscount(order)); // or using extension: public static T Pipe<T>(this T input, Func<T,T> fn) => fn(input); var final = originalOrder .Pipe(applyDiscount) .Pipe(addTax);

When to use: Transformation pipelines, ETL tasks, request processing chains.

3. Safe Error Handling with Result/Either Types

Problem: Exceptions for control flow and nulls lead to fragile, unclear code.

Recipe:

  • Implement a Result type or use a library (e.g., LanguageExt, CSharpFunctionalExtensions).
  • Return Result from operations, compose with Bind/Map.

Example (minimal Result):

csharp

public record Result<T, E>(bool IsSuccess, T? Value, E? Error); public static Result<T2, E> Map<T, T2, E>(this Result<T, E> r, Func<T, T2> f) => r.IsSuccess ? new Result<T2, E>(true, f(r.Value!), default) : new Result<T2, E>(false, default, r.Error);

When to use: IO operations, validation chains, service orchestration.

4. Declarative LINQ and Immutable Collections for Data Processing

Problem: Imperative loops mutate state and are verbose.

Recipe:

  • Use LINQ for transformations and filtering.
  • Prefer ImmutableArray/ImmutableList from System.Collections.Immutable or use IEnumerable pipelines that materialize only when needed.

Example:

csharp

using System.Collections.Immutable; ImmutableArray<OrderItem> expensive = items .Where(i => i.Price.Amount > 100m) .OrderByDescending(i => i.Price.Amount) .ToImmutableArray();

When to use: Reporting, batch processing, preparing data for UI.

5. Concurrency with Pure Functions and Actors

Problem: Shared mutable state causes race conditions in concurrent code.

Recipe:

  • Keep functions pure; share only immutable data.
  • For mutable state, confine it to an actor or use System.Threading.Channels, MailboxProcessor patterns, or libraries like Akka.NET.

Example (minimal actor with Channel):

csharp

var channel = Channel.CreateUnbounded<Func<Task>>(); async Task ActorLoop() { await foreach (var work in channel.Reader.ReadAllAsync()) await work(); } // Post work: await channel.Writer.WriteAsync(async () => { /* mutate isolated state */ });

When to use: Real-time systems, background workers, state machines.

6. Asynchronous Streams and Reactive Patterns

Problem: Combining real-time data with backpressure and composition is complex.

Recipe:

  • Use IAsyncEnumerable for async streams.
  • Apply LINQ-like operators (Where, Select) with await foreach.
  • For richer operators, consider System.Reactive (Rx.NET).

Example:

csharp

async IAsyncEnumerable<int> GenerateAsync() { for (int i = 0; i < 10; i++) { await Task.Delay(100); yield return i; } } await foreach (var x in GenerateAsync().Where(x => x % 2 == 0)) Console.WriteLine(x);

When to use: Event processing, streaming APIs, UI updates.

7. Functional Testing: Property-Based and Pure Unit Tests

Problem: Tests that rely on mutable shared state are flaky.

Recipe:

  • Write pure functions and test them deterministically.
  • Use property-based testing (FsCheck) for broader coverage.
  • Mock side-effects using small adapters.

Example:

  • Test pure mapper functions with many inputs; use FsCheck for invariants.

When to use: Core business logic, transformers, validation.

8. Interop with OOP Codebases

Problem: Introducing FP to existing OOP systems needs gradual adoption.

Recipe:

  • Start with small value objects, pure helpers, and Result-returning methods.
  • Encapsulate side-effects at the edges (repositories, services).
  • Use extension methods and adapter classes to bridge styles.

Example:

  • Replace void methods with functions returning Result and refactor callers stepwise.

9. Performance Considerations

Problem: Overuse of allocations (e.g., LINQ, closures) can hurt throughput.

Recipe:

  • Measure with BenchmarkDotNet.
  • Prefer structs for hot-path small types (Span, Memory where appropriate).
  • Use ValueTask for frequently awaited performance-critical async methods.
  • Cache delegates when composing pipelines in hot paths.

When to use: High-performance servers, tight loops.

10. Recipes for Common Real-World Scenarios

  • Validation pipeline: Compose small validators returning Result and short-circuit on first failure.
  • Retry with backoff: Implement pure policy functions describing delays and an executor handling retries.
  • Bulk import: Build a transform pipeline (IEnumerable -> Map -> Filter -> Batch -> Persist) using immutable collections.
  • Feature flags: Use pure predicates plus configuration injected at startup; avoid scattered ifs.

Conclusion

Adopt functional C# incrementally: prefer immutability, small pure functions, and explicit error handling. Use the recipes above as practical patterns to reduce bugs and improve maintainability in real systems.

Further reading: explore records, System.Collections.Immutable, LanguageExt/CSharpFunctionalExtensions, Rx.NET, and FsCheck.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *