Using C# Source Generators to Generate Data Transfer Objects (DTOs)

For many enterprise applications, there would normally be a split between domain entities that live inside the application core and DTOs that are exposed to the outside world, for e.g. as outgoing data structures from a web service. Often these structures are symmetric to domain entities, i.e. they contain all of the same properties that domain entities contain but most likely none of the logic. There are 2 problems with this pattern however:

  • This tends to be a very manual and time consuming task that doesn’t add much value
  • The code you have to write to map from the domain entities to these DTOs tends to be repetitive and error prone. Mistakes can often occur leading to broken contracts with the consumers of the DTO.

Using C# Source Generators to generate DTOs could potentially save a lot of developer time, so in this post I am going to attempt to write just such a generator.

DISCLAIMERS:

  • This may not be the most performant way or the most sensible to write source generators so if you know of a better way, please by all means comment away!
  • The code shown here will very likely be very hard to follow at times especially in the last half of part 2, its OK if you don’t follow all of it. You are writing code within code essentially, so it is a bit messy by design. I will put the fully refactored code on Github with a readme that describes some of the Roslyn APIs so that should help a little.
  • The whole thing is a bit of an experiment, I’ve not used this code in any production application yet. I intend to at some point to get some real feedback on its viability but nothing as yet. For now I am really curious to see what’s possible and how far I am willing to and/or comfortable, pushing it.
  • Implementation is a bit opinionated so it will not cater for all edge cases.

ASSUMPTIONS:

  • No inheritance relationship between domain entity classes. All entity classes are therefore assumed to be at the same level of hierarchy.
  • Either the whole entity class will be mapped to a DTO or not at all, excluding properties from being mapped whilst possible is not in the current scope.
  • DTOs are assumed to be outgoing from, say a web service or a REST API so persistence related models are out of scope for now because they might require additional data access related code to be added to the DTOs for e.g. special attributes specific to ORMs, access modifiers etc which for a generic code generator is too much responsibility and will make things too complicated.

What is source generation?

At its most basic, source generation is basically what it sounds like: auto-generated code. There are two main types: compile time and post-compilation or IL emitting. Former ties into the existing build toolchain and the latter is a bit more challenging because you need to know IL and emitting correct IL is hard if not impossible. I could imagine both being relatively difficult to test using conventional testing techniques. Usually a lot of trial and error might be involved and there might often be limited support for debugging. Source generation is not a new concept, its been around for a long time in the form of tools like T4, Postsharp, Fody etc but I have never used these tools in the past…well…except for may be T4 several years ago…once.

C# Source Generators allow emitting C# code during the compilation process and include the emitted code in the rest of build process such that it builds along with the rest of your code. The compiler passes the control to the ISourceGenerator implementation to add code to the syntax tree and the emitted code is then included in the rest of compilation process as normal. This blog post goes into a lot of the “whats” and the “whys” so I am not going to.

Compiler 101

The C# compiler creates two models from the code you write: syntactic model i.e. how is the code structured in terms of tokens for e.g. starts with access modifier, then return type then identifier then parenthesis etc basically your cs file and semantic model i.e. what does the code mean for e.g. what is a property? what are the type arguments for a generic type? etc. During the compilation process, the parser parses the code into a syntax tree and generates the semantic model for this tree, these two models are then passed to your source generator to scan, and emit code based on criteria you define. For e.g. generate DTOs for all entity classes decorated with a certain attribute.

Generating DTOs from Basic Domain Entities (only primitive types)

First things first, I will add a console app (call it ConsoleApp9) to a new solution where I can define my domain entities, and later will reference the source generator to do some code generation.

For this blog post I will create a basic Employee domain entity that looks like this:

public class Employee
{
public Employee(...)
{
//...populate the properties here
}
public Guid Id { get; }
public string Name { get; }
public DateTime DateOfJoining { get; }
//..other domain operations
}
view raw Employee.cs hosted with ❤ by GitHub

The corresponding DTOs might look something like this – basically just open property bags:

public class EmployeeDto
{
public Guid Id {get; set;}
public string Name {get; set;}
public DateTime DateOfJoining {get; set;}
}
view raw EmployeeDto.cs hosted with ❤ by GitHub

