Micro Frontends in Action With ASP.NET Core - Server-Side Routing via YARP in Azure Container Apps

Recently, I've been reading Micro Frontends in Action and while doing it I've decided that I want to implement the samples in ASP.NET Core and host them on Azure. Then I had a thought, that maybe I can use this as an opportunity to play with some technologies I didn't have a chance to play with yet, like YARP or Azure Container Apps. Once I did that, the next thought was that maybe I should write down some of this and maybe someone will find it useful. So here we are, possibly starting a new series around micro frontends techniques:

Micro Frontends in Action

One of the praises for Micro Frontends in Action says that it's an excellent starting point to understanding how to introduce micro frontends in your projects. I'm about one-third through it and I'm willing to agree with that statement. The book starts with very simple techniques like page transition via links, composition via iframe, and composition via Ajax. Then it moves to server-side composition, advanced techniques for client-side composition, communication patterns, and more (I don't know, I haven't gotten there yet). Up to this point, it has been interesting and engaging (this is my opinion and just to be clear - nobody is paying me to read the book and provide an opinion about it).

The book contains samples for every discussed technique and it was just too tempting not to implement them with technologies I like. That doesn't mean I will implement every single one of those samples. I will also certainly not describe every single one of those implementations. I'm doing it for fun.

I also recommend you read the book if you are interested in the subject - a blog post describing some specific implementation will not provide you with information about benefits, drawbacks, and when to use a certain technique.

The Landscape So Far

The technique I'm going to describe in this post is server-side routing. Prior to introducing that technique, the project consists of two services: Decide and Inspire. The Decide service is loading frontend fragments provided by Inspire service via Ajax request.

Composition via Ajax Frontend Layout

Under the hood, both services are simple ASP.NET Core MVC applications that serve views based on static HTML from original samples, where the URLs are generated based on configuration.

@using Microsoft.Extensions.Configuration
@inject IConfiguration Configuration
@{string decideServiceUrl = Configuration["DECIDE_SERVICE_URL"];}
<link href="@(Context.Request.Scheme + "://" + Context.Request.Host + "/static/fragment.css")" rel="stylesheet" />
<div class="inspire_fragment">
  <h2 class="inspire_headline">Recommendations</h2>
  <div class="inspire_recommendations">
    <a href="@(decideServiceUrl + "/product/fendt")"><img src="https://mi-fr.org/img/fendt.svg" /></a>
    <a href="@(decideServiceUrl + "/product/eicher")"><img src="https://mi-fr.org/img/eicher.svg" /></a>
  </div>
</div>

The Inspire service additionally defines a CORS policy to enable requesting the fragments from different domain via Ajax.

var builder = WebApplication.CreateBuilder(args);

string decideServiceCorsPolicyName = "decide-service-cors-policy";

builder.Services.AddCors(options =>
{
    options.AddPolicy(name: decideServiceCorsPolicyName, policy =>
    {
        policy.WithOrigins(builder.Configuration["DECIDE_SERVICE_URL"]);
        policy.AllowAnyHeader();
        policy.WithMethods("GET");
    });
});

...

app.UseRouting();

app.UseCors(decideServiceCorsPolicyName);

...

Both services are containerized and deployed as two Container Apps within a single Container Apps environment.

az containerapp create \
  -n ${DECIDE_CONTAINERAPP} \
  -i ${CONTAINER_REGISTRY}.azurecr.io/decide:latest \
  -g ${RESOURCE_GROUP} \
  --environment ${CONTAINERAPPS_ENVIRONMENT} \
  --ingress external \
  --target-port 3001 \
  --min-replicas 1 \
  --registry-server ${CONTAINER_REGISTRY}.azurecr.io

az containerapp create \
  -n ${INSPIRE_CONTAINERAPP} \
  -i ${CONTAINER_REGISTRY}.azurecr.io/inspire:latest \
  -g ${RESOURCE_GROUP} \
  --environment ${CONTAINERAPPS_ENVIRONMENT} \
  --ingress external \
  --target-port 3002 \
  --min-replicas 1 \
  --registry-server ${CONTAINER_REGISTRY}.azurecr.io

This results in the following Container Apps solution.

Composition via Ajax Container Apps Solution

I've created a GitHub Actions workflow that performs all the necessary steps (creating Azure resources, building and pushing containers images, and deploying Container Apps) to set up the entire solution from scratch. There is one tricky step in that workflow. Both services must know each other URLs but those are available only after Container Apps are created. To solve this I'm first getting the ingress information for every app (with az containerapp ingress show) and then write them to environment variables (using the multiline strings approach).

jobs:
  ..
  deploy-to-container-apps:
  ..
  steps:
    ..
    - name: Get Services Ingress
      run: |
        echo 'DECIDE_CONTAINERAPP_INGRESS_JSON<<EOF' >> $GITHUB_ENV
        az containerapp ingress show -n ${DECIDE_CONTAINERAPP} -g ${RESOURCE_GROUP} >> $GITHUB_ENV
        echo 'EOF' >> $GITHUB_ENV
        echo 'INSPIRE_CONTAINERAPP_INGRESS_JSON<<EOF' >> $GITHUB_ENV
        az containerapp ingress show -n ${INSPIRE_CONTAINERAPP} -g ${RESOURCE_GROUP} >> $GITHUB_ENV
        echo 'EOF' >> $GITHUB_ENV
    ..

Next, I'm using the fromJson expression to get FQDN from ingress information and update the Container Apps.

