DEV Community

Cover image for C# DIY Compiler with DDD and Clean Architecture
StepOne
StepOne

Posted on

C# DIY Compiler with DDD and Clean Architecture

Compiler projects are often presented as pipelines of algorithms: lexing, parsing, analysis, and code generation. Once the project grows, however, the harder problem is deciding which concepts own which behavior and how to stop one phase from leaking into every other phase.

This article is a two-year case study from my pet project, the HydraScript language interpreter. It shows where Domain-Driven Design and Clean Architecture clarified the compiler's boundaries, where my first abstractions failed, and why separate .NET projects became enforceable architectural constraints rather than folder decoration.

The project source code is available on GitHub.

What Is HydraScript?

The GitHub repository describes it as:

TypeScript & Go inspired open-source public research project written in C#

hydrascript logo

That description reflects the project at the time of writing. To explain how it got there, we need to go back a little.

It was 2022, my fourth year as an undergraduate at Bauman Moscow State Technical University. I was finishing my degree in the IU-9 department, whose specialization is “Analysis, Generation, and Transformation of Program Code.” Building new programming languages is part of the curriculum. The department even has its own GitHub organization, and it is well worth exploring.

Graduation naturally required a thesis project. Compiler construction had fascinated me throughout my studies because it brings together so much fundamental computer-science theory in one practical field.

My assigned topic was an interpreter for a subset of JavaScript defined by ECMA-262. If you have ever looked at JavaScript and asked, “Why does it behave like that?!”, I recommend reading the standard.

The resulting program works as follows.

  • It receives a source-code fragment:
  function abs(x: number) {
      if (x < 0)
          return -x
      return x
  }

  print(abs(-10) as string)

Enter fullscreen mode Exit fullscreen mode
  • The lexer turns the fragment into a token stream:
  Keyword (1, 1)-(1, 9): function
  Ident (1, 10)-(1, 13): abs
  LeftParen (1, 13)-(1, 14): (
  Ident (1, 14)-(1, 15): x
  Colon (1, 15)-(1, 16): :
  Ident (1, 17)-(1, 23): number
  RightParen (1, 23)-(1, 24): )
  LeftCurl (1, 25)-(1, 26): {
  Keyword (2, 5)-(2, 7): if
  LeftParen (2, 8)-(2, 9): (
  Ident (2, 9)-(2, 10): x
  Operator (2, 11)-(2, 12): <
  IntegerLiteral (2, 13)-(2, 14): 0
  RightParen (2, 14)-(2, 15): )
  Keyword (3, 9)-(3, 15): return
  Operator (3, 16)-(3, 17): -
  Ident (3, 17)-(3, 18): x
  Keyword (4, 5)-(4, 11): return
  Ident (4, 12)-(4, 13): x
  RightCurl (5, 1)-(5, 2): }
  Ident (7, 1)-(7, 6): print
  LeftParen (7, 6)-(7, 7): (
  Ident (7, 7)-(7, 10): abs
  LeftParen (7, 10)-(7, 11): (
  Operator (7, 11)-(7, 12): -
  IntegerLiteral (7, 12)-(7, 14): 10
  RightParen (7, 14)-(7, 15): )
  Keyword (7, 16)-(7, 18): as
  Ident (7, 19)-(7, 25): string
  RightParen (7, 25)-(7, 26): )
  EOP

Enter fullscreen mode Exit fullscreen mode
  • The parser turns those tokens into an abstract syntax tree according to the grammar:

ast example

  • From there, the path depends on your goals and implementation. You can interpret the AST directly, or treat it as the first intermediate representation (IR), perform static analysis, and then generate code:
    Goto End_abs
  Start_abs:
    BeginFunction abs
    PopParameter x
    _t1648079328 = x < 0
    IfNot _t1648079328 Goto End_if_else_53517805
    _t1783762424 = -x
    Return _t1783762424
    Goto End_if_else_53517805
  Start_if_else_53517805:
    BeginCondition if_else_53517805
  End_if_else_53517805:
    EndCondition if_else_53517805
    Return x
  End_abs:
    EndFunction abs
    _t196877937 = -10
    PushParameter _t196877937
    _t3435491484 = Call abs
    _t4127716996 = _t3435491484 as string
    Print _t4127716996
    End