How do I get from the domain to DTO ?

The next thing I’ve got to do is create a new .NET Standard 2.0 class library project in my solution that I created earlier. This library project can then be shipped as a Nuget package later. NOTE: I do need all those Microsoft.CodeAnalysis.* packages to create a source generator. The C# language version has to be latest and in order to see what files the compiler outputs, I’ll set the EmitCompilerGeneratedFiles attribute to true and specify a folder for the generated files to go into (last two lines in the following csproj snippet).

<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis" Version="3.10.0" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.2">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="3.10.0" PrivateAssets="All" />
</ItemGroup>
<PropertyGroup>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<CompilerGeneratedFilesOutputPath>Generated</CompilerGeneratedFilesOutputPath>
</PropertyGroup>
</Project>
view raw Generator.csproj hosted with ❤ by GitHub

Now I need to create an implementation of the ISourceGenerator interface in this project and decorate it with Generator attribute for the compilation process to pick it up as a source generator.

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;
using System;
namespace DtoGenerators
{
[Generator]
public class MappedDtoGenerator : ISourceGenerator
{
public void Execute(GeneratorExecutionContext context)
{
// this is where I will generate code into the compilation process
}
public void Initialize(GeneratorInitializationContext context) {};
}
}
view raw SkeletonGenerator.cs hosted with ❤ by GitHub

What I want to do in order to help the source generator find the types that need converting to DTO, is decorate my domain types with a custom attribute which I will create in the source generator project:

using System;
namespace DtoGenerators
{
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false)]
public class GenerateMappedDtoAttribute : Attribute
{
public GenerateMappedDtoAttribute()
{
}
}
}

and then tack it on the my domain entities that I want DTOs for:

[GenerateMappedDto]
public sealed class Employee
{
public Employee(
string name,
DateTime dateOfJoining)
{
Id = Guid.NewGuid();
Name = name;
DateOfJoining = dateOfJoining;
}
public Guid Id { get; }
public string Name { get; }
public DateTime DateOfJoining { get; }
}
view raw DeocratedEntity.cs hosted with ❤ by GitHub

In order to tell my source generator which classes to generate code for, I need to also implement ISyntaxContextReciever and register it with the source generator (in the Initialise() method). This receiver is kind of a hook that the compiler calls into as it traverses the syntax tree node by node:

public class TargetTypeTracker : ISyntaxContextReceiver
{
public IImmutableList<TypeDeclarationSyntax> TypesNeedingDtoGening =
ImmutableList.Create<TypeDeclarationSyntax>();
public void OnVisitSyntaxNode(GeneratorSyntaxContext context)
{
if (context.Node is TypeDeclarationSyntax cdecl)
if (cdecl.IsDecoratedWithAttribute("generatemappeddto"))
TypesNeedingDtoGening = TypesNeedingDtoGening.Add(
context.Node as TypeDeclarationSyntax);
}
}
internal static class SourceGenExtns
{
internal static bool IsDecoratedWithAttribute(
this TypeDeclarationSyntax cdecl, string attributeName) =>
cdecl.AttributeLists
.SelectMany(x => x.Attributes)
.Any(x => x.Name.ToString().ToLower() == attributeName);
}
view raw TargetTypeTracker.cs hosted with ❤ by GitHub

During each visit, I will check to see if the TypeDeclarationSyntax node (for e.g. a class or a struct) is decorated with the GenerateMappedDto attribute and if it is, I will simply add it to a list. That done its time to register this receiver with the source generator:

[Generator]
public class DtoGenerator : ISourceGenerator
{
public void Execute(GeneratorExecutionContext context)
{
}
public void Initialize(GeneratorInitializationContext context)
{
context.RegisterForSyntaxNotifications(() => new TargetTypeTracker());
}
}
view raw RegisterReceiver.cs hosted with ❤ by GitHub

TypeDeclarationSyntax is the base type for the ClassDeclarationSyntax and StructDeclarationSyntax so will cover both container types and will allow generating DTOs for both. Once the entire syntax tree is thus visited, the control will be transferred back to the source generator and the compiler will pass it both the syntactic model and the semantic model using which I can write out the actual code:

[Generator]
public class DtoGenerator : ISourceGenerator
{
public void Execute(GeneratorExecutionContext context)
{
var targetTypeTracker = context.SyntaxContextReceiver as TargetTypeTracker;
var codeBuilder = new StringBuilder();
foreach (var typeNode in targetTypeTracker.TypesNeedingDtoGening)
{
// Use the semantic model to get the symbol for this type
var typeNodeSymbol = context.Compilation
.GetSemanticModel(typeNode.SyntaxTree)
.GetDeclaredSymbol(typeNode);
// get the namespace of the entity class
var entityClassNamespace = typeNodeSymbol.ContainingNamespace?.ToDisplayString() ?? "NoNamespace";
// give each DTO a name, just suffix the entity class name with "Dto"
var generatedDtoClassName = $"{typeNodeSymbol.Name}Dto";
// Add usings
codeBuilder.AppendLine("using System;");
codeBuilder.AppendLine("using System.Collections.Generic;");
codeBuilder.AppendLine("using System.Linq;");
// Add target namespace
codeBuilder.AppendLine($"namespace {entityClassNamespace}.Dtos");
codeBuilder.AppendLine("{");
// Start class
codeBuilder.AppendLine($"\tpublic class {generatedDtoClassName}");
codeBuilder.AppendLine("\t{");
// get all the properties defined in this class
var allProperties = typeNode.Members.OfType<PropertyDeclarationSyntax>();
// for each property in the domain entity, create a corresponding property
// in the DTO with the same type
foreach (var property in allProperties)
codeBuilder.AppendLine($"\t\t{property.BuildDtoProperty(context.Compilation)}");
// Add closing braces
codeBuilder.AppendLine("\t}");
codeBuilder.AppendLine("}");
// add the code for this DTO class to the context so it can be added to the build
context.AddSource(generatedDtoClassName,
SourceText.From(codeBuilder.ToString(), Encoding.UTF8));
codeBuilder.Clear();
}
}
public void Initialize(GeneratorInitializationContext context)
{
context.RegisterForSyntaxNotifications(() => new TargetTypeTracker());
}
}
internal static class SourceGenExtns
{
internal static string BuildDtoProperty(
this PropertyDeclarationSyntax property, Compilation compilation)
{
// get the symbol for this property from the semantic model
var symbol = compilation
.GetSemanticModel(pds.SyntaxTree)
.GetDeclaredSymbol(pds);
var property = (symbol as IPropertySymbol);
// use the same type and name for the DTO properties as on the entity
return $"public {property.Type.Name()} {property.Name} {{get; set;}}";
}
// instead of returning "System.Collections.Generic.IList<>", just condense it to "IList<>"
// the namespace is already added in the usings block
internal static string Name(this ITypeSymbol typeSymbol) =>
typeSymbol.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat);
}
view raw DtoGenerator.cs hosted with ❤ by GitHub

There is a quite bit going on here so I will unpack, I am using the semantic model for the most part because that gives me richer set of information about the code:

  1. I’d also like, for simplicity reasons, to put the DTOs in “Dtos” namespace under the main domain namespace. So if the entity is in MyApp.Domain then the dtos will be in MyApp.Domain.Dtos (Line 17)
  2. I will like the DTOs to have a naming convention of “{Entity class name}Dto”. For e.g. Employe entity class will have a DTO class named EmployeeDto. (Line 20)
  3. I am importing some generic framework libraries. Nothing fancy about that (Lines 23-25)
  4. Then goes the meat of the class body (Lines 32-41) (more on this in a sec!)
  5. Close class and namespace definitions.

So what’s the meat here?

Essentially, each property in the DTO will be mapped to the corresponding property in the domain entity, with the same name and type. So I am looping over all the PropertyDeclarationSyntax nodes in the domain entity syntax model and emitting corresponding properties. In the BuildDtoProperty() method, I am once again using the semantic model to get more information about the property type which is available via property.Type property. Then I’ve just added a simple extension method to get the condensed name of the type for e.g. Guid instead of System.Guid (just personal readability preference).

It literally emits: public Guid Id {get;set;}

BTW, all these various syntax/symbol classes are a part of the Roslyn C# syntax API that you can browse here.

That…is basically it for a very minimal (read: limitedly useful) DTO generator!

