The Power of Expression Trees in .NET
Have you ever looked at the left sidebar on Amazon?

The Power of Expression Trees in .NET
Have you ever looked at the left sidebar on Amazon?
You search for an item, and suddenly you are presented with a dizzying array of filters. You can filter by Brand, Price Range, Customer Rating, and technical specs. Then, you click that top-right dropdown and select “Sort by: Price (Low to High)”. As a user, the grid updates instantly. It’s a seamless experience. But as a software engineer, I found myself staring at that interface, my mind immediately jumping to the backend: How on earth are they structuring that query?
When users interact with these controls, they aren’t just sending a standard search query; they are interacting with a faceted search and dynamic sorting system. The available data dynamically refines and reorders itself based on complex, shifting parameters.
I was in the middle of building an application — a dynamic Angular frontend hooked up to a .NET API. Initially, I thought I had it all figured out. I just needed to let users filter by Name and Category. I added a couple of nullable parameters to my API endpoint, wrote a few simple if statements in my service layer, and called it a day.
Then, my own requirements evolved. “Can we also filter by Price Range? What about Brand? Can we filter by technical specs like RAM and Storage for electronics, or Size and Color for apparel? Oh, and users need to be able to sort the results by Price, Date Added, or Brand Name, either ascending or descending.”
And the feature that completely broke my backend: “Can we find products where Brand is ‘Apple’ OR Category is ‘Refurbished Electronics’? And within those, can we filter for RAM == ‘16GB’ AND Price is between $500 and $1200? Finally, sort those results by Brand ascending, then by Price descending.”
I wanted my application to feel as powerful as that Amazon experience. My frontend was more than capable of dynamically generating a structured JSON payload of filter and sort rules. But my backend? That was a different story.
Let me walk you through the architectural trap I fell into, the inevitable “switch statement explosion,” and the C# feature that ultimately saved my codebase: Expression Trees.
The Initial Implementation of Using Switch Statements
When tasked with applying dynamic filters and sorts from a UI, the most intuitive approach is to write procedural code. I created a clean contract for my Angular frontend to send to the .NET API:
"filters": [
{ "field": "Brand", "operator": "eq", "value": "Sony", "condition": "and" },
{ "field": "Price", "operator": "lte", "value": "500", "condition": "and" }
],
"sorts": [
{ "field": "Brand", "direction": "asc" },
{ "field": "Price", "direction": "desc" }
],
"pageNumber": 1,
"pageSize": 10
}
To process this, I wrote what I now call the “Ridiculous Switch Statement.” If you have been writing C# for a while, I am willing to bet you’ve written and/or thought of something just like this:
public IQueryable<Product> ApplyRules(IQueryable<Product> query, QueryRequest request)
{
foreach (var filter in request.Filters)
{
switch (filter.Field.ToLower())
{
case "brand":
if (filter.Operator == "eq") query = query.Where(x => x.Brand == filter.Value);
break;
case "price":
if (decimal.TryParse(filter.Value, out decimal price))
{
if (filter.Operator == "lte") query = query.Where(x => x.Price <= price);
}
break;
// Ridiculously increasing lines of repetitive filter code
}
}
var isFirstSort = true;
foreach (var sort in request.Sorts)
{
switch (sort.Field.ToLower())
{
case "price":
if (isFirstSort)
query = sort.Direction == "asc" ? query.OrderBy(x => x.Price) : query.OrderByDescending(x => x.Price);
else
query = sort.Direction == "asc" ? ((IOrderedQueryable<Product>)query).ThenBy(x => x.Price) : ((IOrderedQueryable<Product>)query).ThenByDescending(x => x.Price);
break;
// same ridiculously increasing code for filtering sorts
}
isFirstSort = false;
}
return query;
}
The Switch Statement Explosion
At first glance, it worked. But as more product categories were added, the architecture began screaming under its own weight.
Every time I added a new category of products, I faced an explosion of switch cases in both the filtering and sorting blocks. Laptops introduced Ram and Storage. Clothing introduced Size and Material.
- The Open/Closed Principle was Dead: Every new product facet forced me to open this exact C# file, append new case blocks, type-cast the string data manually, and redeploy.
- The Fragility of Magic Strings: Relying on manual parsing (
decimal.TryParse) inside a growing expanse of switch cases became a hotpath for silent validation bugs.
The “OR” Dilemma
Look closely at the procedural filter code above. Entity Framework (EF) Core uses deferred execution. Every time you chain .Where() on an IQueryable, EF Core implicitly applies an AND operator. query.Where(A).Where(B) means A AND B.
What happens when the UI sends an OR condition? Show me products where Brand == "Apple" OR Category == "Refurbished".
With the standard switch approach, implementing this dynamically is virtually impossible. You cannot simply chain an OR onto an existing IQueryable pipeline. To make this work procedurally, you would have to resort to writing massive, messy .Union() queries, which force the database to execute two entirely separate queries and merge the distinct results, destroying query plan optimization.
I realized I wasn’t just writing a poorly designed feature; I was building a fragile, poorly-implemented SQL parser.
Expression Trees to the Rescue
I started digging into how LINQ actually works under the hood. When you write query.Where(x => x.Price > 500).OrderBy(x => x.Brand), how does EF Core know how to translate that into a SQL WHERE and ORDER BY clause?
The answer is that EF Core doesn’t execute your code; it reads a data structure representing your code.
In C#, if you assign a lambda to a delegate like Func<T>, the compiler turns it into executable Intermediate Language (IL). But if you assign that exact same lambda to an Expression<Func<T>>, the compiler creates an Abstract Syntax Tree (AST)—an in-memory, hierarchical map of your logic.
Let’s look at what the compiler actually does. Take this incredibly simple C# example:
using System;
using System.Linq.Expressions;
public class Example {
public void Execute() {
// A standard executable delegate
Func<int> executableFunc = () => 30;
// An Expression Tree (Data structure)
Expression<Func<int>> expressionTree = () => 30;
}
}
If we run this through a decompiler to look at the lowered C# code, the underlying magic is exposed:
public class Example
{
[Serializable]
[CompilerGenerated]
private sealed class <>c
{
public static readonly <>c <>9 = new <>c();
public static Func<int> <>9__0_0;
internal int <Execute>b__0_0()
{
return 30;
}
}
public void Execute()
{
//Executable Func pointer generated by the compiler
Func<int> func = <>c.<>9__0_0 ?? (<>c.<>9__0_0 = new Func<int>(<>c.<>9.<Execute>b__0_0));
//The Expression Tree is generated strictly as an object graph!
Expression<Func<int>> expression = Expression.Lambda<Func<int>>(Expression.Constant(30, typeof(int)), Array.Empty<ParameterExpression>());
}
}
Because an expression tree is just a data structure of nodes, we can write code to assemble it programmatically at runtime based on whatever variables the UI passes us. EF Core’s entire superpower relies on walking this AST and translating those nodes into SQL strings. By manually building the tree, we are speaking EF Core’s native language.
Turning Code Into Data
To build a filter like x => x.Brand == "Apple" or a sort like OrderBy(x => x.Brand) dynamically, we must programmatically construct the syntax tree using two foundational operations:
**Expression.Parameter(typeof(T), "x")**: This defines the input parameter. It is the exact programmatic equivalent of writing thexinx => ...**Expression.Property(parameter, "Brand")**: This performs dynamic property access. It is the equivalent of writing the dot-notation.Brand. Instead of hardcoding the property at design time, it uses reflection to locate the property metadata matching the incoming string value.
Implementation
To completely eliminate the switch statement explosion and seamlessly handle AND/OR routes, dynamic OrderBy/ThenBy chains, nested properties, and complex type conversions, we need a production-ready engine.
The Dynamic DTO Contracts
We need a unified payload that cleanly separates filtering intentions from sorting intentions.
public class QueryRequest
{
public List<FilterRule> Filters { get; set; } = new();
public List<SortRule> Sorts { get; set; } = new();
public int PageNumber { get; set; } = 1;
public int PageSize { get; set; } = 10;
}
public class FilterRule
{
public string Field { get; set; } = string.Empty; // e.g., "Price", "Category.Name"
public string Operator { get; set; } = "eq"; // eq, neq, gte, lte, contains, between
public string Value { get; set; } = string.Empty; // e.g., "Apple", "100,500"
public string Condition { get; set; } = "and"; // and, or
}
public class SortRule
{
public string Field { get; set; } = string.Empty; // e.g., "Brand", "Price"
public string Direction { get; set; } = "asc"; // asc, desc
}
public class PagedResult<T>
{
public IEnumerable<T> Items { get; set; } = new List<T>();
public int TotalCount { get; set; }
public int PageNumber { get; set; }
public int PageSize { get; set; }
public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize);
}
The Expression Combiner (The Parameter Re-binder)
When combining two separate lambda expressions (e.g., x => x.Price > 50 and y => y.Brand == "Apple"), you cannot simply use Expression.AndAlso on their bodies. The parameters x and y are different memory references, and EF Core will crash. We use an ExpressionVisitor to safely rebind input parameters into a uniform context:
public static class ExpressionCombiner
{
public static Expression<Func<T, bool>> CombineWith<T>(
this Expression<Func<T, bool>> first,
Expression<Func<T, bool>> second,
string condition)
{
var parameterReplacer = new ParameterReplacer(second.Parameters[0], first.Parameters[0]);
var rewrittenBody = parameterReplacer.Visit(second.Body);
Expression combinedBody = condition.ToLower() == "or"
? Expression.OrElse(first.Body, rewrittenBody)
: Expression.AndAlso(first.Body, rewrittenBody);
return Expression.Lambda<Func<T, bool>>(combinedBody, first.Parameters);
}
}
public class ParameterReplacer : ExpressionVisitor
{
private readonly ParameterExpression _oldParameter;
private readonly ParameterExpression _newParameter;
public ParameterReplacer(ParameterExpression oldParameter, ParameterExpression newParameter)
{
_oldParameter = oldParameter;
_newParameter = newParameter;
}
protected override Expression VisitParameter(ParameterExpression node)
{
return node == _oldParameter ? _newParameter : base.VisitParameter(node);
}
}
The Dynamic Filter Builder
This engine handles deep nested properties (like Category.Name), protects against null strings during .Contains() evaluations, and safely parses complex database types like Guid and Enum.
public static class DynamicFilterBuilder
{
public static Expression<Func<T, bool>> Build<T>(IEnumerable<FilterRule> rules)
{
if (rules == null || !rules.Any()) return x => true;
Expression<Func<T, bool>> rootExpression = null!;
foreach (var rule in rules)
{
var ruleExpression = BuildSingleRule<T>(rule);
if (rootExpression == null)
{
rootExpression = ruleExpression;
continue;
}
rootExpression = rootExpression.CombineWith(ruleExpression, rule.Condition);
}
return rootExpression;
}
private static Expression<Func<T, bool>> BuildSingleRule<T>(FilterRule rule)
{
var parameter = Expression.Parameter(typeof(T), "x"); // x is a generic name here
//For Nested Poperties ("Category.Name") where Category
// could be a type on say Product
Expression property = parameter;
foreach (var member in rule.Field.Split('.'))
{
property = Expression.PropertyOrField(property, member);
// Essentially on iteration property = x.Category
// On the next iteration the property will be property = x.Category.Name
}
if (rule.Operator.ToLower() != "between")
{
var targetType = Nullable.GetUnderlyingType(property.Type) ?? property.Type;
var convertedValue = ParseValue(rule.Value, targetType);
var constant = Expression.Constant(convertedValue, property.Type);
Expression body = rule.Operator.ToLower() switch
{
"eq" => Expression.Equal(property, constant),
"neq" => Expression.NotEqual(property, constant),
"gt" => Expression.GreaterThan(property, constant),
"gte" => Expression.GreaterThanOrEqual(property, constant),
"lt" => Expression.LessThan(property, constant),
"lte" => Expression.LessThanOrEqual(property, constant),
"contains" => BuildStringMethod(property, "Contains", rule.Value),
_ => throw new NotSupportedException($"Operator '{rule.Operator}' is not supported.")
};
return Expression.Lambda<Func<T, bool>>(body, parameter);
// This essentially creates lambda x => x.Price > 10 or whatever filter specified.
}
else
{
Expression rangeBody = BuildRangeExpression(property, rule.Value);
return Expression.Lambda<Func<T, bool>>(rangeBody, parameter);
}
}
private static object ParseValue(string value, Type targetType)
{
if (targetType == typeof(Guid)) return Guid.Parse(value);
if (targetType.IsEnum) return Enum.Parse(targetType, value, true);
if (targetType == typeof(DateTime)) return DateTime.Parse(value);
return Convert.ChangeType(value, targetType);
}
private static Expression BuildRangeExpression(Expression property, string value)
{
var parts = value.Split(',');
if (parts.Length != 2) throw new ArgumentException("The 'between' operator requires min,max");
var targetType = Nullable.GetUnderlyingType(property.Type) ?? property.Type;
var minConstant = Expression.Constant(ParseValue(parts[0], targetType), property.Type);
var lowerBound = Expression.GreaterThanOrEqual(property, minConstant);
var maxConstant = Expression.Constant(ParseValue(parts[1], targetType), property.Type);
var upperBound = Expression.LessThanOrEqual(property, maxConstant);
return Expression.AndAlso(lowerBound, upperBound);
}
private static Expression BuildStringMethod(Expression property, string methodName, string value)
{
var method = typeof(string).GetMethod(methodName, new[] { typeof(string) });
var constant = Expression.Constant(value, typeof(string));
var notNull = Expression.NotEqual(property, Expression.Constant(null, typeof(string)));
var methodCall = Expression.Call(property, method!, constant);
return Expression.AndAlso(notNull, methodCall);
}
}
A Quick Note on
.Contains()and Database Collation If you look closely at theBuildStringMethodabove, we are dynamically invoking the C#.Contains()method. It is important to know that EF Core translates this into a SQLLIKEstatement, and the behavior ofLIKEdepends entirely on your database provider's collation.
If you are using SQL Server, your queries will generally be case-insensitive by default. However, if you are using PostgreSQL, standard
LIKEevaluations are case-sensitive. So you may need to configure your database collation or alter the expression builder to utilizeEF.Functions.ILike()to ensure users don't get empty results just because they forgot to capitalize a search term.
The Dynamic Sort Builder
Sorting dynamically requires invoking the generic OrderBy, OrderByDescending, ThenBy, and ThenByDescending methods on IQueryable via reflection. This extension method dynamically chains sorts together while seamlessly handling nested properties.
public static class DynamicSortBuilder
{
public static IQueryable<T> ApplySorting<T>(this IQueryable<T> query, IEnumerable<SortRule> sorts)
{
if (sorts == null || !sorts.Any()) return query;
var elementType = typeof(T);
var parameter = Expression.Parameter(elementType, "x");
bool isFirstSort = true;
foreach (var sort in sorts)
{
Expression property = parameter;
foreach (var member in sort.Field.Split('.'))
{
property = Expression.PropertyOrField(property, member);
}
var selector = Expression.Lambda(property, parameter);
string methodName = isFirstSort
? (sort.Direction.ToLower() == "desc" ? "OrderByDescending" : "OrderBy")
: (sort.Direction.ToLower() == "desc" ? "ThenByDescending" : "ThenBy");
// Fetch the corresponding generic method from Queryable
var method = typeof(Queryable).GetMethods()
.First(m => m.Name == methodName && m.GetParameters().Length == 2)
.MakeGenericMethod(elementType, property.Type);
// Invoke the method on the current query
query = (IQueryable<T>)method.Invoke(null, new object[] { query, selector })!;
isFirstSort = false;
}
return query;
}
}
public static IQueryable<T> ApplyPagination<T>(this IQueryable<T> query, int pageNumber, int pageSize)
{
if (pageNumber < 1) pageNumber = 1;
if (pageSize < 1) pageSize = 10;
//This is a defensive check so that any malicious request does not throttle the db.
const int maxPageSize = 500;
if (pageSize > maxPageSize) pageSize = maxPageSize;
var skip = (pageNumber - 1) * pageSize;
return query.Skip(skip).Take(pageSize);
}
Integration with Repository
public class Repository<T> : IRepository<T> where T : class
{
private readonly DbContext _context;
private readonly DbSet<T> _dbSet;
public Repository(DbContext context)
{
_context = context;
_dbSet = _context.Set<T>();
}
public async Task<PagedResult<T>> FindAsync(QueryRequest request)
{
// Entire where is built here
var predicate = DynamicFilterBuilder.Build<T>(request.Filters);
// 2. Apply the filters to the base query
var query = _dbSet.Where(predicate);
var totalCount = await query.CountAsync();
// First Sorting then pagination
var items = await query
.ApplySorting(request.Sorts)
.ApplyPagination(request.PageNumber, request.PageSize)
.ToListAsync();
// 5. Return the packaged result
return new PagedResult<T>
{
Items = items,
TotalCount = totalCount,
PageNumber = request.PageNumber,
PageSize = request.PageSize
};
}
}
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private readonly IRepository<Product> _productRepository;
public ProductsController(IRepository<Product> productRepository)
{
_productRepository = productRepository;
}
[HttpPost("search")]
public async Task<IActionResult> SearchProducts([FromBody] QueryRequest request)
{
try
{
var pagedProducts = await _productRepository.FindAsync(request);
return Ok(pagedProducts);
}
catch (Exception ex)
{
//Any kind of logging goes here.
return BadRequest($"Invalid query request: {ex.Message}");
}
}
}
By implementing this architecture, we achieve true Separation of Concerns. The API Controller and the Repository now know absolutely nothing about business rules, product schemas, or magical strings. They just apply the generic engines to the database context.
Comments and Tradeoffs
Shifting away from procedural switch statements toward Expression Trees marks a fundamental transition in how we handle data: we stop writing rigid, sequential steps and start defining code as configurable data structures. The structural advantages of this approach are immense. By abstracting the concept of filtering and sorting into a dynamic engine, code maintenance becomes essentially constant; adding ten new product facets or complex nested categories requires absolutely zero changes to the backend infrastructure. Furthermore, because Entity Framework Core natively understands the Abstract Syntax Tree we are building, it translates these dynamic graphs perfectly into highly optimized, index-friendly SQL queries. Best of all, this architecture provides bulletproof security. When building dynamic query engines, the most common pitfall is accidentally opening the door to SQL Injection. Because we are constructing an Abstract Syntax Tree rather than concatenating raw SQL strings, Entity Framework Core automatically parameterizes every single user input. If a malicious user passes a SQL drop command into our filter value, the engine safely wraps it in a ConstantExpression, and EF Core treats it strictly as a string literal. The dynamic engine is 100% immune to SQL injection by design.
We can elevate this architecture even further by pairing the expression engine with monadic control flow. By wrapping the repository response in a monadic structure — such as a custom Checked<PagedResult<T>, Error> type—we can completely eliminate the need for try/catch blocks in our API layer. Through Railway Oriented Programming, the pipeline explicitly declares its failure states. If the expression builder encounters an invalid operator or fails a type conversion, it doesn't throw a disruptive exception; it simply returns a failure track. The API controller is no longer burdened with infrastructure concerns. It elegantly unpacks the monad, evaluates the state, and seamlessly translates the failure directly into a structured 400 Bad Request.
However, any architectural leap comes with oversights and tradeoffs, and the elephant in the room here is our reliance on Reflection. By using Expression.Property and GetMethod(), we are actively stripping away C#'s greatest strength: compile-time safety. We are shifting our safety net entirely to runtime. If a frontend client misspells a payload field—sending "Brannd" instead of "Brand"—the compiler cannot save us. The reflection engine will fail during execution. Furthermore, we are reintroducing "magic strings" into the core of our domain logic, which means refactoring a property name on the Product entity could silently break frontend integrations if the UI is not updated simultaneously.
Ultimately, the question is whether this tradeoff is practical for production development. In the context of a modern web API, the microscopic compute overhead of Reflection is entirely eclipsed by network latency and database I/O. When you weigh the loss of compile-time property checks against the massive leap in developer velocity, the elimination of switch-statement bloat, and the absolute safety of a monadic execution pipeline, the architectural tradeoff is overwhelmingly in our favor. You don’t need a massive infrastructure team to build catalog filtering and sorting as flexible as Amazon’s. You just need to leverage the C# compiler, accept the realities of runtime evaluation, and let your data flow safely down the tracks.
메타데이터
- post_id
- fc04cecaf3a2
- slug
- the-power-of-expression-trees-in-net-fc04cecaf3a2
- url
- https://medium.com/@pravahanjaivili/the-power-of-expression-trees-in-net-fc04cecaf3a2
- canonical_url
- https://medium.com/@pravahanjaivili/the-power-of-expression-trees-in-net-fc04cecaf3a2
- author_url
- https://medium.com/@pravahanjaivili
- status
- ok
- fetched_at
- 2026-06-18 07:02:39