In a system built from multiple autonomous services, each service owns its own data. Displaying a product page, for example, might require:
The simplest approach is to call multiple service APIs from the front end and merge the results there. But this couples the client to service internals, leaks service boundaries, and causes chattiness. Another common reaction is to share a database or build a dedicated aggregation service — but both approaches erode service autonomy and lead to a distributed monolith.
ViewModel Composition is a technique that solves this at the API gateway layer. Each service contributes its own slice of data through a small, independent handler. The gateway runs all handlers in parallel and returns a single merged response to the client. No service knows about the others, and the client makes a single request.
ServiceComposer is an ASP.NET Core implementation of this pattern.
Add the NuGet package to the gateway project:
dotnet add package ServiceComposer.AspNetCore
Configure ServiceComposer in Program.cs:
var builder = WebApplication.CreateBuilder();
builder.Services.AddRouting();
builder.Services.AddViewModelComposition();
var app = builder.Build();
app.MapCompositionHandlers();
app.Run();
That is all the gateway project needs. ServiceComposer scans loaded assemblies at startup and automatically discovers composition handlers.
Create a class library project for each service’s composition handlers (e.g. Sales.ViewModelComposition, Marketing.ViewModelComposition). Add a package reference to ServiceComposer.AspNetCore in each, then define a handler class.
A handler implements ICompositionRequestsHandler and is decorated with an [Http*] routing attribute matching the route it contributes to. Multiple handlers from different assemblies can be registered for the same route — they run in parallel and each writes its own properties onto the shared view model.
Sales handler — contributes price data:
public class SalesProductInfo : ICompositionRequestsHandler
{
[HttpGet("/product/{id}")]
public Task Handle(HttpRequest request)
{
var vm = request.GetComposedResponseModel();
//retrieve product details from the sales database or service
vm.ProductId = request.HttpContext.GetRouteValue("id").ToString();
vm.ProductPrice = 100;
return Task.CompletedTask;
}
}
Marketing handler — contributes name and description:
public class MarketingProductInfo: ICompositionRequestsHandler
{
[HttpGet("/product/{id}")]
public Task Handle(HttpRequest request)
{
var vm = request.GetComposedResponseModel();
//retrieve product details from the marketing database or service
vm.ProductName = "Sample product";
vm.ProductDescription = "This is a sample product";
return Task.CompletedTask;
}
}
Both handlers target the same route (/product/{id}), but they are completely independent: neither knows about the other, and they are free to evolve separately.
[!NOTE] Each service should own distinct, non-overlapping properties on the view model. Writing to the same property from two handlers running in parallel produces a data race. See thread safety for details.
Reference each handler class library from the gateway project. ServiceComposer’s assembly scanner picks up any ICompositionRequestsHandler implementation in a loaded assembly automatically.
<!-- In the gateway .csproj -->
<ItemGroup>
<ProjectReference Include="..\Sales.ViewModelComposition\Sales.ViewModelComposition.csproj" />
<ProjectReference Include="..\Marketing.ViewModelComposition\Marketing.ViewModelComposition.csproj" />
</ItemGroup>
In production, handlers are typically packaged as separate NuGet packages and referenced that way rather than via project references.
Run the gateway and issue a GET request to /product/1. The response is a single merged JSON object:
{
"productId": "1",
"productPrice": 100,
"productName": "Sample product",
"productDescription": "This is a sample product"
}
No client-side merging, no shared database, no knowledge between services.
When a request arrives at a composition endpoint, ServiceComposer:
Handle() methods in parallel via Task.WhenAllAdding a new service means adding a new handler class library. Existing handlers require no changes.
| Topic | When you need it |
|---|---|
| Strongly typed view models | Replace dynamic with a concrete class |
| Events | Coordinate between handlers within a request (e.g. composing lists) |
| Model binding | Bind request body, route values, and query strings in handlers |
| Contract-less handlers | Controller-action syntax without implementing an interface |
| Authentication and authorization | Secure composition routes |
| Thread safety | Handle shared dependencies safely |
| Scatter/Gather | Fan out HTTP requests to downstream services |