Expression Trees

By the Syncfusion DevTutorials team · Last updated

Store code as data: Expression and lambda trees, building nodes by hand, compiling and executing, ExpressionVisitor, dynamic queries, IQueryable and how ORMs translate to SQL.

Introduction to Expression Trees

Think about two ways to share a recipe.

You can cook the dish and hand over the plate. The other person can eat it, but they cannot see how it was made.

Or you can write the recipe down. Now the other person can read each step, change an ingredient, translate it into another language, and then cook it.

A normal lambda like x => x > 5 is the plate. It is already compiled and ready to run. No one can look inside to see how it works.

An expression tree is the written recipe. It stores the same lambda as data: a tree of small pieces that explains "take the input x, take the number 5, compare them with greater-than." Your code can read that tree, change it, turn it into SQL, or compile it and run it.

In simple words: an expression tree is code saved as data, so other code can read and work with it.

Easy Way to Remember: a delegate is the cooked dish. An expression tree is the recipe.

What You Will Learn

In this lesson, you will learn:

  1. What an Expression is and how it works differently from a delegate.
  2. How the compiler turns a lambda into an expression tree.
  3. How to build expression trees yourself.
  4. The main kinds of expression pieces (called nodes).
  5. How to compile and run an expression tree.
  6. How to read and change trees with ExpressionVisitor.
  7. How to build searches and filters at runtime.
  8. How LINQ and databases like Entity Framework use expression trees.

All runnable examples on this page run in your browser. Compiling expression trees works here too. Examples that need a real database, like Entity Framework Core, are shown as plain code. The runnable examples use in-memory data instead.

Start With a Working Example

Press Run.

The same lambda as a delegate and as data

using System;
using System.Linq.Expressions;

Func<int, bool> asCode = x => x > 5;
Expression<Func<int, bool>> asData = x => x > 5;

Console.WriteLine($"Delegate result: {asCode(8)}");
Console.WriteLine($"Delegate as text: {asCode}");

Console.WriteLine($"Tree as text:    {asData}");
Console.WriteLine($"Tree body:       {asData.Body}");
Console.WriteLine($"Body node type:  {asData.Body.NodeType}");

var compare = (BinaryExpression)asData.Body;
Console.WriteLine($"Left side:       {compare.Left}");
Console.WriteLine($"Right side:      {compare.Right}");

Both lines have the same lambda: x => x > 5. The only difference is the declared type.

  • As a Func<int, bool>, it is compiled code. Printing it gives you no useful information.
  • As an Expression<Func<int, bool>>, it is a tree. You can print it and look inside: left side, right side, and operator.

Try This

Change both lambdas to x => x % 2 == 0 and run again. Look at the Body node type line. It changes from GreaterThan to Equal.

Expression

System.Linq.Expressions.Expression is the base class for every piece of a tree. Each piece is called a node.

Every node has two key parts:

  • NodeType tells you what the node does: Add, Constant, Parameter, Call, GreaterThan and so on.
  • Type tells you what .NET type the node produces when it runs: int, bool, string.

Nodes hold other nodes inside. A BinaryExpression like a + b holds a Left node and a Right node. That is why it is called a tree: one top node with branches going down to the leaves.

Walk down the tree

using System;
using System.Linq.Expressions;

Expression<Func<int, int, int>> price = (qty, rate) => qty * rate + 50;

Show(price.Body, 0);

static void Show(Expression node, int depth)
{
    string indent = new string(' ', depth * 2);
    Console.WriteLine($"{indent}{node.NodeType} ({node.Type.Name}): {node}");

    if (node is BinaryExpression b)
    {
        Show(b.Left, depth + 1);
        Show(b.Right, depth + 1);
    }
}

The top node is Add. Its left branch is the Multiply. Its right branch is the number 50. The bottom pieces are the inputs qty and rate, and the number.

            Add
          /     \
     Multiply    50
      /    \
    qty    rate

Lambda Expressions

The easiest way to get an expression tree is to let the compiler build it.

When you save a lambda to a variable of type Expression<TDelegate>, the compiler does not turn the lambda into code. Instead, it writes code that builds the tree.

The top of the tree is a LambdaExpression. It has:

  • Parameters: what the lambda takes as input,
  • Body: the expression after the =>,
  • ReturnType: what the lambda gives back.

Inside a LambdaExpression

using System;
using System.Linq.Expressions;

