Darker

Darker is Brighter's counterpart library. You can use Brighter and Darker in combination when you create a Command Query Responsibility Separation (CQRS) application. Under that approach, Brighter handles Commands, and Darker handles Queries. Brighter can also handle Domain Events.

Both Brighter and Darker implement the Command Dispatcher pattern and support a middleware pipeline between the sender and receiver. The middleware pipeline has a “Russian Doll” model; each piece of middleware encapsulates the next one in the chain.

Darker V5

We have not released Darker for a while. For Brighter, V10 was a significant capability upgrade. Darker V5 brings us comparable functionality to Brighter.

This post covers the main points. The README and the ADRs in docs/adr have the details.

Streaming queries

Handlers can now yield results one at a time as IAsyncEnumerable, so large result sets never have to be held in memory all at once. Streaming has its own pipeline, and logging, tracing, and resilience all work on it.


public sealed class GetOrdersStream : IStreamQuery<Order> { ... }

public sealed class GetOrdersStreamHandler : StreamQueryHandler<GetOrdersStream, Order>
{
    public override async IAsyncEnumerable<Order> ExecuteAsync(
        GetOrdersStream query,
        [EnumeratorCancellation] CancellationToken ct = default)
    {
        await foreach (var order in _repository.StreamByCustomerAsync(query.CustomerId, ct))
            yield return order;
    }
}

await foreach (var order in queryProcessor.ExecuteStream(new GetOrdersStream("C42"), ct))
    Process(order);

Polly V8 resilience pipelines

The new [UseResiliencePipeline] attributes use a Polly V8 ResiliencePipelineRegistry, which is the same approach Brighter takes.


services.AddDarker()
    .AddHandlersFromAssemblies(typeof(GetFooHandler).Assembly)
    .AddDefaultResiliencePipelines();   // or .AddResiliencePipelines(myRegistry)

[UseResiliencePipelineAttributeAsync(3, Constants.RetryPipelineName)]
public override Task<string> ExecuteAsync(GetFoo query, CancellationToken ct = default) { ... }

OpenTelemetry tracing and metrics

Each query now produces a query span. The span records an event for each step in the pipeline and records any exceptions. Handlers can attach their own database spans to it. Metrics are worked out from the traces: duration histograms, plus counts and failures by error.type. You choose how much query detail goes into the spans with InstrumentationOptions. The core package has no OpenTelemetry SDK dependency; the wiring is in Paramore.Darker.Extensions.Diagnostics.


services.AddDarker(o => o.InstrumentationOptions = InstrumentationOptions.QueryInformation);

services.AddOpenTelemetry()
    .WithTracing(t => t.AddDarkerInstrumentation())
    .WithMetrics(m => m.AddDarkerInstrumentation());

Agreement dispatch

This works like Brighter's Agreement Dispatcher. You can route a query to a handler based on the query's content and its context, not only its type. A typical use is switching to a new way of calculating a metric after a cut-over date.


services.AddDarker()
    .AddAsyncHandlers(r => r.Register<GetRevenue, decimal>(
        (query, context) => query.Date < CutOver ? typeof(LegacyRevenueHandler) : typeof(RevenueHandler),
        typeof(LegacyRevenueHandler), typeof(RevenueHandler)));

Query validation

[ValidateQuery] is a validation decorator that doesn't depend on any one validation library. Two provider packages plug into it, one for FluentValidation and one for DataAnnotations.


services.AddDarker()
    .AddHandlersFromAssemblies(...)
    .UseFluentValidation();   // or .UseDataAnnotations()

[ValidateQueryAttributeAsync(4)]
public override Task<Person> ExecuteAsync(GetPerson query, CancellationToken ct = default) { ... }

Result caching

[CacheableQuery] caches query results using Microsoft's HybridCache, and you can plug in FusionCache through DI. On a cache hit the rest of the pipeline is skipped. Each cached entry needs an expiry. You can replace how cache keys are built with your own ICacheKeyGenerator. To evict entries yourself, put a tag in the context bag under CacheableQueryAttribute.CacheTag. Hits and misses show up in traces and metrics. This package needs net8.0 or later.


services.AddHybridCache();
services.AddDarker().AddHandlersFromAssemblies(...).AddCaching();

[CacheableQueryAttributeAsync(step: 5, expirationSeconds: 60)]
public override Task<IReadOnlyList<Person>> ExecuteAsync(GetPeople query, CancellationToken ct = default) { ... }

Sharing the ASP.NET Core request scope (and your DbContext)

Darker query handlers can now use the same Scoped services as your controllers and Brighter command handlers in one request, such as an EF Core DbContext. This comes from the new Paramore.Darker.Extensions.AspNetCore package.


services.AddDarkerRequestScope();
services.AddDarker(o => o.HandlerLifetime = ServiceLifetime.Scoped)
    .AddHandlersFromAssemblies(typeof(MyHandler).Assembly);

Startup validation of pipelines

Calling ValidatePipelines() checks every registered handler and its decorators when the host starts, and reports every problem in one go. That means you no longer find mistakes one at a time on the first query. It looks for:

If you want to run the same checks on demand, for example in a test or a health check, use provider.ValidateAll().


services.AddDarker()
    .AddHandlersFromAssemblies(...)
    .AddDefaultResiliencePipelines()
    .ValidatePipelines();   // call it last

Lifetime-aware factories and safer teardown

Handler and decorator factories now follow the lifetime you configure (Singleton, Scoped or Transient). Each query gets its own scope.

Teardown is also safer. If releasing one component fails, the failure is logged and the rest are still released. Teardown never throws, so it can't hide the handler's own exception.

This fixes two old bugs:

Smaller things

Changes to be aware of

The full list of breaking changes, with a migration step for each, is in release_notes.md. The main ones:

Try it

5.0.0-preview1 is on NuGet now. Please try it and tell us how you get on by opening an issue or joining discussion #273.

Copyright © Ian Cooper 2024+ All Rights Reserved.