Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/ServiceComposer/ServiceComposer.AspNetCore/llms.txt

Use this file to discover all available pages before exploring further.

ServiceComposer executes all composition handlers registered for a given request concurrently using Task.WhenAll. Understanding this execution model is essential for writing handlers that are both correct and efficient, particularly when those handlers share state or depend on services that are not safe for concurrent use.

Handler execution model

Every incoming HTTP request causes ServiceComposer to:
  1. Resolve one fresh instance of each registered handler (transient lifetime).
  2. Invoke all handlers concurrently via Task.WhenAll.
  3. Collect the results and compose the final response.
Because each handler receives its own instance, two handlers on the same route never share a handler object. However, they do share the single composed view model for that request, and they share any DI-registered services whose lifetime spans multiple handlers.

Handler lifetime

All composition components — handlers, event subscribers, and event handlers — are registered in the DI container as transient. A new instance is created for each request, and two concurrent HTTP requests never share handler instances.
Two concurrent HTTP requests get independent sets of handler instances. Concurrency concerns apply within a single request — among the multiple handlers executing in parallel for that request.

The shared view model

Within a single request, every handler writes to the same view model object (dynamic ExpandoObject by default, or a strongly typed instance if a view model factory is configured). Because handlers run in parallel, any write to the view model is subject to concurrent access. ExpandoObject is not thread-safe. In practice, writes targeting completely independent properties are usually safe, but the following patterns create data races:

Unsafe — same property

Two handlers writing the same property simultaneously produce undefined behaviour. The last write wins — non-deterministically.

Safe — independent properties

Each handler owns a distinct, non-overlapping set of properties. No two handlers write the same key, so no synchronisation is needed.
Design your composition so that each service (and its handler) owns distinct, non-overlapping properties on the view model. Shared ownership of a single property across handlers is a data race.

Singleton and scoped dependencies

DI-registered services injected into handlers follow their own registered lifetimes:
LifetimeShared across…Thread-safety requirement
SingletonAll requests and all handlersMust be fully thread-safe
ScopedAll handlers within the same requestMust tolerate concurrent access from parallel handlers
TransientNot shared (new instance per resolution)No requirement
A scoped service is created once per HTTP request. Because multiple handlers within that request run concurrently, they receive the same scoped instance. If the service is not safe for concurrent use — a DbContext is the canonical example — sharing it across parallel handlers will corrupt internal state.

Using scoped dependencies safely with child scopes

The safest pattern for non-thread-safe scoped services is to create a short-lived child DI scope inside each handler that needs the service. CreateAsyncScope() produces a scope whose lifetime is tied to the await using block, ensuring deterministic disposal:
public class SalesHandler : ICompositionRequestsHandler
{
    readonly IServiceProvider _serviceProvider;

    public SalesHandler(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }

    [HttpGet("/product/{id}")]
    public async Task Handle(HttpRequest request)
    {
        await using var scope = _serviceProvider.CreateAsyncScope();
        var db = scope.ServiceProvider.GetRequiredService<SalesDbContext>();

        var vm = request.GetComposedResponseModel();
        vm.ProductPrice = await db.GetProductPriceAsync(
            request.RouteValues["id"].ToString());
    }
}
The SalesDbContext resolved from the child scope is private to this handler invocation and is never shared with any other handler running in parallel.
Inject IServiceProvider rather than SalesDbContext directly. This defers resolution to handler execution time and allows you to create an isolated scope, giving you full control over the service’s lifetime.

Composition events

Event handlers (ICompositionEventsHandler<T> and route-scoped subscribers) also run in parallel with each other and with composition handlers. The same threading considerations apply:
  • Each event handler instance is transient — not shared across requests.
  • All event handlers within a single request share the same view model.
  • Scoped services resolved from the request scope are shared by all event handlers running concurrently in that request.
Apply the same child-scope pattern to event handlers that depend on non-thread-safe services.

Summary checklist

  • View model properties are non-overlapping. No two handlers on the same route write to the same property key.
  • Singleton services are thread-safe. Any singleton injected into handlers must tolerate concurrent calls from multiple requests.
  • Non-thread-safe scoped services use child scopes. Services such as DbContext are resolved inside await using var scope = _serviceProvider.CreateAsyncScope() rather than injected directly.
  • Shared mutable state is avoided. Handlers should not maintain mutable static fields or closure-captured state that can be observed by concurrent requests.

Build docs developers (and LLMs) love