Refactoring Reflection-Based Code to Generator-Based Approach: Pivoting SQL Tables

Generator-Based Approach: Pivoting SQL Tables
C#
·
15 min
·
2025/02/21

Refactoring Reflection-Based Code to Generator-Based Approach: Pivoting SQL Tables

In many projects, especially those that pivot around dynamic SQL data, it's common to face the challenge of mapping raw database fields into a structured model. For a long time, reflection-based code was the go-to approach for this task. But as our applications scale, we quickly realized that relying on reflection has its downsides

Context

Our journey began with a reflection-based method that was adequate for small datasets. However, as our project grew and performance concerns became more pronounced, we recognized the need for a more efficient approach. We evaluated several strategies and decided to invest in incremental source generators as this approach becomes widely used and Microsoft itself uses it. The move was gradual:

  • A co-worker's merge request highlighted the limitations of the reflection-based method.

  • We took some time to assess whether the performance improvements justified the refactor.

  • Implementation: Finally, with a clear roadmap, we refactored the code to use source generators, shifting all the heavy lifting to compile time.

  • Result: Today, our new generator-based approach is robust and delivers a noticeable boost in performance.

Of course we could bring to the table cache-ing of a property info and types with which we need to work, but this would be still the same reflection which we want to avoid because we aimed to support AOT.

What we are working with?

We have a database table named FieldValues with the following data:

appraise_request_id

field_name

field_value

field_type

1828

date_of_inspection

2025-01-22

date

1828

date_of_construction

2025-01-15

date

1828

leaseable_area

1111.22

text

1828

leaseable_area_per_unit

1111.44

text

1828

unit_count

25

text

This is a table which holds values for fields from auto-form, as you can see. We have many forms inside our application and count of fields may be indefinite as each form may be defined by our client in form builder UI.

Of course some of this fields are mandatory and we need to work with them in reports or map them to other forms, etc.

A typical SQL query to retrieve data for a specific appraise_request_id might look like this:

SELECT 
    appraise_request_id, 
    field_name, 
    field_value
FROM 
    FieldValues
WHERE 
    appraise_request_id = 1828
    AND field_name IN (
        'executive_summary_leaseable_area',
        'executive_summary_leaseable_area_per_unit',
        'executive_summary_unit_count'
    );

In last place we need somehow map this data to statically typed DTO model as we need to work with them from the code.


The Old Way: Reflection-Based Binding

Imagine you’re trying to match a messy stack of paperwork to the correct files. Reflection is like having a magnifying glass: it lets you inspect each file (or property) on the fly. We once had a method like this:

public static async Task < T > BindInternalFieldToModel < T > (this AppraiSysContext dbContext, int appraiseRequestId) where T: new() 
{
    var fieldToInternalField = new Dictionary < string,
        (PropertyInfo, InternalFieldFormatType) > ();

    var bindModel = new T();

    var bindModelFields = bindModel.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance)
        .Where(x => Attribute.IsDefined(x, _typeOfFieldNameAttr)).ToList();

    foreach(var field in bindModelFields) {
        if (field.GetCustomAttribute < FieldNameAttribute > () is FieldNameAttribute attribute) {
            fieldToInternalField.Add(attribute.FieldInternalName, (field, attribute.FormatType));
        }
    }

    var fieldsToFetchFromDb = fieldToInternalField.Keys.ToList();

    var fieldsData = await dbContext.FormSubmissionFieldValues
        .Where(x => x.AppraiseRequestId == appraiseRequestId && fieldsToFetchFromDb.Contains(x.FieldName))
        .Select(x => new {
            x.FieldName, x.FieldValue
        })
        .ToListAsync();

    foreach(var fieldDataFromDb in fieldsData) {
        if (fieldToInternalField.TryGetValue(fieldDataFromDb.FieldName, out
                var bindDataField)) {
            var formattedValue = fieldDataFromDb.FieldValue.FormatedByInternalFieldFormatType(bindDataField.Item2);
            if (formattedValue.IsSuccess)
                bindDataField.Item1.SetValue(bindModel, formattedValue);
        }
    }

    fieldToInternalField.Clear();

    return bindModel;
}

This code simply gathers all attributes from the type, store them in a list and then after trip to database maps the values to their original properties, of course using a MethodInfo from Reflection lib.

This solution worked, but—as any developer who’s spent time with reflection knows—it comes with performance overhead and maintenance headaches. Reflection inspects code at runtime, which makes it slower and potentially error-prone when changes occur in your models or attributes.

Why the Shift? The Limitations of Reflection

Let’s draw an analogy. Suppose you’re navigating a city using a paper map. It works, but you have to unfold, refold, and constantly re-read tiny street names. Reflection is much the same: it’s flexible, but every lookup at runtime can be inefficient and difficult to manage as your codebase grows.