Enter fullscreen mode Exit fullscreen mode
  • If code generation is present, you can optimize the instruction stream first or execute it immediately on an embedded virtual machine:

cli run screenshot

Now let’s return to the development history of the HydraScript interpreter.

How the Compiler's Goal Shaped Its Architecture

The first iteration was written as a graduation project.

Every developer under time pressure knows the two rules:

  1. If it works, do not touch it.
  2. Good enough meme

My case was no exception.

Everything was rushed. Bugs piled on bugs, while workarounds and reinventions patched the gaps well enough to get the project over the line.

There was no architecture at first. The interpreter executed the AST directly, but that design could not support functions, so I rewrote it around a minimal code-generation pipeline.

My ambitions grew along the way: “I’ll build an open-source JavaScript killer, and everyone will switch to my language.” I added a type system, basic blocks, a CFG, and optimizations. Within certain limitations, it worked.

Then I defended the thesis and had time to tidy the project up. Balancing it with a full-time job was difficult, however, and reality made it clear that I was not going to defeat JavaScript. The project needed a new direction.

I still wanted to maintain and develop it, but over the long term. I needed to be able to leave it alone for months, return, and still understand what I had written.

Because the source was public, other developers should also be able to understand it without deep prior knowledge of compiler construction.

Clean code and comprehensibility meant giving up on peak performance or serious production use for this interpreted language. That was acceptable: its educational mission—public, approachable reverse engineering—was valuable in its own right.

Applying Domain-Driven Design to a Compiler

DDD was popular on my team at the time, and I fell down the rabbit hole: blue books, red books, Merson’s talk, and everything around them.

It looked like exactly what I needed. A ubiquitous language shared by the compiler domain and the code could make the project easier to understand.

As I modeled the interpreter’s domain, I divided it into three subdomains:

  1. FrontEnd — entities related to syntax analysis.
  2. IR — constructs for working with the intermediate representation, used here during static analysis.
  3. BackEnd — entities produced during code generation and related to direct code execution.

That division has stood the test of time. It is semantically coherent and repeatedly pushed the architecture in the right direction.

At that point I knew little about architecture, and the project was a conventional two-layer application whose boundaries existed only as folders.

old arch

You can find that version in the before branch. I kept it as a reminder of what not to do.

This pet project taught me an important architectural lesson:

Architecture proves itself when you add new functionality. If its evolution follows the right trajectory, implementations of difficult features begin to suggest themselves. They are not repetitions of what already exists, but new pieces that still fit the overall design.

The early DDD decision established that trajectory for the months of development and refactoring that followed.

Applying Clean Architecture to the Compiler

Saying “this project uses DDD” sounds reassuring. What could go wrong? Practically everything.

Architectural Mistakes and Their Cost

The first critical mistake was an intrusive approach to the AST. Operations were placed inside the tree as virtual methods and overridden down the hierarchy. Virtual methods are not inherently bad, but they do not fit every operation.

When an operation requires external context, separating the operation from the data in a more functional style is worth considering. The entity cannot encapsulate information it does not own.

Trying to put everything inside the hierarchy had several consequences. The system became hard to extend, and the cost of new features grew exponentially. Static analysis developed difficult bugs and rejected valid programs, so I disabled it. Code generation assigned incorrect addresses to instructions, which sent the virtual machine into infinite loops or caused runtime exceptions.

tac example

The Visitor pattern helped enormously. It was designed for processing structures that broadly belong to the Composite family.

Here is the method that generated instructions for a unary expression before Visitor:

public override List<Instruction> ToInstructions(int start, string temp)
{
    var instructions = new List<Instruction>();
    (IValue left, IValue right) right = (null, null);
    if (_expression.Primary())
    {
        right.right = ((PrimaryExpression)_expression).ToValue();
    }
    else
    {
        instructions.AddRange(_expression.ToInstructions(start, temp));
        if (_expression is MemberExpression member && member.Any())
        {
            var i = start + instructions.Count;
            var dest = "_t" + i;
            var src = instructions.Any()
                ? instructions.OfType<Simple>().Last().Left
                : member.Id;
            var instruction = member.AccessChain.Tail switch
            {
                DotAccess dot => new Simple(dest, (new Name(src), new Constant(dot.Id, dot.Id)), ".", i),
                IndexAccess index => new Simple(dest, (new Name(src), index.Expression.ToValue()), "[]", i),
                _ => throw new NotImplementedException()
            };
            instructions.Add(instruction);
        }
        right.right = new Name(instructions.OfType<Simple>().Last().Left);
    }
    var number = instructions.Any() ? instructions.Last().Number + 1 : start;
    instructions.Add(new Simple(
        temp + number, right, _operator, number
    ));
    return instructions;
}
Enter fullscreen mode Exit fullscreen mode

