Replacing T4 Templates with plain C# 11

Maybe you don't need a template library?

Edwin Young

3 minute read

Replacing T4 Templates with plain C# 11

Generating files with T4

In Arc-Enabled Servers we have a quite a lot of different config files which need to be mostly the same, but a little different for each region we deploy into. For example, we have values that are used by Helm charts for each region:

Australia East

configmap:
  Environment: Prod
  Location: "australiaeast"
  CloudEnvironment: "Public"
  UseKeyVault: true
  ...

Brazil South

configmap:
  Environment: Prod
  Location: "brazilsouth"
  CloudEnvironment: "Public"
  UseKeyVault: true
  ...

We don’t want to manually maintain this for every region, so we have a T4 Template which is run to generate these files. Looks something like this:

<#@ template language="C#" hostspecific="true" #>
<#@ include file="MultiOutput.ttinclude" #>
<#@ assembly name="System.Core" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="Deployment" #>
<#@ import namespace="Deployment.Models.Kubernetes" #>
<#@ assembly name="Deployment.dll" #>
<#
foreach (var environmentConfig in KubernetesDeploymentInfo.AllEnvironments) 
{
    var cloudWideConfig = environmentConfig.Value.Item1;
    var regions = environmentConfig.Value.Item2;
    foreach (var deployment in regions)
    {
        ClearOutput();

        var valuesFileName = deployment.ValuesFileName;
        var outputPath = "deployment\\charts\\certmgmtservice\\cmsfrontend";
#>

configmap:
  Environment: "<#= deployment.Environment #>"
  Location: "<#= deployment.LocationName #>"
  CloudEnvironment: "<#= deployment.CloudEnvironment #>"
  UseKeyVault: true

<#
        SaveOutput(outputPath, valuesFileName, addToProject: false);
    }
}
#>

NB These are snippets to illustrate the approach, you won’t be able to run them as-is. The real config files are quite a bit longer.

Anything in <# #> is C# code, everything else is text which is echoed to the output when the template is run. Deployment.dll contains C# data structures which define things like the list of all known regions, and the names of each one.

This works OK, but I have some gripes:

  • We use .NET 6 exclusively, but T4 is stuck in .NET Framework land. We would up moving to mono.texttemplating for .NET Core support
  • It’s just not all that easy to read
  • Debugging and developing the template code is a bit more of a hassle than regular code.

Generating with C# 11

I spent a bit of time looking at other templating solutions before thinking: The template is a text file with bits of embedded C#. What if instead we had a C# file with embedded text?

With the introduction of raw string literals this is a lot cleaner than it was. These are preview functionality at the time of writing, but since my use case only runs at build time, I’m not too worried about that. A C# program which does the equivalent of the template above looks like:

using Ev2Deploy.Deployment;
using System.IO;
using System.Text;

public class cmsfrontend
{
    public static void Generate(string rootpath)
    {
        foreach (var environmentConfig in KubernetesDeploymentInfo.AllEnvironments)
        {
            var cloudWideConfig = environmentConfig.Value.Item1;
            var regions = environmentConfig.Value.Item2;
            foreach (var deployment in regions)
            {                
                var valuesFileName = deployment.ValuesFileName;
                var outputPath = "deployment\\charts\\certmgmtservice\\cmsfrontend";
                
                vat outputFile = Path.Combine(rootpath, outputPath, valuesFileName)
                using var output = new StreamWriter(outputFile, false, Encoding.UTF8);

                output.Write($"""
                    configmap:
                        Environment: "{deployment.Environment}"
                        Location: "{deployment.LocationName}"
                        CloudEnvironment: "{deployment.CloudEnvironment}"
                        UseKeyVault: true
                    """);
            }
        }
    }
    public static void Main()
    {
        Generate("out");
    }
}

This is about the same amount of boilerplate, but I prefer this version. It removes an entire tool (T4) from the toolchain and simplifies the dependencies; I can step through the code trivially if needed; syntax highlighting and Intellisense work normally.

More importantly, I think the actual core replacement syntax is cleaner. C# string interpolation in raw string literals is powerful enough to do all the things we were using in T4, it’s clear, easy to read, and familiar for C# developers.