Expression<Func<string, int, bool>> rule = (name, age) => name.Length > 2 && age >= 18;

Console.WriteLine($"Whole lambda: {rule}");
Console.WriteLine($"Returns:      {rule.ReturnType.Name}");

foreach (ParameterExpression p in rule.Parameters)
    Console.WriteLine($"Parameter:    {p.Name} : {p.Type.Name}");

Console.WriteLine($"Body:         {rule.Body}");
Console.WriteLine($"Body type:    {rule.Body.NodeType}");

What the Compiler Cannot Convert

Only expression lambdas (a single expression after =>) can become trees. A statement lambda with braces cannot.

Expression<Func<int, int>> ok = x => x * 2;

Expression<Func<int, int>> bad = x => { return x * 2; };
// error CS0834: A lambda expression with a statement body
// cannot be converted to an expression tree

A few other things are not allowed either, like await, assignments with =, and some newer syntax. The compiler tells you with an error when you use one.

Building Expression Trees

You can also build a tree by hand, using the static factory methods on the Expression class. This is how you create code whose shape you only learn while the program runs.

Each factory method makes one node:

MethodMakesLike writing
Expression.Parameter(typeof(int), "x")An inputx
Expression.Constant(10)A fixed number10
Expression.Add(a, b)Additiona + b
Expression.GreaterThan(a, b)Comparisona > b
Expression.Property(obj, "Name")Property readobj.Name
Expression.Call(obj, method, args)Method callobj.Method(args)
Expression.Lambda<T>(body, params)The full lambdax => body

You build from the leaves up: first the inputs and numbers, then the operators that combine them, and finally the lambda.

Build x => x * 2 + 1 by hand

using System;
using System.Linq.Expressions;

// 1. Leaves
ParameterExpression x = Expression.Parameter(typeof(int), "x");
ConstantExpression two = Expression.Constant(2);
ConstantExpression one = Expression.Constant(1);

// 2. Branches
BinaryExpression times = Expression.Multiply(x, two);
BinaryExpression plus = Expression.Add(times, one);

// 3. Root
Expression<Func<int, int>> lambda = Expression.Lambda<Func<int, int>>(plus, x);

Console.WriteLine(lambda);
Func<int, int> run = lambda.Compile();
Console.WriteLine($"f(5) = {run(5)}");

The tree you build by hand prints just like a lambda you typed: x => ((x * 2) + 1).

Building With Properties and Method Calls

Real code works with objects. Expression.Property reads a property. Expression.Call calls a method.

Build a StartsWith filter by hand

using System;
using System.Linq.Expressions;

ParameterExpression p = Expression.Parameter(typeof(Person), "p");
MemberExpression name = Expression.Property(p, nameof(Person.Name));
var startsWith = typeof(string).GetMethod("StartsWith", new[] { typeof(string) })!;
MethodCallExpression call = Expression.Call(name, startsWith, Expression.Constant("A"));

var filter = Expression.Lambda<Func<Person, bool>>(call, p);
Console.WriteLine(filter);

var test = filter.Compile();
Console.WriteLine($"Asha: {test(new Person("Asha"))}");
Console.WriteLine($"Ravi: {test(new Person("Ravi"))}");

record Person(string Name);

Expression.Call needs a MethodInfo, so this is where reflection from lesson 13 comes back. Expression trees and reflection work together often.

Hand-built trees are checked when you build them, not when you write them. A wrong type, like adding an int to a string, throws an InvalidOperationException or ArgumentException from the factory method while the program runs. Build trees step by step and print them as you go.

Expression Nodes

There are about 85 node types, but a small number cover most real code.

Node classNodeType examplesRepresents
ParameterExpressionParameterAn input, like x
ConstantExpressionConstantA fixed value, like 5 or "Pune"
BinaryExpressionAdd, Equal, AndAlso, GreaterThanTwo things and an operator
UnaryExpressionNot, Negate, ConvertOne thing
MemberExpressionMemberAccessA property or field, like p.Age
MethodCallExpressionCallA method call
ConditionalExpressionConditionaltest ? a : b
NewExpressionNewnew Point(1, 2)
LambdaExpressionLambdaThe full lambda

You check what kind a node is with pattern matching, like in the next example.

Describe every node in a tree

using System;
using System.Linq.Expressions;