Key pain points included:

  • Performance: Each reflection call adds runtime overhead.

  • Maintainability: Changes in attribute names or property signatures can silently break the binding.

  • Debuggability: Runtime errors due to reflection are harder to trace back to the source.

We needed a better solution—one that could shift these checks to compile time, ensuring reliability and boosting performance.

And of course no AOT.


Embracing Source Generators: A Modern Alternative

Enter incremental source generators. Think of them as your project’s autopilot, generating the required binding code at compile time. Not only do they remove the runtime cost, but they also provide type safety and reduce the risk of hidden errors.

The generator-based approach shifts the binding logic to compile time, thereby eliminating runtime reflection. This approach is built using an incremental source generator that automatically creates binding code for classes implementing a certain base type.

Method: Initialize
Implements the IIncrementalGenerator.Initialize method. It scans the syntax trees of the project to find candidate classes that inherit from InternalFieldBindable and contain properties decorated with FieldNameAttribute.

This should do following steps:

  1. Syntax Filtering:
    Uses context.SyntaxProvider.CreateSyntaxProvider to scan for classes (nodes) that have a base list and contain property declarations.

  2. Combine with Compilation Context:
    The filtered syntax nodes are combined with the overall compilation, allowing access to semantic (type) information.

  3. Candidate Processing:
    Iterates over each candidate class, validates if it inherits from InternalFieldBindable, and then collects all properties decorated with the FieldNameAttribute.

  4. Code Generation:
    For each valid candidate, the method calls GenerateBindingCode to produce the binding code and registers the generated code with the compiler.

public void Initialize(IncrementalGeneratorInitializationContext context)
{
    // Select candidate classes
    var classDeclarations = context.SyntaxProvider
        .CreateSyntaxProvider(
            predicate: static (node, ct) =>
                node is ClassDeclarationSyntax { BaseList: not null },
            transform: static (ctx, ct) => (ClassDeclarationSyntax)ctx.Node)
        .Where(cds => cds.Members.OfType<PropertyDeclarationSyntax>().Any());

    // Combine with the Compilation.
    IncrementalValueProvider<(Compilation Compilation, ImmutableArray<ClassDeclarationSyntax> Classes)> compilationAndClasses =
        context.CompilationProvider.Combine(classDeclarations.Collect());
    
    context.RegisterSourceOutput(compilationAndClasses, (spc, source) =>
    {
        var (compilation, classSyntaxes) = source;
        foreach (var classSyntax in classSyntaxes)
        {
            var semanticModel = compilation.GetSemanticModel(classSyntax.SyntaxTree);
            if (semanticModel.GetDeclaredSymbol(classSyntax) is not INamedTypeSymbol classSymbol)
                continue;

            var implementsBindable = classSymbol.BaseType?.Name == "InternalFieldBindable";
            if (!implementsBindable)
                continue;

            // properties with [FieldName]
            var properties = classSymbol.GetMembers()
                .OfType<IPropertySymbol>()
                .Where(prop => prop.GetAttributes()
                    .Any(attr => attr.AttributeClass?.Name == "FieldNameAttribute"))
                .ToList();

            if (properties.Count == 0)
                continue;

            var generatedSource = GenerateBindingCode(classSymbol, properties);
            spc.AddSource($"{classSymbol.Name}_Binding.g.cs", SourceText.From(generatedSource, Encoding.UTF8));
        }
    });
}

Method: GenerateBindingCode
This helper method constructs the binding code as a string for a given class. It generates code that declares a list of internal field names and a method to bind values from a dictionary to the class properties.

While the method name talks by itself, let's clarify the resposibilities:

  • Namespace and Class Declaration:
    Determines the class’s namespace and begins the partial class definition.

  • Generate Field Names List:
    Extracts all distinct internal field names from the class properties and writes them into a static list.

  • Generate Binding Method:
    Iterates over each property, producing an if block that checks if the field value exists in the provided dictionary. If found, the code formats and assigns the value.

The method returns the fully composed code as a string.

