Server side composition in distributed systems (Model composition)

In part 1, we outlined the problem of having to compose data from different service boundaries.

In this post we will look at some specific implementation details.

Server Side Model Composition

There are 2 key parts to this implementation of server side model composition.

  1. A custom HTTP request handler.
  2. An Interface for interceptors to populate a view model with their own data.

HTTP Request Handler

The request handler is owned by a Devops service boundary. It is responsible for getting the interceptors for a URL, waiting for each interceptor to complete its work and returning the resulting composed view model.

We can register the custom handler in a project that provides our composed endpoint. The Devops service libraries are built, packaged and deployed to a NuGet server. The composed endpoint project references the Devops library via the NuGet server.

public class CompositionHandler
{
    private IInterceptorCache _interceptorCache;

    public CompositionHandler(IInterceptorCache interceptorCache)
    {
        _interceptorCache = interceptorCache;
    }

    public async Task<(dynamic ViewModel, int StatusCode)> HandleGetRequest(HttpContext context)
    {
        var composedViewModel = new DynamicViewModel(context);
        var pendingModelsToCompose = new List<Task>();
        var routeData = context.GetRouteData();
        
        try
        {
            var matchingInterceptors = _interceptorCache.GetInterceptorsForRoute();

            foreach (var individualModelsToCompose in matchingInterceptors)
            {
                pendingModelsToCompose.Add
                (
                    individualModelsToCompose.Compose(composedViewModel, routeData)
                );
            }

            if (pendingModelsToCompose.Count == 0)
            {
                return (null, StatusCodes.Status404NotFound);
            }

            await Task.WhenAll(pendingModelsToCompose);
            return (composedViewModel, StatusCodes.Status200OK);
        }
        catch (Exception ex)
        {
            Console.Error.WriteLine(ex.Message);
            return (null, StatusCodes.Status500InternalServerError);
        }
    }
}

For matching interceptors to routes you can create an interface with a match method.

public interface IRouteInterceptor
{
        bool Matches(RouteData routeData, string httpVerb);
}

The list of matches for a given URL are cached into a hash table. The handler retrieves matches from the hash table.

Populating The Composed View Model

All of the concrete interceptors implement an interface that is used to perform the composition.

public interface IViewModelComposer : IRouteInterceptor
{
        Task Compsose(dynamic vm, RouteData routeData);
}

The concrete implementation of interceptors live in a project owned by the service boundary team.

Each service boundary team create the interceptor to satisfy the data they need to provide to the view model.

public async Task Compose(dynamic vm, RouteData routeData)
{
            try
            {
                var id = (string) routeData.Values["id"];
                
                var response = await 
               _endpointClient.GetAsync(url).ConfigureAwait(false);

                dynamic invoiceData = await response.Content.AsExpandoAsync().ConfigureAwait(false);

                vm.InvoiceReference = invoiceData.InvoiceReference;
                vm.InvoicedOnUtc = invoiceData.InvoicedOnUtc;
                vm.InvoiceTotal = invoiceData.InvoiceTotal;
                vm.InvoiceStatus = invoiceData.InvoiceStatus;
            }
            catch(Exception ex)
            {
                System.Diagnostics.Debug.WriteLine(ex.Message);
            }
 }

The interceptor talks to services within its own boundary to populate only the data it is responsible for.

The service boundary team distribute their interceptors project to the composed endpoint project, again via NuGet.

Since all calls are task based each call to interceptors is multithreaded. Since each interceptor then calls backend services within its own boundary this takes away any performance impact.

We can build on this basic implementation to compose more complex views of data such as tabular data or to post data that belongs across different service boundaries.

This approach to server side model composition avoids coupling, avoids data duplication issues (such as eventual consistency) and ensures our service boundaries and teams responsible for those boundaries can operate autonomously.

There is no silver bullet to software architecture, different approaches are better at solving different problems. For example data composition is an excellent candidate for implementing search features.

Choose the approach that best suits what you want to achieve. As with everything there are trade offs. Spend time analysing these trade offs and be careful of engineering bias to guide you decisions.

Leave a Reply