Expression<Func<Order, string>> label =
    o => o.Total > 1000 ? "Big: " + o.Customer.ToUpper() : "Small";

Describe(label.Body, 0);

static void Describe(Expression node, int depth)
{
    string pad = new string(' ', depth * 2);
    switch (node)
    {
        case ConditionalExpression c:
            Console.WriteLine($"{pad}Conditional");
            Describe(c.Test, depth + 1);
            Describe(c.IfTrue, depth + 1);
            Describe(c.IfFalse, depth + 1);
            break;
        case BinaryExpression b:
            Console.WriteLine($"{pad}Binary {b.NodeType}");
            Describe(b.Left, depth + 1);
            Describe(b.Right, depth + 1);
            break;
        case MethodCallExpression m:
            Console.WriteLine($"{pad}Call {m.Method.Name}");
            if (m.Object != null) Describe(m.Object, depth + 1);
            break;
        case MemberExpression m:
            Console.WriteLine($"{pad}Member {m.Member.Name}");
            break;
        case ConstantExpression k:
            Console.WriteLine($"{pad}Constant {k.Value}");
            break;
        default:
            Console.WriteLine($"{pad}{node.NodeType}");
            break;
    }
}

class Order
{
    public decimal Total { get; set; }
    public string Customer { get; set; } = "";
}

Notice the 1000 constant. It shows as 1000 even though Total is a decimal, because the compiler stored it as a decimal constant. Trees keep the exact types the compiler used.

Compiling Expressions

A tree is data. To run it, you must turn it into a delegate. That is what Compile() does.

Compile() reads the tree and makes real code that can run. It gives back a delegate with the same type as the lambda: an Expression<Func<int, int>> compiles to a Func<int, int>.

Compile a tree into a delegate

using System;
using System.Linq.Expressions;

Expression<Func<decimal, decimal, decimal>> withTax = (price, rate) => price + price * rate / 100;

Func<decimal, decimal, decimal> calculate = withTax.Compile();

Console.WriteLine(withTax);
Console.WriteLine($"Rs. 1000 at 18% = {calculate(1000m, 18m)}");
Console.WriteLine($"Rs. 250 at 5%   = {calculate(250m, 5m)}");

Compile() is slow: it makes new code every time you call it. Compile a tree one time, keep the delegate in a field or dictionary, and call the delegate as many times as you want. Calling the delegate is as fast as calling a normal method.

This is the expression-tree speed boost promised in the reflection lesson. You can build a fast property reader for any type and property name:

A fast getter built from a property name

using System;
using System.Linq.Expressions;

Func<object, object?> getter = MakeGetter(typeof(Book), "Pages");

var books = new[] { new Book { Title = "Godan", Pages = 312 }, new Book { Title = "Malgudi Days", Pages = 256 } };
foreach (var book in books)
    Console.WriteLine($"{book.Title}: {getter(book)} pages");

static Func<object, object?> MakeGetter(Type type, string propertyName)
{
    // (object obj) => (object)((Book)obj).Pages
    ParameterExpression obj = Expression.Parameter(typeof(object), "obj");
    UnaryExpression cast = Expression.Convert(obj, type);
    MemberExpression property = Expression.Property(cast, propertyName);
    UnaryExpression boxed = Expression.Convert(property, typeof(object));
    return Expression.Lambda<Func<object, object?>>(boxed, obj).Compile();
}

class Book
{
    public string Title { get; set; } = "";
    public int Pages { get; set; }
}

Serializers and object mappers use this exact trick. They use reflection once to find properties, build a reader tree for each one, compile it, and save the delegates.

Executing Expressions

After you compile, you run the delegate like any other method: calculate(1000m, 18m).

There are two other ways to run a tree:

  • DynamicInvoke on a LambdaExpression when you do not know its delegate type when you build. It takes an object[] and is slower.
  • Expression blocks let one tree hold several steps, variables and loops. You can only build them by hand.

Run a tree you only know as LambdaExpression

using System;
using System.Linq.Expressions;

LambdaExpression[] formulas =
{
    (Expression<Func<int, int>>)(n => n * n),
    (Expression<Func<int, int, int>>)((a, b) => a * b + 1),
};

foreach (LambdaExpression formula in formulas)
{
    Delegate run = formula.Compile();
    object?[] inputs = formula.Parameters.Count == 1 ? new object?[] { 7 } : new object?[] { 3, 4 };
    Console.WriteLine($"{formula} with ({string.Join(", ", inputs)}) = {run.DynamicInvoke(inputs)}");
}

