posts

C# 9 records as strongly-typed ids - Part 5: final bits and conclusion

We’re reaching the end of this series on records as strongly-typed ids. Sorry for the long delay since the last post… life happened! So far we’ve covered ASP.NET Core model binding, JSON serialization, and EF Core integration. Almost everything is working, we just need to fix a few more details.

Handling database-generated values in EF Core

First, I want to address a question asked by @OpsOwns in the comments of the last post:

How can I deal with autoincrement ? like ValueGeneratedOnAdd().UseIdentityColumn() ?

Unfortunately, I don’t have a good answer to that. I experimented a bit, and ran into an error, because apparently EF Core doesn’t support a property that has a value conversion and has its value generated by the database. There’s an open Github issue about this, and hopefully it will be addressed in the next EF Core release. In the meantime, you can either:

  • avoid using database generated values (e.g. get the next id value from a sequence in the C# code, or use a GUID instead of an int)
  • or look at the workaround suggested by user @Dresel

I know, it’s not much of a solution…

Fixing ToString()

In the second post of this series, I suggested implementing ToString() in the StronglyTypedId<TValue> base type, so that you don’t need to do it again in every strongly-typed id:

public override string ToString() => Value.ToString();

This seemed like a good idea… except for the fact that it doesn’t work! If you create a new record that inherits from StronglyTypedId<TValue>, the compiler will always generate a new ToString() method, overriding the one from the base class. This compiler-provided implementation outputs the type and property values, like "ProductId { Value = 42 }". While this looks nice in the debugger, it’s not what we want for strongly-typed ids.

We need the string representation to be that of the wrapped value. The main reason is that this representation will be used in URL generation. For instance, if you do something like this in a controller:

return CreatedAtAction(nameof(GetProduct), new { id = product.Id }, product);

ASP.NET Core will add a Location header to the response, containing the URL for the GetProduct action with the specified value for id. This URL should be something like https://localhost:5000/product/42. The problem is that, if you don’t replace the compiler-generated implementation of ToString(), it will actually generate something like this: https://localhost:5000/product/ProductId { Value = 42 }. Oops!

The obvious fix would be to mark the ToString() override as sealed; unfortunately this is explicitly forbidden, and produces a compilation error.

So, what’s the solution? Override ToString() explicitly in every strongly-typed id? Well, it would work, but it would be pretty annoying… A better option is to use another new feature of C# 9: source generators. Basically, we’re going to automatically generate the method for every strongly-typed id.

Writing a source generator

Source generators are a similar to Roslyn analyzers, in that they run during compilation of the project and rely on the same Roslyn package. They can add new code to the project, based on the existing code.

First, we need to create a new project in our solution to contain the source generator. It’s a simple class library with a reference to the Microsoft.CodeAnalysis.CSharp package:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net5.0</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="3.9.0" PrivateAssets="All" />
  </ItemGroup>
</Project>

In this project, let’s create a StronglyTypedIdGenerator class that implements ISourceGenerator and has the [Generator] attribute:

using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace MyProject.SourceGen
{
    [Generator]
    public class StronglyTypedIdGenerator : ISourceGenerator
    {
        // Adjust the namespace for your project
        private const string StronglyTypedIdMetadataName = "MyProject.StronglyTypedId`1";

        public void Initialize(GeneratorInitializationContext context)
        {
            // Uncomment the next line to debug the source generator
            //System.Diagnostics.Debugger.Launch();
            context.RegisterForSyntaxNotifications(() => new SyntaxReceiver());
        }

        public void Execute(GeneratorExecutionContext context)
        {
            if (context.SyntaxReceiver is not SyntaxReceiver receiver)
                return;

            INamedTypeSymbol stronglyTypedIdBaseType = context.Compilation.GetTypeByMetadataName(StronglyTypedIdMetadataName)
                ?? throw new InvalidOperationException($"Type '{StronglyTypedIdMetadataName}' could not be found");

            foreach (var declaration in receiver.CandidateDeclarations)
            {
                var model = context.Compilation.GetSemanticModel(declaration.SyntaxTree);
                var type = model.GetDeclaredSymbol(declaration);
                if (type is null)
                    continue;

                if (!IsStronglyTypedId(type))
                    continue;

                string fullNamespace = type.ContainingNamespace.ToDisplayString();
                string typeName = type.Name;

                var source = [email protected]"namespace {fullNamespace}
{{
    partial record {typeName}
    {{
        public override string ToString() => Value.ToString();
    }}
}}";
                context.AddSource($"{typeName}.Generated", source);
            }

            bool IsStronglyTypedId(INamedTypeSymbol type)
            {
                return type.BaseType.IsGenericType
                    && !type.BaseType.IsUnboundGenericType
                    && SymbolEqualityComparer.Default.Equals(type.BaseType.ConstructedFrom, stronglyTypedIdBaseType);
            }
        }

        private class SyntaxReceiver : ISyntaxReceiver
        {
            public List<RecordDeclarationSyntax> CandidateDeclarations { get; } = new();

            public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
            {
                if (syntaxNode is RecordDeclarationSyntax declaration)
                {
                    // Must declare a base type (we don't care which at this point)
                    if (declaration.BaseList is null || !declaration.BaseList.Types.Any())
                        return;

                    // Must be partial (otherwise we can't add new members)
                    if (!declaration.Modifiers.Any(SyntaxKind.PartialKeyword))
                        return;

                    // Must have a single parameter, either in the parameter list or in an explicit constructor
                    if (declaration.ParameterList is null)
                    {
                        var ctors = declaration.Members.OfType<ConstructorDeclarationSyntax>().ToList();
                        // We need at least one constructor with one parameter
                        if (!ctors.Any(c => c.ParameterList.Parameters.Count == 1))
                            return;
                        // And there can't be a constructor with more than one parameter
                        if (ctors.Any(c => c.ParameterList.Parameters.Count > 1))
                            return;
                    }
                    else if (declaration.ParameterList.Parameters.Count != 1)
                    {
                        return;
                    }

                    CandidateDeclarations.Add(declaration);
                }
            }
        }
    }
}

I’m not going to describe this code line by line, but here are the important parts:

  • The Initialize method is called when compilation starts. It gives the generator an opportunity to register syntax receivers. Syntax receivers are called every time the compiler encounters a syntax node. In our case, we use it to collect record declarations that we might want to augment. We don’t check too many things here because at this point, we don’t have access to the semantic model of the code. We just check that:

    • the record is partial (because we can’t actually modify existing code, we can only add new code, so the ToString() override will need to be in another partial declaration)
    • it has a base type (we only want to augment records that inherit StronglyTypedId<TValue>)
    • it has exactly one parameter (the Value of the strongly typed id)

    We store the matching record declarations in a collection that we will use in the Execute method.

  • The Execute method is called after the compiler has finished parsing the existing code, and it’s where we have the opportunity to actually generate new code. In this method, we do the following:

    • Look at each candidate record declaration and ensure it really is a strongly-typed id
    • If it is, generate a partial declaration to override the ToString() method, and add that as a source file to be compiled. Note that we could use the Roslyn API to generate the code, but that API is quite verbose, so it’s often simpler to just generate the code as a string.

At this point, we have a very simple source generator that just generates a ToString() method, but of course it could be enriched to generate anything we might need in our strongly-typed ids (e.g. implicit conversion to TValue, a Parse method, etc.)

Using the source generator

Using the source generator is pretty easy: just reference the source generator project in the projects that declare strongly-typed ids. Actually, it’s not really “just” a project reference; we need to specify additional attributes on the reference for this to work:

<ProjectReference
  Include="../MyProject.SourceGen/MyProject.SourceGen.csproj"
  OutputItemType="Analyzer"
  ReferenceOutputAssembly="false" />

OutputItemType is Analyzer, which seems a bit strange, but as I mentioned before, source generators work similarly to analyzers, so it’s not really surprising. ReferenceOutputAssembly is false, because we don’t need the source generator at runtime, only during compilation.

Now, we just need to modify our strongly-typed ids to make them partial, so that the source generator can augment them:

public partial record ProductId(int Value) : StronglyTypedId<int>(Value);

And that’s it! If we rebuild our project, the source generator kicks in and adds the ToString() overrides to all our strongly-typed ids.

Of course, at this point, we can’t see the generated code, so how can we know if it’s there, and if it’s correct? Fortunately, there’s a simple way to see that code. In the project that references the source generator, add the following properties:

  <PropertyGroup>
    <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
    <CompilerGeneratedFilesOutputPath>.generated</CompilerGeneratedFilesOutputPath>
  </PropertyGroup>

The generated code files will now be added to the .generated folder in the project, so we can inspect them:

namespace MyProject
{
    partial record ProductId
    {
        public override string ToString() => Value.ToString();
    }
}

Conclusion

Well, that was quite a ride! We’ve seen that using records as strongly-typed ids is possible today with C# 9, ASP.NET Core 5.0 and EF Core 5.0, but there are many issues to overcome to make it work. I already use the solutions described in this series in a fairly complex project, and it works great; it already prevented me from introducing a few bugs!

There are still a few wrinkles, though:

  • The fact that our strongly-typed ids are reference types is annoying; ideally, they should be value types to make them non-nullable.
  • The EF Core integration is much more painful than it should be, because:
    • We need to manually specify entity keys instead of relying on convention
    • There’s no easy way to support database-generated ids
    • We need to apply a converter to every strongly-typed id property
  • Having to rely on a source generator to override ToString() automatically seems a bit overkill

All of these problems have already been identified in the relevant GitHub repos, so I’m hoping that at least some of them will be solved in the near future:

  • C#
    • There’s a proposal (dotnet/csharplang#4334) to support record structs that seems to be getting some traction; it’s already been discussed in several language design meetings, so it might be included in C# 10.
    • There’s a proposal (dotnet/csharplang#4174) to make it possible to seal the ToString() override in records, so that the base type implementation won’t be replaced by a compiler-generated one. There’s no guarantee that it will be included in C# 10, but it’s a pretty small feature, so I guess it’s possible.
  • Entity Framework Core
    • dotnet/efcore#11597 addresses the problem of database-generated values with converters. It’s been postponed for several releases but hopefully it will be implemented at some point.
    • dotnet/efcore#10784 will provide a mechanism to set a converter for all properties of a given type; it’s in the 6.0 milestone, so unless it’s postponed, it should be in the next major release.

In the meantime, feel free to use the code provided in this series!