
How Function Composition Can Make Your Code Better
A Practical Guide to Functional Programming in C#
Functional programming isn’t just for Haskell enthusiasts and academia. It’s a powerful paradigm that can transform your C# code from imperative spaghetti into declarative, testable, and maintainable solutions. At the heart of FP lies function composition — the art of building complex operations by combining simple, pure functions.
After refactoring countless legacy systems and building high-throughput data pipelines, I’ve learned that function composition isn’t about being “clever” — it’s about writing code that humans can reason about and computers can optimize. Let’s dive into practical patterns that will change how you write C#.
Why Function Composition Matters
The Problem with Imperative Code
// Traditional imperative approach - hard to follow, harder to test
public OrderDto ProcessOrder(int orderId)
{
var order = _dbContext.Orders.Find(orderId);
if (order == null) throw new NotFoundException();
if (order.Status != "Pending")
throw new InvalidOperationException("Order already processed");
var customer = _dbContext.Customers.Find(order.CustomerId);
if (customer == null) throw new NotFoundException("Customer not found");
if (!customer.IsActive)
throw new InvalidOperationException("Customer inactive");
var discount = 0m;
if (customer.IsVip) discount = 0.1m;
else if (order.Total > 1000) discount = 0.05m;
var finalTotal = order.Total * (1 - discount);
order.Status = "Processed";
order.FinalTotal = finalTotal;
_dbContext.SaveChanges();
return new OrderDto
{
Id = order.Id,
CustomerName = customer.Name,
FinalTotal = finalTotal
};
}
Problems:
- Mixed concerns (validation, business logic, persistence)
- Hard to unit test (tight coupling to database)
- No reusability (logic trapped in method)
- Difficult to modify (change one thing, break everything)
The Composed Solution
// Composed functional approach - declarative, testable, reusable
public class OrderProcessingPipeline
{
private readonly Func<int, Task<Order?>> _loadOrder;
private readonly Func<Order, Task<Customer?>> _loadCustomer;
private readonly Func<(Order, Customer), Task<ProcessedOrder>> _calculateDiscount;
private readonly Func<ProcessedOrder, Task<ProcessedOrder>> _persistOrder;
private readonly Func<ProcessedOrder, OrderDto> _mapToDto;
public OrderProcessingPipeline(
Func<int, Task<Order?>> loadOrder,
Func<Order, Task<Customer?>> loadCustomer,
Func<(Order, Customer), Task<ProcessedOrder>> calculateDiscount,
Func<ProcessedOrder, Task<ProcessedOrder>> persistOrder,
Func<ProcessedOrder, OrderDto> mapToDto)
{
_loadOrder = loadOrder;
_loadCustomer = loadCustomer;
_calculateDiscount = calculateDiscount;
_persistOrder = persistOrder;
_mapToDto = mapToDto;
}
public async Task<Result<OrderDto, Error>> ProcessAsync(int orderId)
{
return await Result<Order, Error>
.FromNullable(await _loadOrder(orderId), Error.OrderNotFound)
.BindAsync(async order =>
{
var customer = await _loadCustomer(order);
return customer != null
? Result<(Order, Customer), Error>.Success((order, customer))
: Result<(Order, Customer), Error>.Failure(Error.CustomerNotFound);
})
.BindAsync(async tuple =>
{
var (order, customer) = tuple;
if (order.Status != "Pending")
return Result<(Order, Customer), Error>.Failure(Error.OrderAlreadyProcessed);
if (!customer.IsActive)
return Result<(Order, Customer), Error>.Failure(Error.CustomerInactive);
return Result<(Order, Customer), Error>.Success(tuple);
})
.BindAsync(_calculateDiscount)
.BindAsync(_persistOrder)
.MapAsync(_mapToDto);
}
}
Benefits:
- Each function has a single responsibility
- Easy to test (inject mocks)
- Composable (reuse functions in other pipelines)
- Declarative flow (read top-to-bottom like a story)
Building Blocks: Pure Functions
Function composition starts with pure functions — functions that always produce the same output for the same input and have no side effects.
// Pure functions - deterministic, no side effects, highly testable
public static class PricingFunctions
{
// Pure: same input always produces same output
public static decimal CalculateDiscount(CustomerType type, decimal total) => type switch
{
CustomerType.Vip => total * 0.15m,
CustomerType.Premium => total * 0.10m,
CustomerType.Standard when total > 1000 => total * 0.05m,
_ => 0m
};
// Pure: no external dependencies
public static decimal ApplyTax(decimal amount, decimal taxRate) =>
amount * (1 + taxRate);
// Pure: immutable operation
public static decimal RoundToCents(decimal amount) =>
Math.Round(amount, 2, MidpointRounding.AwayFromZero);
// Compose pure functions
public static Func<CustomerType, decimal, decimal> CalculateFinalPrice =>
(type, total) =>
RoundToCents(
ApplyTax(
total - CalculateDiscount(type, total),
0.08m));
}
Testing Pure Functions is Trivial
[Theory]
[InlineData(CustomerType.Vip, 1000, 918.00)] // 15% discount + 8% tax on 850
[InlineData(CustomerType.Standard, 500, 540.00)] // No discount + 8% tax
[InlineData(CustomerType.Standard, 2000, 1944.00)] // 5% discount + 8% tax on 1900
public void CalculateFinalPrice_ReturnsExpected(CustomerType type, decimal total, decimal expected)
{
var result = PricingFunctions.CalculateFinalPrice(type, total);
Assert.Equal(expected, result);
}
The Composition Operator
C# doesn’t have a native compose operator, but we can build one:
public static class FunctionalExtensions
{
// Forward composition: f.Compose(g)(x) = g(f(x))
public static Func<T1, T3> Compose<T1, T2, T3>(
this Func<T1, T2> f,
Func<T2, T3> g) =>
x => g(f(x));
// Pipe operator: value.Pipe(f) = f(value) - makes reading left-to-right easier
public static T2 Pipe<T1, T2>(this T1 value, Func<T1, T2> func) =>
func(value);
// Async variants
public static Func<T1, Task<T3>> ComposeAsync<T1, T2, T3>(
this Func<T1, Task<T2>> f,
Func<T2, Task<T3>> g) =>
async x => await g(await f(x));
// Compose multiple functions
public static Func<T, T> ComposeAll<T>(params Func<T, T>[] functions) =>
functions.Aggregate((f, g) => f.Compose(g));
}
Usage: Building Data Transformation Pipelines
public class DataTransformationPipeline
{
// Individual transformation steps
private static string Trim(string input) => input.Trim();
private static string ToLower(string input) => input.ToLowerInvariant();
private static string RemoveSpecialChars(string input) =>
Regex.Replace(input, @"[^a-z0-9\s]", "");
private static string CollapseSpaces(string input) =>
Regex.Replace(input, @"\s+", " ");
// Composed transformation
public static Func<string, string> NormalizeText =
Trim
.Compose(ToLower)
.Compose(RemoveSpecialChars)
.Compose(CollapseSpaces);
// Alternative using Pipe (reads left-to-right)
public static string NormalizeTextPipe(string input) => input
.Pipe(Trim)
.Pipe(ToLower)
.Pipe(RemoveSpecialChars)
.Pipe(CollapseSpaces);
}
// Usage
var raw = " HELLO!! World... ";
var clean = DataTransformationPipeline.NormalizeText(raw);
// "hello world"
Real-World Pattern: Result Monad for Error Handling
Stop throwing exceptions for control flow. Use the Result pattern:
public readonly record struct Result<T, TError>
{
private readonly T? _value;
private readonly TError? _error;
private readonly bool _isSuccess;
private Result(T value)
{
_value = value;
_error = default;
_isSuccess = true;
}
private Result(TError error)
{
_value = default;
_error = error;
_isSuccess = false;
}
public bool IsSuccess => _isSuccess;
public bool IsFailure => !_isSuccess;
public T Value => _isSuccess ? _value! : throw new InvalidOperationException("No value");
public TError Error => !_isSuccess ? _error! : throw new InvalidOperationException("No error");
public static Result<T, TError> Success(T value) => new(value);
public static Result<T, TError> Failure(TError error) => new(error);
public static Result<T, TError> FromNullable(T? value, TError error) =>
value != null ? Success(value) : Failure(error);
// Map: transform success value
public Result<TResult, TError> Map<TResult>(Func<T, TResult> func) =>
_isSuccess ? Success(func(_value!)) : Failure(_error!);
// Bind: chain operations that can fail
public Result<TResult, TError> Bind<TResult>(Func<T, Result<TResult, TError>> func) =>
_isSuccess ? func(_value!) : Failure(_error!);
// Async variants
public async Task<Result<TResult, TError>> BindAsync<TResult>(
Func<T, Task<Result<TResult, TError>>> func) =>
_isSuccess ? await func(_value!) : Failure(_error!);
public async Task<Result<TResult, TError>> MapAsync<TResult>(
Func<T, Task<TResult>> func) =>
_isSuccess ? Success(await func(_value!)) : Failure(_error!);
// Match: handle both cases
public TResult Match<TResult>(Func<T, TResult> onSuccess, Func<TError, TResult> onFailure) =>
_isSuccess ? onSuccess(_value!) : onFailure(_error!);
// Tap: side effects without breaking chain
public Result<T, TError> Tap(Action<T> action)
{
if (_isSuccess) action(_value!);
return this;
}
}
Building Composable Validation
public static class ValidationFunctions
{
public static Result<string, ValidationError> NotNullOrEmpty(string? value, string fieldName) =>
string.IsNullOrWhiteSpace(value)
? Result<string, ValidationError>.Failure(new ValidationError(fieldName, "Required"))
: Result<string, ValidationError>.Success(value);
public static Result<string, ValidationError> MinLength(
string value, int min, string fieldName) =>
value.Length < min
? Result<string, ValidationError>.Failure(
new ValidationError(fieldName, $"Minimum {min} characters"))
: Result<string, ValidationError>.Success(value);
public static Result<string, ValidationError> EmailFormat(
string value, string fieldName) =>
Regex.IsMatch(value, @"^[^@]+@[^@]+\.[^@]+$")
? Result<string, ValidationError>.Success(value)
: Result<string, ValidationError>.Failure(
new ValidationError(fieldName, "Invalid email format"));
public static Result<string, ValidationError> ValidateEmail(string? email) =>
NotNullOrEmpty(email, "Email")
.Bind(e => MinLength(e, 5, "Email"))
.Bind(e => EmailFormat(e, "Email"));
}
// Usage in API endpoint
public async Task<IActionResult> RegisterUser(RegisterRequest request)
{
var result = await ValidationFunctions.ValidateEmail(request.Email)
.BindAsync(async email =>
{
var exists = await _userService.EmailExistsAsync(email);
return exists
? Result<string, ValidationError>.Failure(
new ValidationError("Email", "Already registered"))
: Result<string, ValidationError>.Success(email);
})
.BindAsync(async email =>
{
var user = await _userService.CreateAsync(email, request.Password);
return Result<User, ValidationError>.Success(user);
});
return result.Match<IActionResult>(
onSuccess: user => Ok(new { UserId = user.Id }),
onFailure: error => BadRequest(new { error.Field, error.Message }));
}
Higher-Order Functions in Practice
Functions that take functions as parameters or return functions:
public static class HigherOrderPatterns
{
// Retry: takes a function and makes it retryable
public static Func<Task<T>> WithRetry<T>(
Func<Task<T>> operation,
int maxRetries = 3,
TimeSpan? delay = null)
{
var retryDelay = delay ?? TimeSpan.FromSeconds(1);
return async () =>
{
var attempts = 0;
while (true)
{
try
{
return await operation();
}
catch (Exception ex) when (attempts < maxRetries)
{
attempts++;
await Task.Delay(retryDelay * attempts);
}
}
};
}
// Memoize: caches function results
public static Func<T, TResult> Memoize<T, TResult>(Func<T, TResult> func)
where T : notnull
{
var cache = new ConcurrentDictionary<T, TResult>();
return input => cache.GetOrAdd(input, func);
}
// Throttle: limits execution rate
public static Func<Task<T>> Throttle<T>(
Func<Task<T>> operation,
int requestsPerSecond)
{
var semaphore = new SemaphoreSlim(requestsPerSecond);
var lastExecution = DateTime.MinValue;
var lockObj = new object();
return async () =>
{
await semaphore.WaitAsync();
try
{
lock (lockObj)
{
var elapsed = DateTime.UtcNow - lastExecution;
var minInterval = TimeSpan.FromSeconds(1.0 / requestsPerSecond);
if (elapsed < minInterval)
Thread.Sleep(minInterval - elapsed);
lastExecution = DateTime.UtcNow;
}
return await operation();
}
finally
{
semaphore.Release();
}
};
}
// Curry: transforms f(a,b,c) into f(a)(b)(c)
public static Func<T1, Func<T2, TResult>> Curry<T1, T2, TResult>(
Func<T1, T2, TResult> func) =>
a => b => func(a, b);
// Partial application: fix some arguments
public static Func<T2, TResult> Partial<T1, T2, TResult>(
Func<T1, T2, TResult> func,
T1 arg1) =>
arg2 => func(arg1, arg2);
}
Real-World Usage: Resilient API Client
public class ResilientApiClient
{
private readonly HttpClient _httpClient;
public ResilientApiClient(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<T?> GetWithResilience<T>(string url)
{
// Compose retry, caching, and throttling
var fetchData = async () =>
{
var response = await _httpClient.GetAsync(url);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<T>();
};
var resilientFetch = HigherOrderPatterns
.WithRetry(fetchData, maxRetries: 3)
.Pipe(HigherOrderPatterns.Throttle<T?>(requestsPerSecond: 10))
.Pipe(HigherOrderPatterns.Memoize<string, Task<T?>>(url => fetchData()));
return await resilientFetch();
}
}
Functional Collections: Map, Filter, Reduce
LINQ is functional, but let’s make it more explicit:
public static class FunctionalCollections
{
// Map: transform each element
public static IEnumerable<TResult> Map<T, TResult>(
this IEnumerable<T> source,
Func<T, TResult> func) =>
source.Select(func);
// Filter: keep elements matching predicate
public static IEnumerable<T> Filter<T>(
this IEnumerable<T> source,
Func<T, bool> predicate) =>
source.Where(predicate);
// Reduce: aggregate to single value
public static TResult Reduce<T, TResult>(
this IEnumerable<T> source,
TResult seed,
Func<TResult, T, TResult> func) =>
source.Aggregate(seed, func);
// FlatMap: map then flatten
public static IEnumerable<TResult> FlatMap<T, TResult>(
this IEnumerable<T> source,
Func<T, IEnumerable<TResult>> func) =>
source.SelectMany(func);
// GroupBy with transformation
public static Dictionary<TKey, List<TResult>> GroupByTransform<T, TKey, TResult>(
this IEnumerable<T> source,
Func<T, TKey> keySelector,
Func<T, TResult> elementSelector) where TKey : notnull =>
source
.GroupBy(keySelector)
.ToDictionary(g => g.Key, g => g.Select(elementSelector).ToList());
// Pipeline-friendly extensions
public static IEnumerable<T> Tap<T>(
this IEnumerable<T> source,
Action<T> action)
{
foreach (var item in source)
{
action(item);
yield return item;
}
}
public static IEnumerable<T> When<T>(
this IEnumerable<T> source,
bool condition,
Func<IEnumerable<T>, IEnumerable<T>> transform) =>
condition ? transform(source) : source;
}
Data Processing Pipeline Example
public class SalesReportGenerator
{
public SalesReport Generate(IEnumerable<Sale> sales, DateTime startDate, DateTime endDate)
{
return sales
.Filter(s => s.Date >= startDate && s.Date <= endDate)
.When(startDate.Year == DateTime.Now.Year,
s => s.Tap(sale => AuditLog.Record(sale))) // Only audit current year
.Map(s => new EnrichedSale(s, GetRegion(s.StoreId)))
.Filter(es => es.Region != "Excluded")
.GroupByTransform(
es => new { es.Region, es.ProductCategory },
es => es.Amount)
.Map(g => new RegionCategorySummary
{
Region = g.Key.Region,
Category = g.Key.ProductCategory,
TotalSales = g.Value.Sum(),
TransactionCount = g.Value.Count,
AverageSale = g.Value.Average()
})
.OrderByDescending(s => s.TotalSales)
.Pipe(summaries => new SalesReport
{
GeneratedAt = DateTime.UtcNow,
Period = $"{startDate:yyyy-MM-dd} to {endDate:yyyy-MM-dd}",
Summaries = summaries.ToList(),
GrandTotal = summaries.Sum(s => s.TotalSales)
});
}
private string GetRegion(int storeId) =>
_regionLookup.GetValueOrDefault(storeId, "Unknown");
}
Immutable Data Structures
Function composition works best with immutable data:
public record Person(string FirstName, string LastName, int Age)
{
// With-expressions create new instances
public Person WithFirstName(string firstName) => this with { FirstName = firstName };
public Person WithAge(int age) => this with { Age = age };
public Person CelebrateBirthday() => this with { Age = Age + 1 };
}
public record Address(string Street, string City, string ZipCode);
public record Customer(
Guid Id,
Person Person,
Address Address,
IReadOnlyList<Order> Orders)
{
// Deep immutability with nested updates
public Customer MoveTo(Address newAddress) => this with { Address = newAddress };
public Customer Rename(string firstName, string lastName) => this with
{
Person = Person with { FirstName = firstName, LastName = lastName }
};
public Customer AddOrder(Order order) => this with
{
Orders = Orders.Append(order).ToList()
};
public decimal TotalSpent => Orders.Sum(o => o.Total);
}
// Usage - no mutation, just transformation
var customer = new Customer(
Guid.NewGuid(),
new Person("John", "Doe", 30),
new Address("123 Main St", "NYC", "10001"),
new List<Order>());
var updatedCustomer = customer
.CelebrateBirthday() // Returns new Person with Age 31
.MoveTo(new Address("456 Oak Ave", "Boston", "02101"))
.AddOrder(new Order { Total = 150.00m });
// Original unchanged
Console.WriteLine(customer.Person.Age); // 30
Console.WriteLine(updatedCustomer.Person.Age); // 31
Railway-Oriented Programming
A powerful pattern for handling multiple operations that can fail:
public static class RailwayOrientedProgramming
{
// Two-track model: Success track and Failure track
public static Result<T, TError> Switch<T, TError>(
this T input,
Func<T, Result<T, TError>> func) => func(input);
public static Result<TOutput, TError> Switch<TInput, TOutput, TError>(
this Result<TInput, TError> input,
Func<TInput, Result<TOutput, TError>> func) =>
input.IsSuccess ? func(input.Value) : Result<TOutput, TError>.Failure(input.Error);
public static Result<T, TError> Tee<T, TError>(
this Result<T, TError> input,
Action<T> action)
{
if (input.IsSuccess) action(input.Value);
return input;
}
public static Result<T, TError> Ensure<T, TError>(
this Result<T, TError> input,
Func<T, bool> predicate,
TError error) =>
input.IsSuccess && !predicate(input.Value)
? Result<T, TError>.Failure(error)
: input;
public static TOutput Finally<T, TError, TOutput>(
this Result<T, TError> input,
Func<T, TOutput> onSuccess,
Func<TError, TOutput> onFailure) =>
input.IsSuccess ? onSuccess(input.Value) : onFailure(input.Error);
}
Complete Business Flow Example
public class OrderProcessingService
{
public async Task<PaymentResult> ProcessPayment(PaymentRequest request)
{
return await Result<PaymentRequest, PaymentError>
.Success(request)
.Ensure(r => r.Amount > 0, PaymentError.InvalidAmount)
.Ensure(r => !string.IsNullOrEmpty(r.CardToken), PaymentError.MissingCard)
.Switch(async r => await ValidateCardAsync(r))
.Tee(r => _logger.LogInformation("Card validated for {Amount}", r.Amount))
.Switch(async r => await ReserveFundsAsync(r))
.Switch(async r => await CreateTransactionAsync(r))
.Tee(r => _metrics.RecordPayment(r.Amount))
.Finally(
onSuccess: transaction => new PaymentResult
{
Success = true,
TransactionId = transaction.Id
},
onFailure: error => new PaymentResult
{
Success = false,
ErrorCode = error.Code,
ErrorMessage = error.Message
});
}
private async Task<Result<ValidatedPayment, PaymentError>> ValidateCardAsync(
PaymentRequest request)
{
var validation = await _paymentGateway.ValidateCard(request.CardToken);
return validation.IsValid
? Result<ValidatedPayment, PaymentError>.Success(
new ValidatedPayment(request, validation.CardLastFour))
: Result<ValidatedPayment, PaymentError>.Failure(PaymentError.CardDeclined);
}
private async Task<Result<ReservedFunds, PaymentError>> ReserveFundsAsync(
ValidatedPayment payment)
{
var reserve = await _paymentGateway.Reserve(payment.CardToken, payment.Amount);
return reserve.Success
? Result<ReservedFunds, PaymentError>.Success(
new ReservedFunds(payment, reserve.ReservationId))
: Result<ReservedFunds, PaymentError>.Failure(PaymentError.InsufficientFunds);
}
}
Putting It All Together: A Complete API
// Program.cs - Configure functional dependencies
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<Func<string, Result<Email, ValidationError>>>(
_ => ValidationFunctions.ValidateEmail);
builder.Services.AddSingleton<Func<OrderRequest, Result<ValidOrder, ValidationError>>>(
_ => OrderValidation.ValidateOrder);
builder.Services.AddScoped<OrderProcessingPipeline>();
var app = builder.Build();
// Minimal API endpoint using functional composition
app.MapPost("/orders", async (
OrderRequest request,
OrderProcessingPipeline pipeline,
CancellationToken ct) =>
{
var result = await pipeline.ProcessAsync(request, ct);
return result.Match<IResult>(
onSuccess: order => Results.Created($"/orders/{order.Id}", order),
onFailure: error => error.Code switch
{
ErrorCode.NotFound => Results.NotFound(error.Message),
ErrorCode.Validation => Results.BadRequest(error.Message),
ErrorCode.Conflict => Results.Conflict(error.Message),
_ => Results.StatusCode(500)
});
});
app.Run();
Key Takeaways
- Pure functions are the foundation — deterministic, testable, composable
- Function composition builds complexity from simplicity — small functions, big results
- The Result monad eliminates exceptions as control flow — explicit error handling
- Immutability prevents bugs — data transforms instead of mutates
- Higher-order functions abstract patterns — retry, cache, throttle once, use everywhere
When NOT to Use Functional Programming
Conclusion
Functional programming in C# isn’t about abandoning objects — it’s about combining the best of both worlds. Use OOP for boundaries (APIs, frameworks) and FP for business logic (transformations, validations, workflows).
The code you write today will be read hundreds of times. Make it readable. Make it testable. Make it composable.
Start small: Replace one loop with Map, one if/throw with Result. The patterns will feel natural quickly, and your codebase will thank you.