Expression Blocks

A BlockExpression runs several expressions one after another, like statements inside braces. It can declare temporary variables. The value of the block is what its last expression gives.

A hand-built block with a temporary variable

using System;
using System.Linq.Expressions;

// int Discount(int price) { int cut = price / 10; Console.WriteLine(cut); return price - cut; }
ParameterExpression price = Expression.Parameter(typeof(int), "price");
ParameterExpression cut = Expression.Variable(typeof(int), "cut");
var writeLine = typeof(Console).GetMethod("WriteLine", new[] { typeof(int) })!;

BlockExpression body = Expression.Block(
    new[] { cut },
    Expression.Assign(cut, Expression.Divide(price, Expression.Constant(10))),
    Expression.Call(writeLine, cut),
    Expression.Subtract(price, cut));

var discount = Expression.Lambda<Func<int, int>>(body, price).Compile();
Console.WriteLine($"Final price: {discount(850)}");

Expression Visitors

To read or change a whole tree, you visit every node. Writing that walk by hand for 85 node types is a lot of work.

ExpressionVisitor does the walk for you. You inherit from it and override only the Visit... methods for the nodes you care about:

  • VisitBinary for +, >, && and others,
  • VisitMember for property reads,
  • VisitConstant for numbers,
  • VisitParameter for inputs,
  • VisitMethodCall for method calls.

Reading a Tree

This visitor collects every property name a lambda reads.

Find the properties a lambda reads

using System;
using System.Collections.Generic;
using System.Linq.Expressions;

Expression<Func<Employee, bool>> rule = e => e.Salary > 50000 && e.City == "Pune" || e.Years > 10;

var finder = new PropertyFinder();
finder.Visit(rule);
Console.WriteLine($"Uses: {string.Join(", ", finder.Names)}");

class PropertyFinder : ExpressionVisitor
{
    public List<string> Names { get; } = new();

    protected override Expression VisitMember(MemberExpression node)
    {
        Names.Add(node.Member.Name);
        return base.VisitMember(node);   // keep walking into child nodes
    }
}

class Employee
{
    public decimal Salary { get; set; }
    public string City { get; set; } = "";
    public int Years { get; set; }
}

Rewriting a Tree

Expression trees cannot be changed: you cannot edit a node. A visitor gives back a new node instead. ExpressionVisitor rebuilds the tree around it.

This visitor swaps every + for -.

Rewrite + into -

using System;
using System.Linq.Expressions;

Expression<Func<int, int, int>> original = (a, b) => a + b * 2 + 10;

var flipped = (Expression<Func<int, int, int>>)new PlusToMinus().Visit(original);

Console.WriteLine($"Original: {original}  -> {original.Compile()(5, 3)}");
Console.WriteLine($"Rewritten: {flipped}  -> {flipped.Compile()(5, 3)}");

class PlusToMinus : ExpressionVisitor
{
    protected override Expression VisitBinary(BinaryExpression node)
    {
        Expression left = Visit(node.Left);
        Expression right = Visit(node.Right);

        return node.NodeType == ExpressionType.Add
            ? Expression.Subtract(left, right)
            : node.Update(left, node.Conversion, right);
    }
}

The original tree stays the same. The visitor made a second, different tree.

Dynamic Query Generation

Here is the most useful real-world use of expression trees: building a filter while the program runs based on what a user picks.

Imagine a search page. The user picks a field, an operator and a value. You do not know what they will pick, so you cannot write the lambda ahead of time. You build it instead.

Build a filter from user choices

using System;
using System.Linq;
using System.Linq.Expressions;

var products = new[]
{
    new Product("Kettle", "Kitchen", 1299m),
    new Product("Mixer", "Kitchen", 3499m),
    new Product("Lamp", "Home", 899m),
    new Product("Fan", "Home", 2499m),
};

Run("Category", "==", "Kitchen");
Run("Price", ">", 1000m);
Run("Price", "<=", 899m);

void Run(string field, string op, object value)
{
    Expression<Func<Product, bool>> filter = Build<Product>(field, op, value);
    var names = products.AsQueryable().Where(filter).Select(p => p.Name);
    Console.WriteLine($"{filter,-38} => {string.Join(", ", names)}");
}