private static string GenerateBindingCode(INamedTypeSymbol classSymbol, List < IPropertySymbol > properties) 
{
    var namespaceName = classSymbol.ContainingNamespace.IsGlobalNamespace ?
        null :
        classSymbol.ContainingNamespace.ToDisplayString();

    // Determine the fully qualified enum type for InternalFieldFormatType.
    // (We assume that all properties use the same enum type.)
    var fullyQualifiedEnumTypeForHelper = "global::InternalFieldFormatType";
    foreach(var prop in properties) {
        var attr = prop.GetAttributes().FirstOrDefault(a => a.AttributeClass?.Name == "FieldNameAttribute");
        if (attr is {
                ConstructorArguments.Length: > 1
            } &&
            attr.ConstructorArguments[1].Type != null) {
            fullyQualifiedEnumTypeForHelper = EnsureGlobalPrefix(
                attr.ConstructorArguments[1].Type!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat));
            break;
        }
    }

    var sb = new StringBuilder();
    sb.AppendLine("// <auto-generated/>");
    sb.AppendLine("using System.Collections.Generic;");
    sb.AppendLine();

    if (!string.IsNullOrEmpty(namespaceName)) {
        sb.AppendLine($"namespace {namespaceName}");
        sb.AppendLine("{");
    }

    sb.AppendLine($"    public partial class {classSymbol.Name}");
    sb.AppendLine("    {");

    // Generate a static readonly list that holds all internal field names.
    var internalFieldNames = properties
        .Select(prop => {
            var attr = prop.GetAttributes().First(a => a.AttributeClass?.Name == "FieldNameAttribute");
            var internalName = attr.ConstructorArguments.Length > 0 && attr.ConstructorArguments[0].Value is not null ?
                attr.ConstructorArguments[0].Value!.ToString() !
                :
                "";
            return internalName;
        })
        .Distinct()
        .ToList();

    sb.AppendLine("        /// <summary>");
    sb.AppendLine("        /// Public accessor for internal field names");
    sb.AppendLine("        /// </summary>");
    sb.AppendLine("        public new List<string> GetFieldNames => FieldNames;");
    sb.AppendLine();

    sb.AppendLine("        /// <summary>");
    sb.AppendLine("        /// All internal field names");
    sb.AppendLine("        /// </summary>");
    sb.AppendLine("        public static readonly List<string> FieldNames = new List<string>");
    sb.AppendLine("        {");
    foreach(var name in internalFieldNames) {
        sb.AppendLine($"            \"{name}\",");
    }
    sb.AppendLine("        };");
    sb.AppendLine();

    // Generate the BindInternalFields method.
    sb.AppendLine("        /// <summary>");
    sb.AppendLine("        /// Binds provided field values to the model properties.");
    sb.AppendLine("        /// </summary>");
    sb.AppendLine("        public new void BindInternalFields(IDictionary<string, string> fieldValues)");
    sb.AppendLine("        {");

    foreach(var prop in properties) {
        var attr = prop.GetAttributes().First(a => a.AttributeClass?.Name == "FieldNameAttribute");
        //internal field name.
        var internalName = attr.ConstructorArguments.Length > 0 && attr.ConstructorArguments[0].Value is not null ?
            attr.ConstructorArguments[0].Value!.ToString() !
            :
            "";

        // Process the format type from enum member name
        var enumMemberName = "Default";
        if (attr.ConstructorArguments.Length > 1 && attr.ConstructorArguments[1].Value is int intValue) {
            var enumType = attr.ConstructorArguments[1].Type;
            if (enumType != null) {
                foreach(var member in enumType.GetMembers().OfType < IFieldSymbol > ()) {
                    if (member.HasConstantValue && member.ConstantValue is int memberValue && memberValue == intValue) {
                        enumMemberName = member.Name;
                        break;
                    }
                }
            }
        }

        // Get the property type fully qualifid
        var propertyType = EnsureGlobalPrefix(
            prop.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat));

        sb.AppendLine($"            if (fieldValues.TryGetValue(\"{internalName}\", out var rawValue_{prop.Name}))");
        sb.AppendLine("            {");
        sb.AppendLine($"                this.{prop.Name} = rawValue_{prop.Name}.FormatedByInternalFieldFormatType({fullyQualifiedEnumTypeForHelper}.{enumMemberName});");
        sb.AppendLine("            }");
    }

    sb.AppendLine("        }");
    sb.AppendLine();

    sb.AppendLine("    }");

    if (!string.IsNullOrEmpty(namespaceName)) {
        sb.AppendLine("}");
    }

    return sb.ToString();
}

Method: EnsureGlobalPrefix

This is a pretty straight-forward method that just ensures that we are using global namespace for some types. It is need to be done to avoid conflicts in code that may arise if we have same named types.

// type display MUST be string starts with "global::"
private static string EnsureGlobalPrefix(string typeDisplay)
{
    return typeDisplay.StartsWith("global::") ? typeDisplay : "global::" + typeDisplay;
}

Note: to test this generator you can add Debugger.Launch() from the System.Diagnostic namespace https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.debugger.launch?view=net-9.0


Let's use it!

Wait, we need to actually reference this code generator to our project so generator will know what code he needs to process.

There is two way:

  1. Ship the project with the generator as a NuGet package

  2. Reference this project with a few additional configuration flags, so MSBuild will aware how to threat this special project

