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

Search for a command to run...

Gr8 write-up!
and i really like how you showed expression trees as more than just LINQ.......especially the compile-time guards for domain rules. Super practical insight.
An authenticated user calls your API with a valid access token. The token contains the right role and the endpoint requires authorisation. Everything appears secure. The user then changes /claims/8d61

Sending text to an AI model as an image sounds like it should cost more. The application has to render the text, encode a PNG and send a much larger payload over the network. Yet for the right kind of

Applications increasingly accept URLs from outside their own code. A webhook tester calls an endpoint supplied by a customer. An image service downloads a remote avatar. A document pipeline retrieves

Some pull requests look harmless until they reach production. Then someone notices the change touched authentication, a shared response contract, an EF Core migration, and one production config value

IRC is old, plain, and surprisingly useful as a teaching tool. You open a TCP connection. You send text commands. The server sends text lines back. The connection stays open. If the server sends PING,

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.
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.
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.
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.
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.
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.
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.
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.