static Expression<Func<T, bool>> Build<T>(string field, string op, object value)
{
    ParameterExpression p = Expression.Parameter(typeof(T), "p");
    MemberExpression left = Expression.Property(p, field);
    ConstantExpression right = Expression.Constant(value, left.Type);

    Expression body = op switch
    {
        "==" => Expression.Equal(left, right),
        ">" => Expression.GreaterThan(left, right),
        "<" => Expression.LessThan(left, right),
        ">=" => Expression.GreaterThanOrEqual(left, right),
        "<=" => Expression.LessThanOrEqual(left, right),
        _ => throw new ArgumentException($"Unknown operator {op}"),
    };
    return Expression.Lambda<Func<T, bool>>(body, p);
}

record Product(string Name, string Category, decimal Price);

Combining Filters With And

Users usually pick multiple filters. You combine them with Expression.AndAlso, which is the tree version of &&.

Each filter has its own input p, so the combined tree must use one shared input. A small visitor swaps the inputs.

Combine two filters into one

using System;
using System.Linq;
using System.Linq.Expressions;

Expression<Func<Product, bool>> inKitchen = p => p.Category == "Kitchen";
Expression<Func<Product, bool>> cheap = x => x.Price < 2000m;

var both = And(inKitchen, cheap);
Console.WriteLine(both);

var products = new[]
{
    new Product("Kettle", "Kitchen", 1299m),
    new Product("Mixer", "Kitchen", 3499m),
    new Product("Lamp", "Home", 899m),
};
Console.WriteLine(string.Join(", ", products.AsQueryable().Where(both).Select(p => p.Name)));

static Expression<Func<T, bool>> And<T>(Expression<Func<T, bool>> first, Expression<Func<T, bool>> second)
{
    ParameterExpression shared = first.Parameters[0];
    Expression secondBody = new SwapParameter(second.Parameters[0], shared).Visit(second.Body);
    return Expression.Lambda<Func<T, bool>>(Expression.AndAlso(first.Body, secondBody), shared);
}

class SwapParameter(ParameterExpression from, ParameterExpression to) : ExpressionVisitor
{
    protected override Expression VisitParameter(ParameterExpression node) => node == from ? to : node;
}

record Product(string Name, string Category, decimal Price);

Expression Trees in LINQ

Now you see why LINQ has two sets of methods with the same names.

  • Enumerable.Where works on IEnumerable<T>. It takes a Func<T, bool> delegate and runs it on each item in memory.
  • Queryable.Where works on IQueryable<T>. It takes an Expression<Func<T, bool>> tree and does not run it right away. It adds the tree to the query.

You write the same lambda for both. The compiler picks a delegate or a tree based on which method you call.

IQueryable collects a tree instead of running code

using System;
using System.Linq;

int[] marks = { 35, 72, 91, 58, 44 };

var inMemory = marks.Where(m => m >= 50);            // IEnumerable<int>
var asQuery = marks.AsQueryable().Where(m => m >= 50) // IQueryable<int>
                                 .OrderBy(m => m);

Console.WriteLine($"Enumerable: {string.Join(", ", inMemory)}");
Console.WriteLine($"Queryable:  {string.Join(", ", asQuery)}");

Console.WriteLine("The query's tree:");
Console.WriteLine($"  {asQuery.Expression}");
Console.WriteLine($"  Root node: {asQuery.Expression.NodeType}");

asQuery.Expression holds the whole query as a tree: the source, then the Where, then the OrderBy. A query provider decides what to do with that tree. Here, the provider is the in-memory one, so it compiles and runs it. A database provider does something different.

If you save a filter in a Func<T, bool> variable and pass it to Where on an IQueryable, C# picks Enumerable.Where. The whole table is loaded into memory first, then filtered. Keep database filters as Expression<Func<T, bool>> so they stay translatable.

Expression Trees in ORM Frameworks

An ORM (Object-Relational Mapper) like Entity Framework Core lets you ask a database using LINQ. It cannot run your C# lambda inside SQL Server. So it reads the tree instead and translates each node into SQL.

With EF Core, this C# query:

var bigOrders = db.Orders
    .Where(o => o.Total > 1000 && o.City == "Pune")
    .OrderBy(o => o.Total)
    .Select(o => o.Id)
    .ToList();

becomes SQL that looks like this, which runs on the database server:

SELECT [o].[Id]
FROM [Orders] AS [o]
WHERE [o].[Total] > 1000.0 AND [o].[City] = N'Pune'
ORDER BY [o].[Total]

This needs a real database, so it is shown as plain code. But the core idea is just an ExpressionVisitor. Here is a tiny translator that turns a filter tree into a SQL WHERE clause:

A mini ORM: translate a filter into SQL

using System;
using System.Linq.Expressions;
using System.Text;

Console.WriteLine(ToSql<Order>(o => o.Total > 1000 && o.City == "Pune"));
Console.WriteLine(ToSql<Order>(o => o.City != "Delhi" || o.Total <= 500));

static string ToSql<T>(Expression<Func<T, bool>> filter)
{
    var writer = new SqlWriter();
    writer.Visit(filter.Body);
    return $"SELECT * FROM {typeof(T).Name}s WHERE {writer.Sql}";
}

class SqlWriter : ExpressionVisitor
{
    private readonly StringBuilder _sql = new();
    public string Sql => _sql.ToString();

    protected override Expression VisitBinary(BinaryExpression node)
    {
        _sql.Append('(');
        Visit(node.Left);
        _sql.Append(node.NodeType switch
        {
            ExpressionType.AndAlso => " AND ",
            ExpressionType.OrElse => " OR ",
            ExpressionType.Equal => " = ",
            ExpressionType.NotEqual => " <> ",
            ExpressionType.GreaterThan => " > ",
            ExpressionType.LessThanOrEqual => " <= ",
            _ => throw new NotSupportedException(node.NodeType.ToString()),
        });
        Visit(node.Right);
        _sql.Append(')');
        return node;
    }

    protected override Expression VisitMember(MemberExpression node)
    {
        _sql.Append($"[{node.Member.Name}]");
        return node;
    }

    protected override Expression VisitConstant(ConstantExpression node)
    {
        _sql.Append(node.Value is string s ? $"'{s.Replace("'", "''")}'" : node.Value);
        return node;
    }
}

class Order
{
    public decimal Total { get; set; }
    public string City { get; set; } = "";
}

Real ORMs handle many more node types, use SQL parameters instead of pasting values, and support joins. But the way works the same: visit the tree and write SQL for each node.

This also explains a common EF Core error. If your lambda calls your own C# method, the ORM finds a MethodCallExpression it does not know how to turn into SQL:

db.Orders.Where(o => IsVip(o.Customer)).ToList();
// InvalidOperationException: The LINQ expression '...IsVip(o.Customer)'
// could not be translated.

The ORM can only translate things it knows. Your private C# method does not exist inside the database.

Where Expression Trees Are Used

  • Entity Framework Core and other ORMs turn LINQ into SQL.
  • Dynamic filters in search pages and grids build Where clauses while the program runs.
  • Mapping libraries like AutoMapper compile fast copy code.
  • Validation libraries like FluentValidation read x => x.Email to get the property name.
  • Mocking libraries like Moq read m => m.Send(It.IsAny<string>()) to set up fakes.
  • Serializers and DI containers compile fast readers and constructors.

Common mistakes

MistakeSymptomFix
Calling a tree directlyCS0149Compile() first
Statement lambda as a treeCS0834Use a single expression
Two different inputs"variable referenced from scope"Swap inputs with a visitor
Mixed types in a hand-built nodeInvalidOperationExceptionMatch types, or use Expression.Convert
Func passed to an IQueryableWhole table loaded into memoryKeep it as Expression<Func<...>>
Compile() in a loopSlow codeCompile one time and keep it
Own method inside an EF Core query"could not be translated"Move the logic outside the query

What to Remember

  • An expression tree stores code as data that other code can read.
  • Func<...> is compiled code. Expression<Func<...>> is a tree of the same lambda.
  • Every piece of a tree is a node with a NodeType and a Type.
  • Only single-expression lambdas can become trees.
  • Build trees by hand with Expression.Parameter, Constant, Add, Property, Call and Lambda.
  • Compile() turns a tree into a delegate. It is slow, so compile one time and keep the delegate.
  • ExpressionVisitor reads or changes trees. Trees never change. Visitors give back new ones.
  • Combine filters with Expression.AndAlso and one shared input.
  • IQueryable collects a tree. A provider decides how to run it.
  • ORMs like EF Core translate trees into SQL. They can only handle what they understand.

Next: Dependency Injection