How do I actually use this generator in my application?

Remember our ConsoleApp9 that we added earlier? I will now add to it a project reference to the source generator project:

<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp3.1</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Compile Include="..\Genny\GenerateMappedDtoAttribute.cs" Link="GenerateMappedDtoAttribute.cs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Genny\Genny.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>
</Project>
view raw UsingGenny.csproj hosted with ❤ by GitHub

Two things of note here:

1) I have added the GenerateMappedDtoAttribute class as a linked file temporarily, eventually this would be a part of the Nuget package so the linked file reference can be removed from the ConsoleApp9.csproj, and

2) the project reference doesn’t reference the output assembly and also sets the item type to Analyzer. The former will make sure that any transitive dependencies of the source generator project don’t get added as references of the console app project itself (this attribute is not needed when adding a PackageReference) and the latter will make it appear as an analyzer under dependencies and this is also where I can see the generated DTO.

Now I am all set to generate my very first DTO automagically! I’ll just hit Ctrl+Shift+B in Visual Studio (or do dotnet build from the CLI), to build the solution! If you are doing this for the first time you might notice that nothing seems to have changed in ConsoleApp9! 🤔No DTOs in sight, nothing and if you are unlucky there might be some build errors to boot as well, what’s that about?

Well, this is where we might want to pay heed to the recommendation of source generator creators:

You will need to restart Visual Studio after building the source generator to make errors go away and IntelliSense appear for generated source code. After you do that, things will work. Currently, Visual Studio integration is very early on. This current behavior will change in the future so that you don’t need to restart Visual Studio.

– Microsoft

I don’t think VS Code suffers from this issue and I haven’t tried this on Rider, unfortunately, my trial expired before I embarked on source generators so that mystery will stay a mystery for now!

Once I do the proverbial “turn it off and on again”, I now see the DTO light up in the consuming project and intellisense should also pick it up. Now bear in mind, these generated DTOs don’t get checked into source control because..well..they get generated at build time!😁But they will get packaged into my application binaries during deployment, so I can rest easy!

How do I Nuget-ify my source generators?

For this I will modify the csproj of the source generator to:

  1. Add a package version (come on, we’re not animals!)
  2. Instruct the dotnet pack command to put the analyser in a pre-designated folder in the generated nuget package (I ended up spending several frustrating minutes trying to figure out why the analyser was not showing up in my consuming project, this turned out to the missing piece! Now you know as well!)
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>latest</LangVersion>
<PackageVersion>1.0.0-preview1</PackageVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis" Version="3.10.0" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.2">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="3.10.0" PrivateAssets="All" />
</ItemGroup>
<ItemGroup>
<!-- Package the generator in the analyzer directory of the nuget package -->
<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
</ItemGroup>
<PropertyGroup>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<CompilerGeneratedFilesOutputPath>Generated</CompilerGeneratedFilesOutputPath>
</PropertyGroup>
</Project>
view raw Nugetify.csproj hosted with ❤ by GitHub

Once this package is pushed to nuget feed, I can replace ProjectReference with PackageReference in my ConsoleApp9 project, remove the ReferenceOutputAssembly attribute from it and I’m off to the races! Now whenever I add a new property to my domain entity, all I have to do now is build the project and the DTO will be automatically updated. That’s a lot of developer time saved potentially!

What’s missing?

As cool as this was, the generator is very basic in that it doesn’t support:

  • Properties with complex types for e.g. Employee class having a Address type property which in turn could be composed of primitive types.
  • Properties with generic types with one or more primitive type arguments for e.g. Employee class having an IReadOnlyCollection<string> property called Dogs (may be for some godforsaken reason we want to track the names of their dogs! I know you pooped on the carpet, Winston! 🤷‍♂️)
  • Properties with generic types with one or more complex type arguments for e.g. Employee class having a IReadOnlyCollection<CompanyAsset> called AssetsAllocated or some completely made up property of type Dictionary<int,Spaceship>, and
  • Generating mapping extension methods to convert entities to DTOs. This could potentially be a bigger win in terms of time saving!

These I will cover in the final part of this blog post!

Header image source

One Reply to “Using C# Source Generators to Generate Data Transfer Objects (DTOs)”

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.