Skip to main content

Command Palette

Search for a command to run...

Building a Domain-Driven DSL with Expression Trees and Compile Time Guards in C#

Updated
5 min read
Building a Domain-Driven DSL with Expression Trees and Compile Time Guards in C#

Most people encounter expression trees for the first time through LINQ. They write a query like orders.Where(o => o.Amount > 1000) and discover that the compiler has turned their lambda into a tree of nodes representing parameters, constants, and binary expressions. Those nodes are then interpreted by LINQ-to-SQL or Entity Framework to generate SQL queries. But expression trees are not limited to LINQ. They are a general mechanism for capturing code as data, a way of describing intent rather than immediately executing it. This makes them a perfect foundation for creating domain-specific languages (DSLs), small, declarative languages embedded within C# that express concepts directly from the problem domain.

When combined with source generators or compile time guards, expression trees let us build DSLs that are both expressive and safe, eliminating whole classes of runtime errors by catching them early in the build process.

Why Build a DSL?

Large systems often revolve around repeating patterns of rules. In insurance, you might define underwriting conditions, in finance, you might define pricing formulas, in healthcare, you might define treatment eligibility criteria.If these rules are buried inside imperative code, they become difficult to read, validate, or change. A DSL provides a layer where the rules can be expressed declaratively, in language close to the domain itself, while the underlying engine handles execution, caching, and validation.

Expression trees give us a way to write these rules in C# syntax while still treating them as data. That means we can analyse them, transform them, and even persist them for later execution.

A First Example: Discount Rules

Imagine an e-commerce system where discounts depend on order size and customer status. A naive approach might hard code logic like this:

public decimal CalculateDiscount(Order order)
{
    if (order.Total > 1000 && order.Customer.IsPremium)
        return order.Total * 0.1m;
    return 0m;
}

This works, but what if marketing want to change the rules weekly? You don’t want to redeploy every time. Instead, you can model rules as expression trees:

using System.Linq.Expressions;

public static class DiscountRules
{
    public static Expression<Func<Order, decimal>> PremiumHighValueDiscount =>
        order => order.Total > 1000 && order.Customer.IsPremium
            ? order.Total * 0.1m
            : 0m;
}

Now you have a rule expressed declaratively. You can compile it into a delegate and execute it, but you can also inspect its structure.

Analysing Rules at Compile Time

Suppose the business says: “No rule should ever reference Customer.Age directly, use Customer.IsAdult instead.” With expression trees, you can enforce this as a guard.

public static void ValidateRule(Expression expression)
{
    var visitor = new ForbiddenMemberVisitor("Age");
    visitor.Visit(expression);

    if (visitor.Found)
        throw new InvalidOperationException("Rules must not reference Customer.Age directly.");
}

class ForbiddenMemberVisitor : ExpressionVisitor
{
    private readonly string _forbidden;
    public bool Found { get; private set; }

    public ForbiddenMemberVisitor(string forbidden) => _forbidden = forbidden;

    protected override Expression VisitMember(MemberExpression node)
    {
        if (node.Member.Name == _forbidden)
            Found = true;
        return base.VisitMember(node);
    }
}

By running this validator at build time with a source generator, you can prevent invalid rules from ever compiling into the system. This moves domain governance from runtime to compile time.

Combining Rules into a DSL

Rules rarely exist in isolation. Let’s say we want to allow marketing to compose multiple discount rules together. Expression trees can be combined into new expressions dynamically.

public static class RuleComposer
{
    public static Expression<Func<Order, decimal>> Or(
        Expression<Func<Order, decimal>> left,
        Expression<Func<Order, decimal>> right)
    {
        var param = Expression.Parameter(typeof(Order), "order");

        var body = Expression.Condition(
            Expression.NotEqual(left.Body, Expression.Constant(0m)),
            left.Body,
            right.Body);

        return Expression.Lambda<Func<Order, decimal>>(body, param);
    }
}

Now we can define:

var combinedRule = RuleComposer.Or(
    DiscountRules.PremiumHighValueDiscount,
    DiscountRules.PremiumLoyaltyDiscount);

The resulting expression can be compiled and executed, but it can also be inspected, logged, or serialised for analysis. This is the backbone of a DSL, small primitives combined declaratively into larger behaviours.

Persisting and Reloading Rules

Because expression trees are data, they can be serialised to JSON, stored in a database, and reloaded dynamically. A persisted rule might look like:

{
  "Rule": "PremiumHighValueDiscount",
  "Expression": "order.Total > 1000 && order.Customer.IsPremium ? order.Total * 0.1m : 0m"
}

At runtime, the engine reloads the expression, validates it against compile time guards, and executes it as a delegate. This makes your system far more adaptable without sacrificing safety.

Extending with Source Generators

Source generators make the DSL truly ergonomic. Instead of writing verbose expression tree code, you can design attributes that mark up rules and let the generator do the hard work.

[DiscountRule("PremiumHighValue")]
public static decimal PremiumHighValue(Order order) =>
    order.Total > 1000 && order.Customer.IsPremium
        ? order.Total * 0.1m
        : 0m;

A source generator can intercept [DiscountRule], capture the method body, and emit an expression tree equivalent. It can even inject validators like our ForbiddenMemberVisitor to enforce domain constraints at compile time. The result is a developer experience that feels natural, just write methods, but the system gains a formal DSL layer with safety guarantees.

Broader Applications

The same technique applies in many domains. In trading, you can model risk rules declaratively and validate them against compliance constraints. In healthcare, you can define eligibility criteria with strict compile time checks to avoid regulatory violations. In workflow engines, you can build conditionals that non-technical staff can author but that compile into verifiable code. In each case, the combination of expression trees and source generators provides the best of both worlds, flexibility for the domain experts, and safety for the engineering team.

C# has quietly grown into a language capable of hosting powerful DSLs, not by adding new syntax, but by opening up the compiler itself through expression trees and source generators. What once required runtime hacks and reflection can now be captured declaratively and validated at build time.

For systems that live or die by their rules, this is transformative. You can empower non technical teams to author rules, enforce domain constraints at compile time, and achieve the holy grail of domain driven design, a language that is both expressive and safe.