Documentation

adrs/023-component-service-registration.md

ADR-023: Component Service Registration Pattern

Status

Accepted

Context

The Dynaplex architecture requires a consistent pattern for registering component services, endpoints, and dependencies during application startup. With multiple components each having their own services, repositories, validators, and configurations, we need a standardized approach that maintains consistency while allowing flexibility.

Requirements:

  • Clear separation of concerns between components
  • Consistent registration pattern across all services
  • Support for component-specific configuration
  • Easy to understand and maintain
  • Testable service registration

Decision

We will use the MapAcsisEndpoints convention as the standard pattern for component service registration, combining endpoint mapping with service configuration in a unified extension method approach.

Pattern:

// In Program.cs
app.MapAcsisEndpoints(endpoints =>
{
    endpoints.MapCoreDataEndpoints();
    endpoints.MapIdentityEndpoints();
    endpoints.MapCatalogEndpoints();
});

Consequences

Positive

  • Consistency: Single pattern for all component registration
  • Discoverability: All registrations in one place
  • Modularity: Components are self-contained
  • Flexibility: Components control their own registration
  • Testability: Registration can be tested in isolation
  • Convention: Follows ASP.NET Core patterns

Negative

  • Hidden Complexity: Service registration hidden in extension methods
  • Order Dependencies: Some registrations may depend on order
  • Debugging: Harder to debug registration issues
  • Documentation: Must document what each registration does

Neutral

  • Convention-Based: Relies on naming conventions
  • Extension Methods: Uses C# extension method pattern
  • Component Coupling: Components must follow the pattern

Implementation Notes

Core Registration Extension

namespace Acsis.Dynaplex.Projects.BbuRfid.ServiceDefaults;

public static class AcsisEndpointExtensions
{
    /// <summary>
    /// Maps all Acsis component endpoints and configures Scalar API documentation
    /// </summary>
    public static IEndpointRouteBuilder MapAcsisEndpoints(
        this IEndpointRouteBuilder endpoints,
        Action<IEndpointRouteBuilder> configureEndpoints)
    {
        // Configure component endpoints
        configureEndpoints(endpoints);

        // Add Scalar API documentation
        endpoints.MapScalarApiReference(options =>
        {
            options.Title = "Dynaplex API";
            options.Theme = ScalarTheme.Saturn;
            options.DarkMode = true;
            options.RoutePrefix = "/scalar";
        });

        // Add OpenAPI endpoint
        endpoints.MapOpenApi();

        // Add health checks
        endpoints.MapHealthChecks("/health");

        return endpoints;
    }
}

Component Registration Pattern

namespace Acsis.Dynaplex.Engines.Catalog;

public static class CatalogServiceExtensions
{
    /// <summary>
    /// Adds Catalog component services to the DI container
    /// </summary>
    public static IServiceCollection AddCatalogServices(
        this IServiceCollection services,
        IConfiguration configuration)
    {
        // Add database context
        services.AddDbContext<CatalogDb>(options =>
        {
            options.UseSqlServer(configuration.GetConnectionString("catalog"));
        });

        // Add repositories
        services.AddScoped<IAssetRepository, AssetRepository>();
        services.AddScoped<IAssetTypeRepository, AssetTypeRepository>();

        // Add services
        services.AddScoped<IAssetService, AssetService>();
        services.AddScoped<IAssetTypeService, AssetTypeService>();

        // Add validators
        services.AddScoped<IValidator<CreateAssetRequest>, CreateAssetValidator>();
        services.AddScoped<IValidator<UpdateAssetRequest>, UpdateAssetValidator>();

        // Add API client for external calls
        services.AddHttpClient<CatalogApiClient>(client =>
        {
            client.BaseAddress = new Uri(configuration["Services:Catalog:Url"]);
        });

        // Add caching
        services.AddMemoryCache();
        services.AddSingleton<ICatalogCache, CatalogCache>();

        // Add background services
        services.AddHostedService<AssetSyncService>();

        return services;
    }

    /// <summary>
    /// Maps Catalog component endpoints
    /// </summary>
    public static IEndpointRouteBuilder MapCatalogEndpoints(
        this IEndpointRouteBuilder endpoints)
    {
        var group = endpoints.MapGroup("/api/catalog")
            .WithTags("Catalog")
            .RequireAuthorization();

        // Asset endpoints
        group.MapAssetEndpoints();

        // Asset Type endpoints
        group.MapAssetTypeEndpoints();

        // Category endpoints
        group.MapCategoryEndpoints();

        return endpoints;
    }
}

Complete Program.cs Example

using Acsis.Dynaplex.Projects.BbuRfid.ServiceDefaults;

var builder = WebApplication.CreateBuilder(args);

// Add service defaults (Aspire integration)
builder.AddServiceDefaults();

// Add component services
builder.Services.AddCoreDataServices(builder.Configuration);
builder.Services.AddIdentityServices(builder.Configuration);
builder.Services.AddCatalogServices(builder.Configuration);
builder.Services.AddWorkflowServices(builder.Configuration);

// Add API services
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddOpenApiDocument();

// Add authentication
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer();
builder.Services.AddAuthorization();

// Add CORS if needed
if (EnvironmentDetector.IsRunningInContainer)
{
    builder.Services.AddCors(options =>
    {
        options.AddDefaultPolicy(policy =>
        {
            policy.WithOrigins(builder.Configuration["Cors:Origins"])
                .AllowAnyMethod()
                .AllowAnyHeader()
                .AllowCredentials();
        });
    });
}

var app = builder.Build();

// Configure pipeline
app.UseAuthentication();
app.UseAuthorization();

