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.
Scatter/gather is a ServiceComposer pattern for aggregating data from multiple downstream HTTP services in parallel. When an incoming request arrives at a scatter/gather endpoint, ServiceComposer fans out — “scatters” — one HTTP request per configured gatherer, waits for all responses concurrently, then merges — “gathers” — the results into a single composed response returned to the original caller.
Prerequisites
Before mapping scatter/gather endpoints, register the required services in your DI container:
var builder = WebApplication.CreateBuilder();
builder.Services.AddRouting();
builder.Services.AddHttpClient();
builder.Services.AddScatterGather();
AddScatterGather() registers the internal plumbing. AddHttpClient() makes IHttpClientFactory available, which HttpGatherer uses to send downstream requests.
Basic setup
Map a scatter/gather endpoint with MapScatterGather, supplying a route template and a ScatterGatherOptions instance that lists your gatherers:
app.MapScatterGather(template: "api/scatter-gather", new ScatterGatherOptions()
{
Gatherers = new List<IGatherer>
{
new HttpGatherer(key: "ASamplesSource", destinationUrl: "https://a.web.server/api/samples/ASamplesSource"),
new HttpGatherer(key: "AnotherSamplesSource", destinationUrl: "https://another.web.server/api/samples/AnotherSamplesSource")
}
});
HttpGatherer requires two constructor arguments:
| Property | Description |
|---|
key | A unique string identifying this gatherer within the scope of a single composed request. Used as the HttpClient named-client key. |
destinationUrl | The full URL of the downstream endpoint to call. |
By default, HttpGatherer expects the downstream endpoint to return a JSON array, and each element is merged into the composed response.
Customizing downstream URLs
If the incoming request carries a query string, HttpGatherer appends it verbatim to destinationUrl via DefaultDestinationUrlMapper. You can replace this behaviour per gatherer using DestinationUrlMapper:
app.MapScatterGather(template: "api/scatter-gather", new ScatterGatherOptions()
{
Gatherers = new List<IGatherer>
{
new HttpGatherer("ASamplesSource", "https://a.web.server/api/samples/ASamplesSource")
{
DestinationUrlMapper = (request, destination) => destination.Replace(
"{this-is-contextual}",
request.Query["this-is-contextual"])
}
}
});
DefaultDestinationUrlMapper appends request.QueryString (including the leading ?) directly to destinationUrl. If destinationUrl already contains a query string this produces a malformed URL such as …?existing=1?new=2. In that case, supply a custom DestinationUrlMapper that concatenates with & instead.
By default HttpGatherer forwards every incoming request header to the downstream service through DefaultHeadersMapper. Three behaviours are available:
Set ForwardHeaders = false to prevent any header from being sent downstream:app.MapScatterGather(template: "api/scatter-gather", new ScatterGatherOptions()
{
Gatherers = new List<IGatherer>
{
new HttpGatherer("ASamplesSource", "https://a.web.server/api/samples/ASamplesSource")
{
ForwardHeaders = false
}
}
});
DefaultHeadersMapper forwards all headers verbatim, including Authorization and Cookie. If downstream services should receive a different credential or no credential at all, replace HeadersMapper to filter or substitute sensitive headers before the request is dispatched.
Handling downstream errors
By default, any failure in a gatherer — a network error or a non-2xx HTTP response — propagates as an exception and causes the entire composed request to fail. Set IgnoreDownstreamRequestErrors = true on an individual HttpGatherer to make that gatherer return an empty result instead, allowing the remaining gatherers to contribute a partial response:
app.MapScatterGather(template: "api/scatter-gather", new ScatterGatherOptions()
{
Gatherers = new List<IGatherer>
{
new HttpGatherer(key: "ASamplesSource", destinationUrl: "https://a.web.server/api/samples/ASamplesSource")
{
IgnoreDownstreamRequestErrors = true
},
new HttpGatherer(key: "AnotherSamplesSource", destinationUrl: "https://another.web.server/api/samples/AnotherSamplesSource")
}
});
IgnoreDownstreamRequestErrors catches HttpRequestException, which covers HTTP error status codes (thrown by EnsureSuccessStatusCode) and network-level failures reported by HttpClient. It does not catch other exception types such as TaskCanceledException (request timeout or cancellation). It does not affect other gatherers on the same route.
Custom gatherers
For non-HTTP data sources — in-memory data, a database, a message bus — implement IGatherer directly:
class CustomGatherer : IGatherer
{
public string Key { get; } = "CustomGatherer";
public Task<IEnumerable<object>> Gather(HttpContext context)
{
var data = (IEnumerable<object>)[new { Value = "ACustomSample" }];
return Task.FromResult(data);
}
}
The IGatherer interface:
public interface IGatherer
{
string Key { get; }
Task<IEnumerable<object>> Gather(HttpContext context);
}
Pass an instance of your custom gatherer in the Gatherers list just like an HttpGatherer.
If you need to reshape the raw HttpResponseMessage before results are merged, subclass HttpGatherer and override TransformResponse:
public class CustomHttpGatherer : HttpGatherer
{
public CustomHttpGatherer(string key, string destination) : base(key, destination) { }
protected override Task<IEnumerable<JsonNode>> TransformResponse(HttpResponseMessage responseMessage)
{
// retrieve the response as a string from the HttpResponseMessage
// and parse it as a JsonNode enumerable.
return base.TransformResponse(responseMessage);
}
}
Override Gather
To take full control of the downstream invocation, override Gather itself:
public class CustomHttpGatherer(string key, string destination) : HttpGatherer(key, destination)
{
public override Task<IEnumerable<JsonNode>> Gather(HttpContext context)
{
return base.Gather(context);
}
}
Output formatters and content negotiation
Scatter/gather endpoints can participate in ASP.NET Core MVC content negotiation by setting UseOutputFormatters = true in ScatterGatherOptions. When enabled, the response format is determined by the client’s Accept header rather than always producing JSON.
app.MapScatterGather(template: "api/scatter-gather", new ScatterGatherOptions()
{
UseOutputFormatters = true,
Gatherers = new List<IGatherer>
{
new HttpGatherer(key: "ASamplesSource", destinationUrl: "https://a.web.server/api/samples/ASamplesSource"),
new HttpGatherer(key: "AnotherSamplesSource", destinationUrl: "https://another.web.server/api/samples/AnotherSamplesSource")
}
});
To use output formatters, MVC services must be registered — for example via builder.Services.AddControllers().
When one gatherer fetches JSON and another fetches XML, and the client expects XML back, use typed gatherers and a CustomAggregator so that XML serializers know the element type at compile time:
Define the shared model and gatherers
public class SampleItem
{
public string Value { get; set; }
public string Source { get; set; }
}
public class JsonSourceGatherer() : Gatherer<SampleItem>("JsonSource")
{
public override Task<IEnumerable<SampleItem>> Gather(HttpContext context)
{
// fetch JSON from downstream service and deserialize to SampleItem[]
throw new NotImplementedException();
}
}
public class XmlSourceGatherer() : Gatherer<SampleItem>("XmlSource")
{
public override Task<IEnumerable<SampleItem>> Gather(HttpContext context)
{
// fetch XML from downstream service and parse to List<SampleItem>
throw new NotImplementedException();
}
}
public class TypedAggregator : IAggregator
{
readonly ConcurrentBag<SampleItem> allItems = new();
public void Add(IEnumerable<object> nodes)
{
foreach (var node in nodes)
{
allItems.Add((SampleItem)node);
}
}
public Task<object> Aggregate() => Task.FromResult<object>(allItems.ToArray());
}
Register services and map the endpoint
var builder = WebApplication.CreateBuilder();
builder.Services.AddControllers().AddXmlSerializerFormatters();
builder.Services.AddTransient<TypedAggregator>();
var app = builder.Build();
app.MapScatterGather(template: "api/scatter-gather", new ScatterGatherOptions()
{
UseOutputFormatters = true,
CustomAggregator = typeof(TypedAggregator),
Gatherers = new List<IGatherer>
{
new JsonSourceGatherer(),
new XmlSourceGatherer()
}
});
app.Run();
A client sending Accept: application/xml receives XML; a client sending Accept: application/json receives JSON — with the same gatherers and aggregator.
Configuration-based setup
Routes and their gatherers can be defined in appsettings.json (or any IConfiguration source), allowing you to change routes without recompiling.
JSON configuration shape
{
"ScatterGather": [
{
"Template": "api/products",
"Gatherers": [
{ "Key": "AProductsSource", "DestinationUrl": "https://one.web.server/api/a-source" },
{ "Key": "AnotherProductSource", "DestinationUrl": "https://two.web.server/api/another-source" }
]
}
]
}
Each entry supports the same options available in code, including UseOutputFormatters and IgnoreDownstreamRequestErrors per gatherer.
Mapping from configuration
var builder = WebApplication.CreateBuilder();
builder.Services.AddRouting();
builder.Services.AddHttpClient();
builder.Services.AddScatterGather();
var app = builder.Build();
app.MapScatterGather(builder.Configuration.GetSection("ScatterGather"));
app.Run();
Mixing configuration and code-defined routes
Both overloads of MapScatterGather can be combined in the same application:
// Routes loaded from appsettings.json (or any IConfiguration source)
app.MapScatterGather(configuration.GetSection("ScatterGather"));
// Additional route defined purely in code
app.MapScatterGather("api/other", new ScatterGatherOptions
{
Gatherers = new List<IGatherer>
{
new HttpGatherer("OtherSource", "https://other.web.server/api/items")
}
});
Customising configuration-defined routes
The optional customize callback is invoked for every route after ScatterGatherOptions is built from configuration but before the endpoint is registered:
app.MapScatterGather(
configuration.GetSection("ScatterGather"),
customize: (template, options) =>
{
if (template == "api/products")
{
// Inject an additional gatherer not present in the configuration file
options.Gatherers.Add(new HttpGatherer("Reviews", "https://reviews.web.server/api/reviews"));
}
});
Custom gatherer types in configuration
By default every gatherer entry creates an HttpGatherer. To use a different implementation, add a Type field in configuration and register a factory via AddGathererFactory:
Add Type to the configuration entry
{
"ScatterGather": [
{
"Template": "api/products",
"Gatherers": [
{ "Key": "ProductDetails", "DestinationUrl": "https://products.web.server/api/details" },
{ "Key": "StaticProductDetails", "Type": "StaticProductDetails" }
]
}
]
}
Implement the custom gatherer
class StaticProductDetails(string key) : IGatherer
{
public string Key { get; } = key;
public Task<IEnumerable<object>> Gather(HttpContext context)
{
var data = (IEnumerable<object>)[new { Value = "InStockItem" }];
return Task.FromResult(data);
}
}
Register the factory
var builder = WebApplication.CreateBuilder();
builder.Services.AddRouting();
builder.Services.AddHttpClient();
builder.Services.AddScatterGather(config =>
{
config.AddGathererFactory(
"StaticProductDetails",
(section, _) => new StaticProductDetails(section["Key"]));
});
The factory receives an IConfigurationSection for the entry and the application’s root IServiceProvider. Factories are invoked once at startup.Map from configuration as usual
var builder = WebApplication.CreateBuilder();
var app = builder.Build();
app.MapScatterGather(builder.Configuration.GetSection("ScatterGather"));
app.Run();
Factories are invoked once at startup, not per request. The IServiceProvider argument is the singleton root provider. Resolving a scoped service such as a DbContext from it will either throw a scope-validation error or silently produce a root-lifetime instance. If your gatherer needs per-request services, accept them via HttpContext.RequestServices inside IGatherer.Gather instead.
Because the factory receives the raw IConfigurationSection, any additional fields in the entry are available. Use section["FieldName"] for strings and section.GetValue<T>("FieldName") for typed values:
{
"ScatterGather": [
{
"Template": "api/products",
"Gatherers": [
{ "Key": "Inventory", "Type": "FilteredInventory", "Category": "Electronics", "MaxItems": 10 }
]
}
]
}
class GathererWithProperties(string key, string category, int maxItems) : IGatherer
{
public string Key { get; } = key;
public Task<IEnumerable<object>> Gather(HttpContext context)
{
// use category and maxItems to filter/limit results from a data source
var data = (IEnumerable<object>)[new { Category = category, MaxItems = maxItems }];
return Task.FromResult(data);
}
}
Register the factory with typed property extraction:
var builder = WebApplication.CreateBuilder();
builder.Services.AddRouting();
builder.Services.AddHttpClient();
builder.Services.AddScatterGather(config =>
{
config.AddGathererFactory(
"WithProperties",
(section, _) => new GathererWithProperties(
section["Key"],
section["Category"],
section.GetValue<int>("MaxItems")));
});
If a Type value is encountered in configuration but no matching factory has been registered, ServiceComposer throws a descriptive InvalidOperationException at startup listing the missing type and how to register it.