ServiceComposer natively supports scatter/gather scenarios. Scatter/gather is supported through a fanout approach. Given an incoming HTTP request, ServiceComposer will issue as many downstream HTTP requests to fetch data from downstream endpoints. Once all data has been retrieved, they are composed and returned to the original upstream caller.
The following configuration configures a scatter/gather endpoint:
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")
}
});
The above configuration snippet configures ServiceComposer to handle HTTP requests matching the template. Each time a matching request is dealt with, ServiceComposer invokes each configured gatherer and merges responses from each one into a response returned to the original issuer.
The Key and Destination properties are mandatory. The key uniquely identifies each gatherer in the context of a specific request. The destination is the downstream URL of the endpoint to invoke to retrieve data.
If the incoming request contains a query string, the query string and its values are automatically appended to downstream URLs as is. It is possible to override that behavior by setting the DestinationUrlMapper delegate as presented in the following snippet:
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"])
}
}
});
The same approach can be used to customize the downstream URL before invocation.
Note: The default
DefaultDestinationUrlMapperappends the incoming query string by concatenatingrequest.QueryString(which includes the leading?). IfdestinationUrlalready contains a query string, this will produce a malformed URL (e.g.…?existing=1?new=2). In that case, replace the default mapper with one that uses&to append additional parameters.
By default, HttpGatherer forwards all incoming request headers to the downstream destination. This behavior is controlled by the ForwardHeaders property (default: true) and can be customized using the HeadersMapper delegate, following the same pattern as DefaultDestinationUrlMapper/DestinationUrlMapper.
Security note: The default
DefaultHeadersMapperforwards all headers verbatim, including sensitive ones such asAuthorizationandCookie. If the downstream service should receive a different credential (e.g. a service-to-service token) or no credential at all, replaceHeadersMapperto filter or substitute those headers before the request is dispatched.
To prevent any headers from being forwarded, set ForwardHeaders = false:
app.MapScatterGather(template: "api/scatter-gather", new ScatterGatherOptions()
{
Gatherers = new List<IGatherer>
{
new HttpGatherer("ASamplesSource", "https://a.web.server/api/samples/ASamplesSource")
{
ForwardHeaders = false
}
}
});
To selectively forward headers, replace the HeadersMapper delegate. The default implementation (DefaultHeadersMapper) copies all incoming request headers. Assign a custom delegate to filter, modify, add or remove headers before the downstream request is dispatched:
app.MapScatterGather(template: "api/scatter-gather", new ScatterGatherOptions()
{
Gatherers = new List<IGatherer>
{
new HttpGatherer("ASamplesSource", "https://a.web.server/api/samples/ASamplesSource")
{
HeadersMapper = (incomingRequest, outgoingMessage) =>
{
foreach (var header in incomingRequest.Headers)
{
if (header.Key.Equals("x-do-not-forward", StringComparison.OrdinalIgnoreCase))
continue;
outgoingMessage.Headers.TryAddWithoutValidation(header.Key, (IEnumerable<string>)header.Value);
}
}
}
}
});
To inject additional headers alongside the forwarded ones:
app.MapScatterGather(template: "api/scatter-gather", new ScatterGatherOptions()
{
Gatherers = new List<IGatherer>
{
new HttpGatherer("ASamplesSource", "https://a.web.server/api/samples/ASamplesSource")
{
HeadersMapper = (incomingRequest, outgoingMessage) =>
{
HttpGatherer.DefaultHeadersMapper(incomingRequest, outgoingMessage);
outgoingMessage.Headers.TryAddWithoutValidation("x-custom-header", "custom-value");
}
}
}
});
By default, HttpGatherer assumes that the downstream endpoint result can be converted into a JsonArray. Custom gatherers implementing IGatherer can return any object type; the default aggregator will serialize non-JSON values automatically.
Scatter/gather endpoints can participate in ASP.NET Core’s MVC content negotiation (JSON, XML, etc.) by setting UseOutputFormatters = true in ScatterGatherOptions. When enabled, the response format is determined by the client’s Accept header instead of 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 (e.g., builder.Services.AddControllers()).
Consider a scenario where one gatherer fetches a JSON response and another fetches XML, and the original request expects an XML response:
SampleItem[] so that XML
serializers (which need to know the element type at compile time) can serialize the result.UseOutputFormatters = true lets ASP.NET Core pick the right formatter based on the Accept header.The gatherers, model, and aggregator:
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 the aggregator and XML formatter, then configure 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 now receives XML; a client sending Accept: application/json receives JSON — with the same gatherers and aggregator.
If there is a need to transform downstream data to respect the expected format, it’s possible to create a custom gatherer and override the TransformResponse method:
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);
}
}
If transforming returned data is not enough, it’s possible to take full control over the downstream service invocation process by overriding the Gather method:
public class CustomHttpGatherer(string key, string destination) : HttpGatherer(key, destination)
{
public override Task<IEnumerable<JsonNode>> Gather(HttpContext context)
{
return base.Gather(context);
}
}
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.
Setting IgnoreDownstreamRequestErrors = true on an HttpGatherer makes that gatherer return an empty result instead of propagating an exception. The other gatherers continue normally, so a partial response is still returned.
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 applies to all error conditions for that gatherer: HTTP error status codes, network timeouts, and any other exception thrown by the downstream call. It does not affect other gatherers in the same route.
When using configuration-based setup, the same option can be set per entry:
{
"ScatterGather": [
{
"Template": "api/products",
"Gatherers": [
{ "Key": "Optional", "DestinationUrl": "https://optional.web.server/api/data", "IgnoreDownstreamRequestErrors": true },
{ "Key": "Required", "DestinationUrl": "https://required.web.server/api/data" }
]
}
]
}
It is possible to implement fully custom gatherers by implementing the IGatherer interface directly. This allows non-HTTP data sources, in-memory data, or any other data retrieval mechanism:
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);
}
}
Routes and their gatherers can be defined in an external configuration source such as appsettings.json, environment variables, or any other IConfiguration-compatible provider. This allows changes to be made without recompiling the application.
{
"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 in the array supports the same options available when calling MapScatterGather in code, including UseOutputFormatters.
Register scatter/gather services and call MapScatterGather (the IConfiguration overload), passing the configuration section that contains the route list:
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();
Calling MapScatterGather (the IConfiguration overload) and MapScatterGather (the template overload) in the same UseEndpoints block is fully supported. Routes defined in code are registered in addition to those loaded from configuration:
// 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")
}
});
The optional customize callback is invoked for every route after its ScatterGatherOptions has been built from configuration but before the endpoint is registered. Use it to add gatherers, override UseOutputFormatters, set a CustomAggregator, or apply any other per-route change:
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"));
}
});
The customize callback receives the route template string, making it easy to apply different changes to different routes from the same call.
By default, every gatherer entry in configuration creates an HttpGatherer. To plug in a different implementation — such as an in-memory source, a database reader, or a third-party gatherer — add a Type field to the gatherer entry and register a factory for that type.
{
"ScatterGather": [
{
"Template": "api/products",
"Gatherers": [
{ "Key": "ProductDetails", "DestinationUrl": "https://products.web.server/api/details" },
{ "Key": "StaticProductDetails", "Type": "StaticProductDetails" }
]
}
]
}
When Type is omitted the entry behaves exactly as before (backward-compatible). When Type is present, the factory registered under that name is called with the raw IConfigurationSection for the entry, allowing access to any additional fields.
Define 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 alongside the scatter/gather services:
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:
IConfigurationSection — the raw section for the gatherer entry (access any field via section["FieldName"])IServiceProvider — the application’s root (singleton) service providerLifetime note: Factories are invoked once at startup, not per request. The
IServiceProviderargument is the singleton root provider — resolving a scoped service (e.g. aDbContext) from it will either throw a scope-validation error or silently return a root-lifetime instance. If your gatherer needs per-request services, accept them viaHttpContext.RequestServicesinsideIGatherer.Gatherinstead.
Then map as usual:
var builder = WebApplication.CreateBuilder();
var app = builder.Build();
app.MapScatterGather(builder.Configuration.GetSection("ScatterGather"));
app.Run();
If a Type value is encountered in configuration but no matching factory has been registered, a descriptive InvalidOperationException is thrown at startup listing the missing type and how to register it.
Because the factory receives the raw IConfigurationSection, any additional fields present in the entry are available alongside Key and Type. Use section["FieldName"] for strings and section.GetValue<T>("FieldName") for typed values.
Given this configuration:
{
"ScatterGather": [
{
"Template": "api/products",
"Gatherers": [
{ "Key": "Inventory", "Type": "FilteredInventory", "Category": "Electronics", "MaxItems": 10 }
]
}
]
}
The gatherer reads those extra fields from its constructor:
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);
}
}
And the factory passes those values through at registration time:
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")));
});