jobs:
  ..
  deploy-to-container-apps:
  ..
  steps:
    ..
    - name: Configure Services URLs
      run: |
        az containerapp update -n ${DECIDE_CONTAINERAPP} -g ${RESOURCE_GROUP} --set-env-vars INSPIRE_SERVICE_URL=https://${{ fromJSON(env.INSPIRE_CONTAINERAPP_INGRESS_JSON).fqdn }}
        az containerapp update -n ${INSPIRE_CONTAINERAPP} -g ${RESOURCE_GROUP} --set-env-vars DECIDE_SERVICE_URL=https://${{ fromJSON(env.DECIDE_CONTAINERAPP_INGRESS_JSON).fqdn }}
    ..

The Challenge

There is a problem with this solution. There are two services and each of them is available to public requests under a different domain. This has several drawbacks:

  • Bad user experience (requests flying to different domains).
  • Internal infrastructure of the solution is exposed publicly.
  • Performance (two DNS lookups, two SSL handshakes, etc.).
  • Indexing by search engines.

To solve this a central service (a proxy), where all requests will arrive, is needed.

Introducing YARP as a Solution

For about a year, the ASP.NET Core stack have its own reverse proxy - YARP. It provides a lot of routing features like headers-based routing, session affinity, load balancing, or destination health checks. In this scenario I'm going to use direct forwarding to simply forward requests to specific services based on a path. I've also decided to set up the rules from code instead of using configuration (it seemed simpler as I still could provide service URLs through environment variables). For this purpose, I've created a MapForwarder extension method.

public static void MapForwarder(this IEndpointRouteBuilder endpoints, string pattern, string serviceUrl)
{
    var forwarder = endpoints.ServiceProvider.GetRequiredService<IHttpForwarder>();
    var requestConfig = new ForwarderRequestConfig { ActivityTimeout = TimeSpan.FromMilliseconds(500) };

    endpoints.Map(pattern, async httpContext =>
    {
        var error = await forwarder.SendAsync(httpContext, serviceUrl, HttpClient, requestConfig, HttpTransformer.Default);

        if (error != ForwarderError.None)
        {
            var errorFeature = httpContext.GetForwarderErrorFeature();
            var exception = errorFeature?.Exception;
        }
    });
}

I've followed the approach of defining service-specific path prefixes as well as specific prefixes for important pages.

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddHttpForwarder();

var app = builder.Build();

string decideServiceUrl = app.Configuration["DECIDE_SERVICE_URL"];
string inspireServiceUrl = app.Configuration["INSPIRE_SERVICE_URL"];

app.UseRouting();
app.UseEndpoints(endpoints =>
{
    // Per service prefixes
    endpoints.MapForwarder("/", decideServiceUrl);

    // Per service prefixes
    endpoints.MapForwarder("/decide/{**catch-all}", decideServiceUrl);
    endpoints.MapForwarder("/inspire/{**catch-all}", inspireServiceUrl);

    // Per page prefixes
    endpoints.MapForwarder("/product/{**catch-all}", decideServiceUrl);
    endpoints.MapForwarder("/recommendations/{**catch-all}", inspireServiceUrl);
});

app.Run();

As it will be now the proxy responsibility to know the addresses of both services, they no longer need to be able to point to each other. All the paths can now be relative, as long as they follow the established rules.

<link href="/inspire/static/fragment.css" rel="stylesheet" />
<div class="inspire_fragment">
  <h2 class="inspire_headline">Recommendations</h2>
  <div class="inspire_recommendations">
    <a href="/product/fendt"><img src="https://mi-fr.org/img/fendt.svg" /></a>
    <a href="/product/eicher"><img src="https://mi-fr.org/img/eicher.svg" /></a>
  </div>
</div>

There is also no need for CORS anymore, as from a browser perspective there are no cross-origin requests. So I've removed that code from the Inspire service.

Now it's time to hide the services from the public. For starters let's change their ingress to internal.

az containerapp create \
  -n ${DECIDE_CONTAINERAPP} \
  -i ${CONTAINER_REGISTRY}.azurecr.io/decide:latest \
  -g ${RESOURCE_GROUP} \
  --environment ${CONTAINERAPPS_ENVIRONMENT} \
  --ingress internal \
  --target-port 3001 \
  --min-replicas 1 \
  --registry-server ${CONTAINER_REGISTRY}.azurecr.io

az containerapp create \
  -n ${INSPIRE_CONTAINERAPP} \
  -i ${CONTAINER_REGISTRY}.azurecr.io/inspire:latest \
  -g ${RESOURCE_GROUP} \
  --environment ${CONTAINERAPPS_ENVIRONMENT} \
  --ingress internal \
  --target-port 3002 \
  --min-replicas 1 \
  --registry-server ${CONTAINER_REGISTRY}.azurecr.io

This way the services are now accessible only from within the Container Apps environment. The proxy can be deployed to the same Container Apps environment and expose the desired traffic outside.

az containerapp create \
  -n ${PROXY_CONTAINERAPP} \
  -i ${CONTAINER_REGISTRY}.azurecr.io/proxy:latest \
  -g ${RESOURCE_GROUP} \
  --environment ${CONTAINERAPPS_ENVIRONMENT} \
  --ingress external \
  --target-port 3000 \
  --min-replicas 1 \
  --registry-server ${CONTAINER_REGISTRY}.azurecr.io \
  --env-vars INSPIRE_SERVICE_URL=https://${INSPIRE_CONTAINERAPP_FQDN} DECIDE_SERVICE_URL=https://${DECIDE_CONTAINERAPP_FQDN}

After this change, the Container Apps solution looks like in the below diagram.

Server-Side Routing via YARP Container Apps Solution

That's All Folks

At least for this post. You can find the final solution here and its GitHub Actions workflow here. The services have become a little bit strange in the process (they server static HTML in an unnecessarily complicated way) but they are not the focus here, the server-side routing technique is.

I don't know if or when I'm going to play with another technique, but if I will it's probably going to be something around server-side composition.