We decided to use second way because it was easier for us, in first place because we don't have our own dedicated NuGet server for project needs and actually we don't wanna to deal with it as it brings some headache in terms of managing this server.

So, to "attach" a project with generator inside all we need to do is point MSBuild to threat this project right. In our case generator project named as "Generator.BindInternalField". All we need to do is add reference like below:

<ProjectReference Include="..\..\Generator.BindInternalField\Generator.BindInternalField.csproj" OutputItemType="Analyzer" />

Main item here is OutputItemType="Analyzer" , this flag configure reference to be threated as Analyzer which are permitted to rise diagnostic events (the one that you often ignores: Warning, Error, etc) and generate code.

Next step is just "decorate" our DTO model which will be a target for code generation with a base class InternalFieldBindable:

public partial class ExecutiveSummaryBindDataModel : InternalFieldBindable
{
    [FieldName("executive_summary_leaseable_area", InternalFieldFormatType.FormatedNumber)]
    public Result<string> LeaseableArea { get; set; } = new Problem();

    [FieldName("executive_summary_leaseable_area_per_unit", InternalFieldFormatType.FormatedNumber)]
    public Result<string> LeaseableAreaPerUnit { get; set; } = new Problem();

    [FieldName("executive_summary_unit_count", InternalFieldFormatType.FormatedNumber)]
    public Result<string> UnitCount { get; set; } = new Problem();

    // Other properties...
}

After running build command we should get something like this, code with the generated binding method:

public new void BindInternalFields(IDictionary<string, string> fieldValues)
{
    if (fieldValues.TryGetValue("executive_summary_leaseable_area", out var rawValue_LeaseableArea))
    {
        this.LeaseableArea = rawValue_LeaseableArea.FormatedByInternalFieldFormatType(global::InternalFieldFormatType.FormatedNumber);
    }
    if (fieldValues.TryGetValue("executive_summary_leaseable_area_per_unit", out var rawValue_LeaseableAreaPerUnit))
    {
        this.LeaseableAreaPerUnit = rawValue_LeaseableAreaPerUnit.FormatedByInternalFieldFormatType(global::InternalFieldFormatType.FormatedNumber);
    }
    if (fieldValues.TryGetValue("executive_summary_unit_count", out var rawValue_UnitCount))
    {
        this.UnitCount = rawValue_UnitCount.FormatedByInternalFieldFormatType(global::InternalFieldFormatType.FormatedNumber);
    }
    // Additional property bindings...
}

And generated getter for all field names which needs to be used in DTO:

/// <summary>
/// Public accessor for internal field names
/// </summary>
public new List<string> GetFieldNames => FieldNames;

/// <summary>
/// All internal field names
/// </summary>
public static readonly List<string> FieldNames = new List<string>
{
    "executive_summary_leaseable_area",
    "executive_summary_leaseable_area_per_unit",
    "executive_summary_usable_land_area",
    ... // other fields fetched from DB
};

After all this generated method may be used in our wrapper that perform trip to the database for necessary fields:

public static async Task<T> BindInternalFieldToModel <T>(this DbContext dbContext, int appraiseRequestId)
where T: InternalFieldBindable, new() 
{
    var model = new T();

    var fieldValues = await dbContext.FieldValues
        .Where(x => x.AppraiseRequestId == appraiseRequestId && model.GetFieldNames /*generated getter*/ .Contains(x.FieldName))
        .ToDictionaryAsync(x => x.FieldName, x => x.FieldValue);

    // generated method
    model.BindInternalFields(fieldValues);

    return model;
}

Bringing It All Together

By shifting from reflection-based binding to compile-time code generation:

  • Data Mapping Becomes Transparent:
    The generated code clearly defines which field names map to which DTO properties.

  • Performance Improves:
    Instead of using runtime reflection to inspect and map fields, the mapping is determined at compile time.

  • Type Safety Is Enhanced:
    Changes to the DTO or attribute definitions are caught during compilation rather than at runtime.

  • You can navigate to the generated file and see what will run, instead of "holding in the head" all magic that reflection does at the runtime.

This approach not only makes the code more maintainable but also ensures that your application handles data mapping in a robust and efficient manner.

Final Thoughts

Both approaches solve the problem of binding SQL table data to model properties, but they do so in very different ways:

  • Reflection-Based Code:
    Offers flexibility and quick setup but comes with runtime overhead and potential maintenance challenges.

  • Generator-Based Code:
    Moves the work to compile time, resulting in explicit, efficient, and robust binding code that’s easier to debug and maintain.

By examining each method and seeing how they all fit together into complete solutions, you gain insight into how to approach similar challenges in your own projects. Whether you choose the flexibility of reflection or the performance benefits of source generators, understanding both techniques enriches your toolbox as a developer.

Stay in the loop

Psss... want some goodies?