if (app.Environment.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
}

if (EnvironmentDetector.IsRunningInContainer)
{
    app.UseCors();
}

// Map all component endpoints
app.MapAcsisEndpoints(endpoints =>
{
    endpoints.MapCoreDataEndpoints();
    endpoints.MapIdentityEndpoints();
    endpoints.MapCatalogEndpoints();
    endpoints.MapWorkflowEndpoints();
    endpoints.MapEventsEndpoints();
    endpoints.MapFileServiceEndpoints();
});

app.Run();

Service Registration Validation

public static class ServiceRegistrationValidator
{
    public static IServiceCollection ValidateServices(
        this IServiceCollection services)
    {
        var serviceProvider = services.BuildServiceProvider();

        // Validate critical services are registered
        ValidateService<CatalogDb>(serviceProvider, "CatalogDb");
        ValidateService<IAssetService>(serviceProvider, "IAssetService");
        ValidateService<IAssetRepository>(serviceProvider, "IAssetRepository");

        return services;
    }

    private static void ValidateService<T>(IServiceProvider provider, string name)
    {
        var service = provider.GetService<T>();
        if (service == null)
        {
            throw new InvalidOperationException(
                $"Required service {name} is not registered");
        }
    }
}

// Use in Program.cs
builder.Services
    .AddCatalogServices(builder.Configuration)
    .ValidateServices();

Component Configuration Pattern

public class CatalogOptions
{
    public const string SectionName = "Catalog";

    public string ConnectionString { get; set; } = string.Empty;
    public int CacheExpirationMinutes { get; set; } = 60;
    public bool EnableBackgroundSync { get; set; } = true;
    public int MaxPageSize { get; set; } = 100;
}

public static class CatalogServiceExtensions
{
    public static IServiceCollection AddCatalogServices(
        this IServiceCollection services,
        IConfiguration configuration)
    {
        // Bind configuration
        services.Configure<CatalogOptions>(
            configuration.GetSection(CatalogOptions.SectionName));

        // Register with options
        services.AddSingleton<IValidateOptions<CatalogOptions>,
            CatalogOptionsValidator>();

        // Use options in services
        services.AddScoped<IAssetService>(provider =>
        {
            var options = provider.GetRequiredService<IOptions<CatalogOptions>>();
            var db = provider.GetRequiredService<CatalogDb>();
            var cache = provider.GetRequiredService<IMemoryCache>();

            return new AssetService(db, cache, options.Value);
        });

        return services;
    }
}

Testing Service Registration

[TestFixture]
public class CatalogRegistrationTests
{
    [Test]
    public void AddCatalogServices_RegistersAllRequiredServices()
    {
        // Arrange
        var services = new ServiceCollection();
        var configuration = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string>
            {
                ["ConnectionStrings:catalog"] = "Server=test;Database=test;"
            })
            .Build();

        // Act
        services.AddCatalogServices(configuration);
        var provider = services.BuildServiceProvider();

        // Assert
        provider.GetService<CatalogDb>().Should().NotBeNull();
        provider.GetService<IAssetService>().Should().NotBeNull();
        provider.GetService<IAssetRepository>().Should().NotBeNull();
    }

    [Test]
    public void MapCatalogEndpoints_RegistersExpectedEndpoints()
    {
        // Arrange
        var builder = WebApplication.CreateBuilder();
        var app = builder.Build();

        // Act
        app.MapCatalogEndpoints();

        // Assert
        var dataSource = app.Services.GetRequiredService<EndpointDataSource>();
        var endpoints = dataSource.Endpoints;

        endpoints.Should().Contain(e => e.DisplayName.Contains("assets"));
        endpoints.Should().Contain(e => e.DisplayName.Contains("types"));
    }
}

Startup Diagnostics

public static class StartupDiagnostics
{
    public static IApplicationBuilder UseStartupDiagnostics(
        this IApplicationBuilder app,
        ILogger<Program> logger)
    {
        var services = app.ApplicationServices;

        // Log registered services
        logger.LogInformation("=== Registered Services ===");

        LogService<CatalogDb>(services, logger, "CatalogDb");
        LogService<IAssetService>(services, logger, "AssetService");

        // Log registered endpoints
        var endpointDataSource = services.GetRequiredService<EndpointDataSource>();
        logger.LogInformation("=== Registered Endpoints ===");

        foreach (var endpoint in endpointDataSource.Endpoints)
        {
            if (endpoint is RouteEndpoint routeEndpoint)
            {
                logger.LogInformation("Endpoint: {Pattern} [{Methods}]",
                    routeEndpoint.RoutePattern.RawText,
                    string.Join(", ", routeEndpoint.Metadata
                        .OfType<HttpMethodMetadata>()
                        .SelectMany(m => m.HttpMethods)));
            }
        }

        return app;
    }

    private static void LogService<T>(IServiceProvider services, ILogger logger, string name)
    {
        var service = services.GetService<T>();
        logger.LogInformation("Service {Name}: {Status}",
            name,
            service != null ? "Registered" : "NOT REGISTERED");
    }
}

Best Practices

  1. Group related registrations in component-specific extension methods
  2. Validate critical services during startup
  3. Use options pattern for component configuration
  4. Document dependencies in XML comments
  5. Test registration with unit tests
  6. Log registration in development for debugging
  7. Order matters - register dependencies before dependents
  • ADR-013: Minimal APIs with Static Classes (endpoint definition)
  • ADR-015: Extension Methods for Endpoint Registration (registration pattern)
  • ADR-007: Migration to .NET Aspire Microservices (service architecture)