Composite UIs for Microservices - Server Composition
Posts in this series:
In the last post, we looked at techniques for composing on the client side. One of the issues we saw is there aren't really a lot of tools to perform composition, nor are there explicit building blocks to do so. The story is largely the same on the server side, where we don't have a lot of built-in hooks to compose.
But before we get too far into the details, let's pull back and examine where on the server side we might want to compose. In most modern web server frameworks, we have some form of MVC, which gives us 3 avenues to compose:
- Controller
- Model
- View
Let's eliminate the controller from the discussion here; controllers are mainly coordinators and route definitions so they're typically not what's getting composed. Instead, it's back to the view and the information being displayed. If we break apart our UI into individual sections, we see two composition forces at play:
Individual components/widgets on our screen have logically separate space on the screen, and logically separate data that feeds into their rendering. A menu bar on the left will pull from different sources than the details section in the body. They both might use the same input (product ID from the URL for example), but ultimately query from disparate sources.
Looking at a single widget, however, we might have a similar situation on the client, where the data for a single widget might need to be composed from different sources. As it is with most design decisions, it's not an either-or choice between view and model composition. The needs of our UI need to drive our composition choices.
First, let's look at view composition, as it's the built-in choice for us.
View Composition
In view composition, we're breaking up our overall rendering into individual components on the server side. Instead of having a single rendering path build out the data for the entire screen to render at once, we leverage individual component rendering pipelines for each widget.
Typically, it's the view itself that dictates where and how to render each widget (as it should), so either some sort of layout/master page/template invokes rendering of child view components.
In ASP.NET Core, we would accomplish this through the built-in feature of View Components. Our layout page that would include different sectional pieces would direct which widgets to load:
@await Component.InvokeAsync("PriorityList", new { maxPriority = 4, isDone = true })
Our ViewComponent
then builds up its own model and renders its own widget of HTML. The downside to this approach is its explicit nature - the template needs to direct which view components to render.
Contrast this with something like an Amazon product page, which will dynamically determine which view components to render based on a variety of data sources, all of which come together at runtime:
We have a set of inputs (query string, cookie, headers, path) and we look for any view component renderer that can service our request. For any that can, we allow them to render in the order we've configured. Our overall layout no longer explicitly calls out to specific view components with specific inputs, and instead we have more of a pipeline of components that can decide to render (or not).
Another upside to this approach is if any one rendering component has a problem, say an exception or the data source is down/missing/slow, then our pipeline can skip rendering that widget. Put a circuit breaker in front of it, and we can detect failures over time and automatically skip rendering that component for future requests (instead of say, timing out continuously).
As with most things I do, I'd start with the simplest possible approach and only move to more complicated rendering and pipeline techniques when the rendering code tells me through code smells. Less indirection and custom framework code is usually better.
Model Composition
Now comes the more difficult part, model composition. There's really no framework/library/tool to help us out here (that I could find). You might say that something like GraphQL can do this, but you're on your own building the server side to actually compose.
With model composition, we have a single component/widget on a page that needs to pull information from multiple backend services:
Again, we can have our model composer explicitly build the pieces up, but this can re-introduce coupling:
var relatedSkus = await relatedSkuService.Fetch(sku);
var productDetails = await productDetailsService.Fetch(relatedSkus);
var reviews = await reviewsService.Fetch(relatedSkus);
var model = new RelatedSkusModel {
Skus = relatedSkus,
Details = productDetails,
Reviews = reviews
};
If we want to limit runtime/compile-time dependencies between our services, we'd want to build a pipeline that doesn't force an explicit compile-time contract like we do above.
One example of providing less coupling is the microservice example from Particular, where we build an API gateway-esque pattern and build a single model via a series of appenders:
public interface IViewModelAppender {
bool Matches(RouteData routeData, string httpMethod);
Task Append(dynamic vm, RouteData routeData, IQueryCollection query);
}
Instead of the controller explicitly calling a series of services, we dynamically look for a set of appenders (in our case, through an MVC result filter). And instead of us coupling to a specific set of class contracts, we leverage a more dynamic model with the dynamic
type in C#. Each appender can now do whatever it needs to do to build up the model, completely separate from each other. It's pipes and filters, for a model.
We still need to worry about packaging our appenders together, so our packaging of the front-end needs to be able to pull together these separate appenders either at package-time or runtime. Again, there's not tools in this space to do so, so we're left to our own devices to package things together.
View composition is clearly the easier path since we have tools built in, but there are still cases we need to perform model composition. In the next post, we'll look at data composition, when client and server composition won't work for us.