And here is the same operation after applying the pattern:

public AddressedInstructions Visit(UnaryExpression visitable)
{
    if (visitable.Expression is PrimaryExpression primary)
        return [new Simple(visitable.Operator, _valueDtoConverter.Convert(primary.ToValueDto()))];
    var result = visitable.Expression.Accept(This);
    var last = new Name(result.OfType<Simple>().Last().Left!);
    result.Add(new Simple(visitable.Operator, last));
    return result;
}
Enter fullscreen mode Exit fullscreen mode

Honestly, I can no longer explain exactly how the first version works. The second is almost self-documenting; it reads like a sequence of English sentences.

Getting there required several unusual and interesting engineering solutions.

AddressedInstructions

Moving code generation to Visitor was the biggest challenge in separating operations from data. The old method accepted an int start parameter: an offset into the instruction list that allowed it to calculate integer addresses.

That addressing system eventually failed, and the parameter no longer had a natural place in the new design. A global counter would be unsafe. A functional visitor would be easier to read and debug because you could step through the call chain and locate the source of an invalid result.

I therefore rebuilt instruction addressing from scratch. The result was both useful and technically interesting: a data structure that recalculates addresses in O(1) when its elements change.

What does that mean?

Previously, every instruction in a list had a numeric address:

0: a = 0 1: x = 2 2: y = a 3: print x

Instructions 0 and 2 are dead code and can be removed, leaving:

1: x = 2 3: print x

The virtual machine now fails. It starts at zero, and its default rule for calculating the next address is current address plus one. It cannot traverse the remaining instructions until we recalculate the addresses:

0: x = 2 1: print x

The goal was to avoid walking an entire array—or more—just to shift indices. Any deletion, insertion, or move should immediately yield correct addresses.

That required an abstraction for the concept of an address and a data structure that satisfies the requirements.

Visitor.NET

Adopting Visitor also required choosing among several implementations. If you are unfamiliar with the alternatives, Dmitri Nesteruk’s talk referenced in the original article is a good starting point.

The acyclic variant interested me because it lets a class declare which elements it visits:

public class TypeSystemLoader : IVisitor,
    IVisitor<ScriptBody>,
    IVisitor<AbstractSyntaxTreeNode>,
    IVisitor<TypeDeclaration>
{
    // ...
}
Enter fullscreen mode Exit fullscreen mode

But I strongly disliked the explicit type check and cast required by the visitable object:

public class ScriptBody : AbstractSyntaxTreeNode
{
    public override void Accept(IVisitor visitor)
    {
        if (visitor is IVisitor<ScriptBody> typed)
            typed.Visit(this);
    }
}
Enter fullscreen mode Exit fullscreen mode

I rejected that design and returned to the classic pattern, but the compromise also failed.

First, I had assigned some entities to the wrong subdomains: AST nodes ended up in IR rather than FrontEnd. Second, the classic Visitor implementation introduced a circular dependency. Third, the subdomains were not isolated; FrontEnd knew about BackEnd because the pattern implementation required it.

Despite every intention to write clean code, I had produced a high-coupling, low-cohesion bowl of spaghetti.

I had to reinvent the pattern as a type-safe, acyclic implementation that followed SOLID and required no casts at all.

www.nuget.org/packages/Visitor.NET

The result was the Visitor.NET library, which I later presented at the Stachka developer conference in Saint Petersburg. I also wrote a separate article for readers who could not attend.

Contravariance produced the required behavior:

public interface IVisitor<in TVisitable, out TReturn>
    where TVisitable : IVisitable<TVisitable>
{
    TReturn Visit(TVisitable visitable);
}

public interface IVisitable<out TVisitable>
    where TVisitable : IVisitable<TVisitable>
{
    TReturn Accept<TReturn>(IVisitor<TVisitable, TReturn> visitor);
}

