Interpreter Pattern Real-World Example in C#: Complete Implementation
Most interpreter pattern tutorials define an abstract expression class, parse "1 + 2", and print 3. That demonstrates the mechanics, but it won't help you the next time a product manager asks for user-configurable search filters or a rule engine that non-developers can modify. This article builds a complete interpreter pattern real-world example in C# from scratch: a search query DSL that parses expressions like status:active AND priority:high OR assignee:john and evaluates them against a collection of data objects.
By the end, you'll have compilable classes covering the full pipeline -- tokenizer, parser, expression tree, and evaluator -- along with xUnit tests and a discussion of production considerations. If you need to let users define dynamic queries, business rules, or filtering logic without recompiling your application, the interpreter pattern gives you a structured way to do it.
The Problem: Hard-Coded Search Filters
Consider a task management application where users need to filter work items by status, priority, assignee, and other fields. Without the interpreter pattern, every filter combination becomes a conditional branch in your code:
public class WorkItemFilter
{
public List<WorkItem> Filter(
List<WorkItem> items,
string? status,
string? priority,
string? assignee)
{
var results = items.AsEnumerable();
if (!string.IsNullOrEmpty(status))
{
results = results
.Where(i => i.Status == status);
}
if (!string.IsNullOrEmpty(priority))
{
results = results
.Where(i => i.Priority == priority);
}
if (!string.IsNullOrEmpty(assignee))
{
results = results
.Where(i => i.Assignee == assignee);
}
return results.ToList();
}
}
This code has several problems that get worse as requirements grow. Each new field means adding another parameter and another conditional block. Users can't combine filters with OR logic -- the code only supports AND. Negation ("everything except high priority") requires yet another set of parameters. Nested expressions like (status:active OR status:blocked) AND priority:high are impossible without a complete rewrite.
The real cost shows up at scale. If you have dozens of filterable fields and users need flexible Boolean logic, you're looking at a combinatorial explosion of filter methods. The interpreter pattern solves this by letting users express their queries in a small domain-specific language, parsing that language into an expression tree, and evaluating the tree against each item. The filter logic lives in the query string, not in your C# code.
Designing the Domain Model
Before building the interpreter, we need the data it will query. Here's a simple WorkItem class that represents a task in our system:
public sealed class WorkItem
{
public required string Id { get; init; }
public required string Title { get; init; }
public required string Status { get; init; }
public required string Priority { get; init; }
public required string Assignee { get; init; }
public string GetField(string fieldName)
{
return fieldName.ToLowerInvariant() switch
{
"id" => Id,
"title" => Title,
"status" => Status,
"priority" => Priority,
"assignee" => Assignee,
_ => throw new ArgumentException(
$"Unknown field: {fieldName}")
};
}
}
The GetField method is important. It lets our interpreter resolve field names dynamically at evaluation time without hardcoding property access into each expression class. This is similar to how the facade pattern provides a unified entry point into a subsystem -- GetField gives our interpreter a single way to access any property on the work item.
The Expression Hierarchy
The interpreter pattern's core is a hierarchy of expression classes, each implementing a shared interface. Every node in the expression tree knows how to evaluate itself against a work item and return true or false. If you've worked with the composite pattern, this structure will look familiar -- composite expressions like AndExpression contain child expressions, while terminal expressions like ComparisonExpression are the leaves.
Here's the shared interface:
public interface IFilterExpression
{
bool Evaluate(WorkItem item);
}
ComparisonExpression: The Terminal Expression
The leaf node of our interpreter pattern expression tree compares a field's value to an expected value:
public sealed class ComparisonExpression
: IFilterExpression
{
private readonly string _fieldName;
private readonly string _expectedValue;
public ComparisonExpression(
string fieldName,
string expectedValue)
{
_fieldName = fieldName
?? throw new ArgumentNullException(
nameof(fieldName));
_expectedValue = expectedValue
?? throw new ArgumentNullException(
nameof(expectedValue));
}
public bool Evaluate(WorkItem item)
{
var actual = item.GetField(_fieldName);
return string.Equals(
actual,
_expectedValue,
StringComparison.OrdinalIgnoreCase);
}
}
The comparison is case-insensitive, which is typical for user-facing search queries. This class is the terminal expression in the interpreter pattern -- it doesn't contain other expressions. It directly interprets a field:value pair.
AndExpression: Logical Conjunction
public sealed class AndExpression
: IFilterExpression
{
private readonly IFilterExpression _left;
private readonly IFilterExpression _right;
public AndExpression(
IFilterExpression left,
IFilterExpression right)
{
_left = left
?? throw new ArgumentNullException(
nameof(left));
_right = right
?? throw new ArgumentNullException(
nameof(right));
}
public bool Evaluate(WorkItem item)
{
return _left.Evaluate(item)
&& _right.Evaluate(item);
}
}
OrExpression: Logical Disjunction
public sealed class OrExpression
: IFilterExpression
{
private readonly IFilterExpression _left;
private readonly IFilterExpression _right;
public OrExpression(
IFilterExpression left,
IFilterExpression right)
{
_left = left
?? throw new ArgumentNullException(
nameof(left));
_right = right
?? throw new ArgumentNullException(
nameof(right));
}
public bool Evaluate(WorkItem item)
{
return _left.Evaluate(item)
|| _right.Evaluate(item);
}
}
NotExpression: Logical Negation
public sealed class NotExpression
: IFilterExpression
{
private readonly IFilterExpression _inner;
public NotExpression(IFilterExpression inner)
{
_inner = inner
?? throw new ArgumentNullException(
nameof(inner));
}
public bool Evaluate(WorkItem item)
{
return !_inner.Evaluate(item);
}
}
Notice how each composite expression delegates to its children. AndExpression short-circuits -- if the left side is false, the right side never evaluates. This is the same recursive structure that makes the composite pattern powerful: you can nest expressions to arbitrary depth, and evaluation walks the tree without the caller knowing the structure.
The Tokenizer: Breaking Input Into Tokens
Before we can parse a query string into an expression tree, we need to break the raw text into meaningful tokens. This is the lexical analysis step -- the tokenizer reads characters and produces a sequence of typed tokens.
public enum TokenType
{
Field,
Value,
Colon,
And,
Or,
Not,
LeftParen,
RightParen,
End
}
public sealed record Token(
TokenType Type,
string Text);
The tokenizer itself scans the input string character by character:
public sealed class QueryTokenizer
{
public List<Token> Tokenize(string input)
{
var tokens = new List<Token>();
int pos = 0;
while (pos < input.Length)
{
if (char.IsWhiteSpace(input[pos]))
{
pos++;
continue;
}
if (input[pos] == '(')
{
tokens.Add(new Token(
TokenType.LeftParen, "("));
pos++;
continue;
}
if (input[pos] == ')')
{
tokens.Add(new Token(
TokenType.RightParen, ")"));
pos++;
continue;
}
if (input[pos] == ':')
{
tokens.Add(new Token(
TokenType.Colon, ":"));
pos++;
continue;
}
var word = ReadWord(input, ref pos);
if (word.Equals(
"AND",
StringComparison.OrdinalIgnoreCase))
{
tokens.Add(new Token(
TokenType.And, word));
}
else if (word.Equals(
"OR",
StringComparison.OrdinalIgnoreCase))
{
tokens.Add(new Token(
TokenType.Or, word));
}
else if (word.Equals(
"NOT",
StringComparison.OrdinalIgnoreCase))
{
tokens.Add(new Token(
TokenType.Not, word));
}
else if (tokens.Count > 0
&& tokens[^1].Type == TokenType.Colon)
{
tokens.Add(new Token(
TokenType.Value, word));
}
else
{
tokens.Add(new Token(
TokenType.Field, word));
}
}
tokens.Add(new Token(TokenType.End, ""));
return tokens;
}
private static string ReadWord(
string input,
ref int pos)
{
int start = pos;
while (pos < input.Length
&& !char.IsWhiteSpace(input[pos])
&& input[pos] != ':'
&& input[pos] != '('
&& input[pos] != ')')
{
pos++;
}
return input[start..pos];
}
}
The tokenizer handles whitespace, parentheses, and the colon separator. Keywords like AND, OR, and NOT are recognized as operators. Everything else is classified as either a field name or a value based on its position relative to the colon token. This is where we draw the line between lexical analysis and parsing -- the tokenizer doesn't understand the grammar, it just classifies chunks of text.
If you're thinking this looks like a simplified version of what language compilers do, you're right. The interpreter pattern operates at a smaller scale, but the pipeline is the same: tokenize, parse, evaluate. Think of how the chain of responsibility pattern passes requests through a chain of handlers -- our pipeline passes text through a chain of transformation stages.
The Parser: Building the Expression Tree
The parser consumes the token list and builds an IFilterExpression tree using recursive descent. This is where the interpreter pattern's grammar rules live. Our grammar supports operator precedence: NOT binds tightest, then AND, then OR. Parentheses override the default precedence.
public sealed class QueryParser
{
private List<Token> _tokens = new();
private int _pos;
public IFilterExpression Parse(List<Token> tokens)
{
_tokens = tokens
?? throw new ArgumentNullException(
nameof(tokens));
_pos = 0;
var expression = ParseOr();
if (Current().Type != TokenType.End)
{
throw new FormatException(
$"Unexpected token: " +
$"'{Current().Text}' " +
$"at position {_pos}");
}
return expression;
}
private Token Current()
{
return _pos < _tokens.Count
? _tokens[_pos]
: new Token(TokenType.End, "");
}
private Token Consume(TokenType expected)
{
var token = Current();
if (token.Type != expected)
{
throw new FormatException(
$"Expected {expected} but " +
$"found {token.Type} " +
$"('{token.Text}') " +
$"at position {_pos}");
}
_pos++;
return token;
}
private IFilterExpression ParseOr()
{
var left = ParseAnd();
while (Current().Type == TokenType.Or)
{
_pos++;
var right = ParseAnd();
left = new OrExpression(left, right);
}
return left;
}
private IFilterExpression ParseAnd()
{
var left = ParseUnary();
while (Current().Type == TokenType.And)
{
_pos++;
var right = ParseUnary();
left = new AndExpression(left, right);
}
return left;
}
private IFilterExpression ParseUnary()
{
if (Current().Type == TokenType.Not)
{
_pos++;
var inner = ParseUnary();
return new NotExpression(inner);
}
return ParsePrimary();
}
private IFilterExpression ParsePrimary()
{
if (Current().Type == TokenType.LeftParen)
{
_pos++;
var expression = ParseOr();
Consume(TokenType.RightParen);
return expression;
}
var field = Consume(TokenType.Field);
Consume(TokenType.Colon);
var value = Consume(TokenType.Value);
return new ComparisonExpression(
field.Text,
value.Text);
}
}
The parser uses a standard recursive descent approach. ParseOr calls ParseAnd, which calls ParseUnary, which calls ParsePrimary. This call chain naturally encodes operator precedence. Parenthesized subexpressions restart the precedence chain from ParseOr, allowing users to override the default binding.
Each Parse* method returns an IFilterExpression. The parser doesn't evaluate anything -- it builds a tree of expression objects that can be evaluated later against any number of work items. This separation between parsing and evaluation is a key benefit of the interpreter pattern. You parse once and evaluate many times.
Putting It All Together: The Query Engine
Now we combine the tokenizer, parser, and expression tree into a single entry point. This is where the facade pattern idea applies -- the QueryEngine hides the three-step pipeline behind a simple method call:
public sealed class QueryEngine
{
private readonly QueryTokenizer _tokenizer;
private readonly QueryParser _parser;
public QueryEngine(
QueryTokenizer tokenizer,
QueryParser parser)
{
_tokenizer = tokenizer
?? throw new ArgumentNullException(
nameof(tokenizer));
_parser = parser
?? throw new ArgumentNullException(
nameof(parser));
}
public IFilterExpression Compile(string query)
{
var tokens = _tokenizer.Tokenize(query);
return _parser.Parse(tokens);
}
public List<WorkItem> Execute(
string query,
IEnumerable<WorkItem> items)
{
var expression = Compile(query);
return items
.Where(item => expression.Evaluate(item))
.ToList();
}
}
The Compile method returns the parsed expression tree, which callers can cache and reuse. The Execute method is a convenience that compiles and evaluates in one step. Here's how it looks in practice:
var tokenizer = new QueryTokenizer();
var parser = new QueryParser();
var engine = new QueryEngine(tokenizer, parser);
var workItems = new List<WorkItem>
{
new()
{
Id = "WI-1",
Title = "Fix login bug",
Status = "active",
Priority = "high",
Assignee = "john"
},
new()
{
Id = "WI-2",
Title = "Add dark mode",
Status = "backlog",
Priority = "low",
Assignee = "jane"
},
new()
{
Id = "WI-3",
Title = "Database migration",
Status = "active",
Priority = "high",
Assignee = "jane"
},
new()
{
Id = "WI-4",
Title = "Update docs",
Status = "done",
Priority = "low",
Assignee = "john"
}
};
// Simple query
var active = engine.Execute(
"status:active",
workItems);
// Returns WI-1, WI-3
// Compound query with AND
var activeHigh = engine.Execute(
"status:active AND priority:high",
workItems);
// Returns WI-1, WI-3
// Compound query with OR
var johnOrHigh = engine.Execute(
"assignee:john OR priority:high",
workItems);
// Returns WI-1, WI-3, WI-4
// Negation
var notDone = engine.Execute(
"NOT status:done",
workItems);
// Returns WI-1, WI-2, WI-3
// Parenthesized grouping
var grouped = engine.Execute(
"(status:active OR status:backlog) AND priority:high",
workItems);
// Returns WI-1, WI-3
Users can now build arbitrarily complex queries without any changes to C# code. Adding a new filterable field only requires updating the GetField method on WorkItem. The interpreter pattern gives us a clean separation: the grammar defines what queries are valid, the parser translates strings to expression trees, and the expression classes handle evaluation.
Registering With Dependency Injection
If you're building this into a larger application, you'll want to register the query engine components with your DI container. If you're not familiar with how IServiceCollection works in C#, it's the standard way to wire up dependencies in .NET. Here's a registration method that follows inversion of control principles:
using Microsoft.Extensions.DependencyInjection;
public static class QueryServiceExtensions
{
public static IServiceCollection AddQueryEngine(
this IServiceCollection services)
{
services.AddSingleton<QueryTokenizer>();
services.AddSingleton<QueryParser>();
services.AddSingleton<QueryEngine>();
return services;
}
}
All three classes are stateless during the compile/execute flow, so singleton lifetime is appropriate. The QueryParser does hold mutable state during a single Parse call, but each call resets it. If you need thread-safe parsing, consider making the parser create a new internal state object per call or using a transient lifetime instead.
Unit Tests
Testing the interpreter pattern requires covering each layer independently. We test the tokenizer, the parser, individual expressions, and the full pipeline. Here are xUnit tests that verify the critical behaviors:
using Xunit;
public class QueryTokenizerTests
{
private readonly QueryTokenizer _tokenizer = new();
[Fact]
public void Tokenize_SimpleFieldValue_ProducesTokens()
{
var tokens = _tokenizer.Tokenize(
"status:active");
Assert.Equal(4, tokens.Count);
Assert.Equal(TokenType.Field, tokens[0].Type);
Assert.Equal("status", tokens[0].Text);
Assert.Equal(TokenType.Colon, tokens[1].Type);
Assert.Equal(TokenType.Value, tokens[2].Type);
Assert.Equal("active", tokens[2].Text);
Assert.Equal(TokenType.End, tokens[3].Type);
}
[Fact]
public void Tokenize_WithAndOperator_RecognizesKeyword()
{
var tokens = _tokenizer.Tokenize(
"status:active AND priority:high");
Assert.Equal(TokenType.Field, tokens[0].Type);
Assert.Equal(TokenType.Colon, tokens[1].Type);
Assert.Equal(TokenType.Value, tokens[2].Type);
Assert.Equal(TokenType.And, tokens[3].Type);
Assert.Equal(TokenType.Field, tokens[4].Type);
}
[Fact]
public void Tokenize_WithParentheses_ProducesParenTokens()
{
var tokens = _tokenizer.Tokenize(
"(status:active)");
Assert.Equal(TokenType.LeftParen, tokens[0].Type);
Assert.Equal(TokenType.Field, tokens[1].Type);
Assert.Equal(TokenType.Colon, tokens[2].Type);
Assert.Equal(TokenType.Value, tokens[3].Type);
Assert.Equal(TokenType.RightParen, tokens[4].Type);
}
[Fact]
public void Tokenize_NotOperator_RecognizesKeyword()
{
var tokens = _tokenizer.Tokenize(
"NOT status:done");
Assert.Equal(TokenType.Not, tokens[0].Type);
Assert.Equal(TokenType.Field, tokens[1].Type);
}
}
public class ExpressionTests
{
private static WorkItem CreateItem(
string status = "active",
string priority = "high",
string assignee = "john")
{
return new WorkItem
{
Id = "WI-1",
Title = "Test item",
Status = status,
Priority = priority,
Assignee = assignee
};
}
[Fact]
public void ComparisonExpression_MatchingValue_ReturnsTrue()
{
var expr = new ComparisonExpression(
"status", "active");
Assert.True(
expr.Evaluate(CreateItem(status: "active")));
}
[Fact]
public void ComparisonExpression_NonMatchingValue_ReturnsFalse()
{
var expr = new ComparisonExpression(
"status", "done");
Assert.False(
expr.Evaluate(CreateItem(status: "active")));
}
[Fact]
public void ComparisonExpression_CaseInsensitive()
{
var expr = new ComparisonExpression(
"status", "ACTIVE");
Assert.True(
expr.Evaluate(CreateItem(status: "active")));
}
[Fact]
public void AndExpression_BothTrue_ReturnsTrue()
{
var expr = new AndExpression(
new ComparisonExpression("status", "active"),
new ComparisonExpression("priority", "high"));
Assert.True(expr.Evaluate(CreateItem()));
}
[Fact]
public void AndExpression_OneFalse_ReturnsFalse()
{
var expr = new AndExpression(
new ComparisonExpression("status", "active"),
new ComparisonExpression("priority", "low"));
Assert.False(expr.Evaluate(CreateItem()));
}
[Fact]
public void OrExpression_OneFalse_ReturnsTrue()
{
var expr = new OrExpression(
new ComparisonExpression("status", "done"),
new ComparisonExpression("priority", "high"));
Assert.True(expr.Evaluate(CreateItem()));
}
[Fact]
public void NotExpression_InvertsResult()
{
var expr = new NotExpression(
new ComparisonExpression("status", "done"));
Assert.True(
expr.Evaluate(CreateItem(status: "active")));
}
}
public class QueryEngineTests
{
private readonly QueryEngine _engine = new(
new QueryTokenizer(),
new QueryParser());
private readonly List<WorkItem> _items = new()
{
new()
{
Id = "WI-1",
Title = "Fix login bug",
Status = "active",
Priority = "high",
Assignee = "john"
},
new()
{
Id = "WI-2",
Title = "Add dark mode",
Status = "backlog",
Priority = "low",
Assignee = "jane"
},
new()
{
Id = "WI-3",
Title = "Database migration",
Status = "active",
Priority = "high",
Assignee = "jane"
}
};
[Fact]
public void Execute_SimpleQuery_FiltersCorrectly()
{
var results = _engine.Execute(
"status:active", _items);
Assert.Equal(2, results.Count);
Assert.All(results, item =>
Assert.Equal("active", item.Status));
}
[Fact]
public void Execute_AndQuery_FiltersCorrectly()
{
var results = _engine.Execute(
"status:active AND assignee:jane",
_items);
Assert.Single(results);
Assert.Equal("WI-3", results[0].Id);
}
[Fact]
public void Execute_OrQuery_FiltersCorrectly()
{
var results = _engine.Execute(
"assignee:john OR priority:low",
_items);
Assert.Equal(2, results.Count);
}
[Fact]
public void Execute_NotQuery_FiltersCorrectly()
{
var results = _engine.Execute(
"NOT status:active", _items);
Assert.Single(results);
Assert.Equal("backlog", results[0].Status);
}
[Fact]
public void Execute_GroupedQuery_RespectsParentheses()
{
var results = _engine.Execute(
"(assignee:john OR assignee:jane) AND priority:high",
_items);
Assert.Equal(2, results.Count);
Assert.All(results, item =>
Assert.Equal("high", item.Priority));
}
[Fact]
public void Execute_NestedNot_WorksCorrectly()
{
var results = _engine.Execute(
"NOT NOT status:active", _items);
Assert.Equal(2, results.Count);
}
[Fact]
public void Compile_InvalidQuery_ThrowsFormatException()
{
Assert.Throws<FormatException>(() =>
_engine.Compile("AND status:active"));
}
}
These tests cover the most important scenarios: simple field matching, Boolean operators, negation, parenthesized grouping, double negation, and invalid input handling. The layered test approach -- tokenizer tests, expression tests, and integration tests -- means you can pinpoint failures quickly. If an integration test fails but the expression tests pass, the problem is in the parser.
Production Considerations
The implementation above handles the core interpreter pattern well, but production systems need a few more considerations.
Error Handling and Input Validation
The parser throws FormatException for syntax errors, but the error messages could be more user-friendly. Consider wrapping parse errors in a custom exception that includes the character position and a suggestion:
public sealed class QueryParseException : Exception
{
public int Position { get; }
public string Query { get; }
public QueryParseException(
string message,
string query,
int position)
: base(message)
{
Query = query;
Position = position;
}
}
You should also validate field names against an allow-list. The current GetField method throws on unknown fields, but a production system should catch this during parsing rather than during evaluation. Validating early means users get a clear error message before any evaluation begins.
Security
If the query DSL is exposed to end users through an API, treat query strings as untrusted input. The current implementation is naturally safe against injection attacks because the tokenizer only recognizes a fixed set of token types and the parser enforces a strict grammar. There's no way to break out of the interpreter and execute arbitrary code.
However, you should guard against denial-of-service through deeply nested expressions. A query with thousands of nested parentheses could overflow the stack during recursive descent parsing. Set a maximum expression depth and reject queries that exceed it. This is conceptually similar to how the proxy pattern can add protective guardrails around an underlying service.
Performance With Large Datasets
The interpreter pattern evaluates each work item by walking the expression tree. For small to medium datasets (thousands of items), this is perfectly fine. For larger datasets, consider these optimizations:
- Compile to a delegate: Instead of walking the tree per item, compile the expression into a
Func<WorkItem, bool>usingSystem.Linq.Expressions. This eliminates the virtual dispatch overhead ofEvaluatecalls. - Index-based filtering: For frequently queried fields, build in-memory indexes (dictionaries keyed by field value) and resolve simple comparisons via index lookup before falling back to tree evaluation for complex expressions.
- Short-circuit evaluation: The current
AndExpressionandOrExpressionalready short-circuit. Make sure your most selective conditions appear on the left side of AND expressions and your broadest conditions on the left side of OR expressions.
If you're iterating over large result sets after filtering, the iterator pattern can help you process results lazily without materializing the entire filtered list into memory.
Extending the Grammar
The interpreter pattern makes it straightforward to add new operators. Want a "contains" operator for substring matching? Add a new token type, update the tokenizer to recognize a ~ operator, and create a ContainsExpression class. Want comparison operators like > and < for numeric fields? Same approach -- new token type, new expression class, update the parser. Each extension is isolated to its own class, following the open-closed principle.
You could also factor the interpreter pattern into a command pattern architecture. Each parsed expression becomes a command object that encapsulates a filter operation. This lets you log queries, queue them for batch execution, or implement undo functionality where users can refine their search step by step.
Frequently Asked Questions
When should I use the interpreter pattern instead of LINQ?
Use the interpreter pattern when the query logic comes from user input at runtime rather than being known at compile time. LINQ expressions are compiled into your C# code and can't be modified without redeployment. The interpreter pattern lets users define queries in a DSL that your application parses and evaluates dynamically. If your filtering logic is fixed and known at compile time, stick with LINQ -- it's simpler and the compiler catches errors for you.
How does the interpreter pattern relate to the composite pattern?
The interpreter pattern uses the same recursive tree structure as the composite pattern. AndExpression and OrExpression are composite nodes that contain child expressions, while ComparisonExpression is a leaf node. The difference is intent: the composite pattern focuses on treating individual objects and compositions uniformly, while the interpreter pattern focuses on defining a grammar and evaluating sentences in that grammar. In practice, the expression hierarchy in an interpreter pattern implementation is a composite.
Can I use the interpreter pattern for validation rules?
Yes. Replace the Evaluate(WorkItem) method with something like Evaluate(FormData) and your expression classes become validation rules. A query like NOT email:empty AND age:valid could validate form submissions using the same tokenizer-parser-expression pipeline. The interpreter pattern works well for any scenario where you need configurable Boolean logic over a set of named fields.
How do I handle quoted strings with spaces in values?
Update the ReadWord method in the tokenizer to detect opening quote characters. When the tokenizer encounters a " character, it reads everything until the closing " as a single token value. This lets users write queries like assignee:"john smith" where the value contains whitespace. The parser and expression classes need no changes at all -- the tokenizer handles it entirely.
What are alternatives to recursive descent parsing?
Recursive descent is the simplest parsing approach and works well for small DSLs like our search query language. For more complex grammars, you could use parser combinator libraries like Sprache or Superpower that let you define grammars declaratively in C#. For even larger languages, parser generators like ANTLR produce parsers from formal grammar specifications. The interpreter pattern itself doesn't prescribe how you build the expression tree -- it only defines the tree structure and evaluation.
Is the interpreter pattern the same as the expression tree pattern?
They overlap significantly. The interpreter pattern from the Gang of Four book emphasizes a grammar and evaluation method on each node. The expression tree pattern (as in System.Linq.Expressions) focuses on representing code as data that can be inspected, transformed, or compiled. In practice, when you implement the interpreter pattern in C#, you're building an expression tree. The difference is mostly in vocabulary and emphasis rather than structure.
How do I add support for different comparison operators?
Extend the ComparisonExpression to accept an operator parameter. Define an enum with values like Equals, NotEquals, Contains, GreaterThan, and LessThan. Update the tokenizer to recognize operator symbols like !=, ~, >, and <. The parser creates ComparisonExpression instances with the appropriate operator. The Evaluate method switches on the operator to apply the correct comparison logic. Each new operator is a case in the switch statement -- no new classes needed.
Wrapping Up This Interpreter Pattern Real-World Example
This implementation shows the interpreter pattern solving a genuine production problem -- dynamic search filtering that users can configure without code changes. We started with a rigid filter method that required a new parameter for every field and ended with a flexible query DSL that supports field comparisons, Boolean operators, negation, and parenthesized grouping.
The three-stage pipeline -- tokenize, parse, evaluate -- keeps each concern isolated. The tokenizer handles lexical analysis. The parser enforces grammar rules and builds the expression tree. The expression classes handle evaluation. This separation means you can extend the grammar, swap out the tokenizer for a more sophisticated one, or optimize evaluation without touching the other stages.
Take the QueryEngine, QueryTokenizer, QueryParser, and expression classes from this article, wire them into your dependency injection container, and you have a reusable search engine that scales from simple field filters to complex Boolean queries. The interpreter pattern keeps your grammar rules explicit, your evaluation logic composable, and your application flexible enough to handle whatever query your users dream up.

