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:
- missing, non-public or unregistered handlers
- handlers on the wrong sync/async interface
- decorators that are on the wrong path or not registered
- two decorators with the same step number
- a fallback placed inside a retry
- lifetime problems such as a captive dependency
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:
- A Singleton handler was being disposed after its first query.
- Scoped dependencies were resolved from the root provider instead of the request's scope.
Smaller things
- Pass your own context. Execute and ExecuteAsync now accept an IQueryContext.
- Built-in test doubles. SimpleHandlerFactory, SimpleHandlerDecoratorFactory and the InMemory registries are now public in core, so you can run Darker in tests or without a DI container.
- AOT-friendly logging. Query logging uses System.Text.Json.
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:
- Packages consolidated.
- The Policies and QueryLogging side packages are now part of Paramore.Darker.
- Paramore.Darker.AspNetCore has been renamed to Paramore.Darker.Extensions.DependencyInjection.
- The SimpleInjector and LightInject packages have been removed. Microsoft.Extensions.DependencyInjection is the only DI integration Darker supports.
- Sync and async are fully separate. Handlers, decorators, attributes and factories now come in separate sync and async types, for example IQueryHandler and IQueryHandlerAsync. No single type serves both paths any more, so if you implemented IQueryHandler<,> directly and relied on both paths, you'll need to choose one.
- Newtonsoft.Json is gone. Logging uses System.Text.Json now. The AddJsonQueryLogging callback takes JsonSerializerOptions instead of JsonSerializerSettings. If you were getting Newtonsoft.Json through Darker, add your own reference to it.
- [RetryableQuery] is obsolete. It still works in V5, but you'll get compiler warnings, and it will be removed in V6. Move to [UseResiliencePipeline] and a ResiliencePipelineRegistry. The new defaults are not the same as the old ones, so review your retry and circuit-breaker settings.
- contextBagData has been removed. Pass an IQueryContext instead.
- Custom factories need updating.
- On the async factories, Release is now ValueTask ReleaseAsync.
- IAmALifetime now extends IAsyncDisposable.
- Handler factories must implement CreatePipelineScope().
- IAmALifetime.Add has been removed.
- Container-backed decorator factories must resolve from lifetime.PipelineScope.
- Teardown no longer throws. A failing release is logged at Error level instead of being thrown, so watch your logs rather than catching these exceptions.
- IAsyncDisposable is honoured. On the async and stream paths, the Simple factories now await DisposeAsync. A Scoped service that implements only IAsyncDisposable won't be disposed on the sync Execute path.
- Decorators are Transient under a Scoped or Transient handler lifetime, so don't count on one decorator instance being shared across a scope.
- A Scoped QueryProcessor was never the fix for EF Core. It gets its own scope next to the request's, not the request's scope itself. Use Scoped handlers with AddDarkerRequestScope() instead. Note also that concurrent queries within one request need ScopeAffinity.AlwaysNew.
- New dependency. The DI package now references Microsoft.Extensions.Hosting.Abstractions, so check for version conflicts with your hosting packages.
- Validation is strict about visibility. An internal handler that you register explicitly still runs, but it fails ValidatePipelines().
- Framework floors. The caching and AspNetCore packages need net8.0 or later. The other packages also support netstandard2.0.
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.