public record Operation(
    char Symbol,
    BinaryTreeNode Left,
    BinaryTreeNode Right) : BinaryTreeNode, IVisitable<Operation>
{
    public override TReturn Accept<TReturn>(
        IVisitor<BinaryTreeNode, TReturn> visitor) =>
        Accept(visitor);

    public TReturn Accept<TReturn>(
        IVisitor<Operation, TReturn> visitor) =>
        visitor.Visit(this);
}
Enter fullscreen mode Exit fullscreen mode

I then polished away the implementation boilerplate with incremental source generators:

www.nuget.org/packages/Visitor.NET.AutoVisitableGen

[AutoVisitable<BinaryTreeNode>]
public partial record Operation(
    char Symbol,
    BinaryTreeNode Left,
    BinaryTreeNode Right) : BinaryTreeNode;
Enter fullscreen mode Exit fullscreen mode

Advanced Static Analysis

As mentioned earlier, static analysis in the pre-refactoring project had to be disabled because the intrusive design produced strange and difficult bugs. The language also lacked forward references, largely because the parser tried to populate the symbol table while parsing.

I once again had to invent and reverse-engineer a solution: one that could check semantics properly and remain extensible and resilient to change.

This happened alongside my rethinking of Visitor. Once I realized that analysis required several passes over the tree, I also realized that I needed a small framework for building visitors as services.

The result was a multi-stage algorithm, with implementation details at every stage. At the time of writing, the passes were:

  • Scope initialization: Some nodes use the current scope; others introduce a nested scope—for example, functions, objects, and blocks.
  • Type-system loading: Built-in and user-defined types are loaded into symbol tables, after which references between them are resolved.
  • Name reservation: All other symbols are registered. This is a separate pass because an omitted type must be inferred, and inference needs access to reserved identifiers.
  • Main program validation: Expressions, function calls, assignments, and other constructs are analyzed.

With this deeper approach, HydraScript rejects during semantic analysis code that TypeScript allows to fail at runtime:

let x = f()
function f() {
    print(x as string)
    return 5
}
Enter fullscreen mode Exit fullscreen mode

Enforcing Boundaries with Separate .NET Projects

Solving those problems—introducing DDD and Visitor, then designing services—made the next step clearer with every commit: a complete move to Clean Architecture.

The domain core—lexers, parsers, instructions, and so on—needed to become a set of strictly isolated components.

Static analysis and code generation are application-layer features implemented through visitors acting as services.

Dump logging, configuration, and other supporting concerns belong to infrastructure. The CLI handler is the host that wires everything together.

I repeatedly failed to police layer and dependency constraints manually, so I made the compiler enforce them instead of relying on ArchUnitNET tests.

Projects instead of folders are architectural static typing in its purest form.

solution tree

If components in one folder must not depend on components in another, place them in separate projects and let the compiler report violations.

Some people argue that this creates too many small projects, or that “small applications do not need it, while large applications turn into a mess.”

That uses the wrong criterion. Project size does not determine whether the technique applies; domain clarity does. If separate projects reveal the domain structure and make the system easier to understand, use them.

Takeaways

DDD and Clean Architecture did not make the compiler correct by themselves. They became useful only where they exposed stable domain concepts, isolated change, or let the C# compiler enforce a dependency rule. These are the lessons I would carry into another long-lived .NET codebase:

  • Start from the domain when writing code. It improves clarity and readability, assigns responsibilities to the right components, and leads to a flexible architecture that can survive change.
  • Stop assuming you are merely reinventing the wheel. Instead of believing everything has already been built, ask: “What if it has not?”
  • Learn to play the long game. If you cannot solve a problem today, do not abandon it or hide it behind a workaround. Admit that you are missing something, then advance in small steps—both deeper and broader.
  • Do not fear change; accept it. Early decisions are not carved in stone. Some may prove unexpectedly wrong and expensive, and you must be ready to work with that reality.
  • Do not be afraid to build complex algorithms and systems. Not every problem has a two-line solution.
  • Account for the cost of a decision. The more code a solution requires, the more carefully you should consider its consequences.

If you want hands-on open-source contribution experience, browse the HydraScript repository issues. There is usually something useful to work on.

Related Compiler-Construction Articles

Follow StepOne on GitHub for open-source C#/.NET projects, compiler experiments, production-focused examples, and new releases.

Top comments (0)