Source Generators - BisocM/CQRSharp GitHub Wiki

Introduction

The library heavily relies on the functionality of source generators - compile-time ran code - to get rid of boilerplate code and make the usage of the library as simplified as possible. It is responsible for performing compile-time discovery of handlers, request types, request metadata generation and pipeline generation, so that we can avoid the usage of reflection in the future.

Architecture

Currently, we only really handle generated source code in the context of registries - e.g., providing us with a ConcurrentDictionary that we can later hook up with some methods, query, filter, and use. The architecture revolves around the Registrar and the IDataRegistrar:

namespace CQRSharp.Core.SourceGeneration;

/// <summary>
///     Provides static access to generated registrars for various subsystems
///     (e.g. handler registration, pipeline registration) within the CQRSharp library.
///     The source generators will assign generated instances to these fields automatically.
/// </summary>
public static class Registrar
{
    /// <summary>
    ///     Gets or sets the generated data registrar responsible for handler registration.
    ///     The source generator sets this value via a module initializer.
    /// </summary>
    public static IDataRegistrar? HandlerRegistrar { get; set; }

    //...other registrars
}
using Microsoft.Extensions.DependencyInjection;

namespace CQRSharp.Core.SourceGeneration;

/// <summary>
///     Defines a contract for a data registrar that registers various handlers,
///     pipelines, or other constructs with a dependency injection container.
///     The CQRSharp library uses this interface to hook into the generated registration
///     logic without having to implement any runtime discovery.
/// </summary>
public interface IDataRegistrar
{
    /// <summary>
    ///     Registers data, such as handlers or pipeline components, with the specified <see cref="IServiceCollection" />.
    ///     The implementation is provided by the source generator.
    /// </summary>
    /// <param name="services">The service collection where registrations should be added.</param>
    void RegisterData(IServiceCollection services);
}

This methodology utilizes the ModuleInitializerAttribute, that runs on the startup of the application, to dynamically hook the code generated by the source generators into the CQRSharp assembly - preventing any direct references between CQRSharp and CQRSharp.Generators.

Addition of Registries

To add another registry, you need to:

  • Define the registry interface
  • Provide a registry concrete implementation, ensuring that the constructor takes the ConcurrentDictionary type as an argument

Here is an example of a registry implementation:

namespace CQRSharp.Core.Caching.Pipelines;

/// <summary>
///     Provides access to a mapping from request types to precompiled pipeline builder delegates.
/// </summary>
public interface IPipelineRegistry
{
    /// <summary>
    ///     Retrieves the pipeline builder delegate associated with the specified request type.
    /// </summary>
    /// <param name="requestType">The type of the request for which the pipeline builder delegate is to be retrieved.</param>
    /// <returns>The pipeline builder delegate mapped to the provided request type.</returns>
    public PipelineBuilderDelegate? GetPipelineBuilder(Type requestType);
}

/// <summary>
///     Delegate that builds the execution pipeline for a given request.
///     It takes an IServiceProvider, the request as an object, a final‐handler delegate,
///     and a cancellation token, returning a Task that yields an object result.
/// </summary>
public delegate Task<object> PipelineBuilderDelegate(
    IServiceProvider services,
    object request,
    Func<CancellationToken, Task<object>> finalHandler,
    CancellationToken cancellationToken);
using System.Collections.Concurrent;

namespace CQRSharp.Core.Caching.Pipelines;

/// <summary>
///     Represents a registry for managing and retrieving pipeline builder delegates
///     mapped to specific request types. The creation of the concurrent dictionary happens in the source code generation
///     part of the library.
/// </summary>
public class PipelineRegistry(ConcurrentDictionary<Type, PipelineBuilderDelegate> pipelineMappings) : IPipelineRegistry
{
    /// <inheritdoc />
    public PipelineBuilderDelegate? GetPipelineBuilder(Type requestType)
    {
        pipelineMappings.TryGetValue(requestType, out var pipelineBuilder);
        return pipelineBuilder;
    }
}

And, in the generated code, after generating the ConcurrentDictionary with all the required pipeline data, we simply do the following:

    public sealed class GeneratedPipelineRegistrar : IDataRegistrar
    {
        /// <summary>
        /// A mapping of request types to their corresponding pipeline builder delegates.
        /// </summary>
        private readonly ConcurrentDictionary<Type, PipelineBuilderDelegate> _pipelineMap;

        /// <summary>
        /// Initializes a new instance of the <see cref="GeneratedPipelineRegistrar"/> class.
        /// </summary>
        public GeneratedPipelineRegistrar()
        {
            _pipelineMap = new ConcurrentDictionary<Type, PipelineBuilderDelegate>();

            //...various pipeline TryAdd calls here to populate the dict...
        }

        /// <inheritdoc />
        public void RegisterData(IServiceCollection services)
        {
            //Register the pipeline map as a singleton IPipelineRegistry.
            services.AddSingleton<IPipelineRegistry>(new PipelineRegistry(_pipelineMap));
        }
    }

    /// <summary>
    /// A module initializer that assigns the generated pipeline registrar to the global Registrar.
    /// </summary>
    public static class GeneratedPipelineRegistrarInitializer
    {
        [ModuleInitializer]
        public static void Initialize()
        {
            try
            {
                Registrar.PipelineRegistryRegistrar = new GeneratedPipelineRegistrar();
            }
            catch (Exception ex)
            {
                throw new InvalidOperationException("Failed to initialize GeneratedPipelineRegistrar.", ex);
            }
        }
    }

After populating the Registrar with the correct method, we simply call it in the extensions for dependency injection: {

    /// <summary>
    ///     Registers the pipeline registry using generated pipeline builders.
    ///     Looks for a generated type in the known namespace and uses it if available.
    /// </summary>
    /// <param name="services">The service collection to which the handler registries will be registered.</param>
    private static void AddGeneratedPipelineRegistry(this IServiceCollection services)
    {
        Registrar.PipelineRegistryRegistrar?.RegisterData(services);
    }

Now, when required, we can simply inject the IPipelineRegistry interface into any class we require, and utilize the functionality as needed.

⚠️ **GitHub.com Fallback** ⚠️