The typical web application usually consists of the backend part, implementing the business logic, and the web application to interface with your users. This can quickly add some additional complexity, especially if different languages are in use and if they interface with each other. It can also lead to discrepancies between the source code. Recently, I had a similar case at work and decided to invest some time to figure out a solution. Here's an in-depth summary of how it went.

The problem with the application

The application in question is built using Angular and ASP.NET. It consisted of three repositories:

  • A repository containing all frontend source code, including a PHP server that will be ran in a Docker container to serve the static artifacts from the build.
  • A repository containing all backend source code, including another Docker image to serve the ASP.NET app.
  • A repository for deployment and infrastructure provisioning. Also configuring Ingress and the deployment & services themselves.

One of the main problems is the complexity and scattering of the source - it becomes hard for developers to coordinate reviews, deployments and search through code. As an example, we had an API route in the ASP.NET application to get some data:

public record User
{
    public required string Name { get; init; }

    public required string Email { get; init; }

    public required IReadonlyCollection<string> Roles { get; init }
}

public class UserController : Controller
{
    [Route("/")]
    public async IActionResult<User?> GetCurrentUser()
    {
        return _currentUserService.GetCurrentUserOrDefault();
    }
}

The frontend then contains the respective code to consume this endpoint:

type User = {
    name: string;
    email: string;
    roles: string[];
}

@Injectable()
public class UserService {
    constructor(private readonly httpClient: HttpClient) {}

    public getCurrentUser(): Observable<User> {
        return this.httpClient.get<Observable<User>>('BACKEND_URI');
    }
}

There's one obvious flaw with this model though; they are not linked at all and a developer can change one counterpart without the other part failing. Because both backend and frontend tests are isolated, no tests will ever fail if one forgets to update the corresponding model.

Another issue with the application was the additional web server dependency. Because we had two individual Git repositories and Pipelines, we also had two separate Docker images. We then configured our Ingress to route depending on the path:

http:
  paths:
  - path: /ui
    pathType: Prefix
    backend:
      service:
        name: frontend
        port:
          number: 8080
  - path: /ui-api
    pathType: Prefix
    backend:
      service:
        name: backend
        port:
          number: 8080

But this also meant that we have two services running, two pods running and also two web servers running. Although this might seem harmless, it actually caused over 2 hours of downtime, when a breaking change in the web server for the frontend was accidentally deployed. Because our E2E tests only covered the backend being accessible, our automated deployment also did not prevent the faulty deployment. As you can see, the additional point of failure introduced additional risk and pressure for the developers.

Deployments also need to change

Since we also had two Docker images, that need to be maintained and deployed, we needed to invest additional time to coordinate deployments, especially if we introduced breaking changes in the API. Consider the following example in comparison to the above code:

public record User
{
    public required string Name { get; init; }

    public required string Email { get; init; }

    public required IReadonlyCollection<Role> Roles { get; init }
}

We now changed the type of Roles to a type that is serialized differently by System.Text.Json. Hence we also need to make the same adjustment in the frontend:

type User = {
    name: string;
    email: string;
    roles: Role[];
}

But how do we deploy these changes now? If we first merge the frontend change, we will break it since it expects an object from the API, which will result in deserialization errors. Deploying the backend first doesn't solve the problem; it will serialize the role to a JSON object while the frontend still expects a JSON string array.

To solve this, we would need to coordinate both deployment runs and let Kubernetes switch over to the new pods at the exactly same time. That seems like a huge effort! Because of this limitation, we decided to always make our API backwards compatible, by deprecating old API endpoints instead of deleting them or by using objects if we know more data will be added later on.

Establishing requirements

I knew that we could solve some of these problems using a Monorepository. The frontend experience in the team was quite limited, so I analyzed our requirements, desires and noted them down:

  • The new form and structure should be easier to navigate for developers
  • Deployments of frontend, backend or a combination should be easy to perform
  • The structure change should not break integration with existing tools, such as our development environments or the deployment in the Kubernetes cluster
  • Remove unnecessary dependencies and point of failures to reduce complexity and risk
  • Make code changes more transparent and build the foundation to further improve on the system (e.g. linking models or expanding the testing system)

Analysis of variants

Until now, all of this was just an idea. Since this required us to invest not an insignificant amount of time, I discussed my suggestion with architects and the product owner so we can estimate the advantages we gain and the effort that is needed. To do this, we needed concrete examples and a PoC to proof that it actually works.

So I started by creating a documentation page, where I noted all my findings so other developers can understand my thoughts and implement the change on their own. This leads to a more broad knowledge in the team as well.

đź’ˇ
The following section has been simplified and reduced to the relevant components. It will still apply to most projects though.

Regarding project stucture, this was our existing directory structure:

Project structure

  • src/
  • src/Company.Common
  • src/Company.Service
  • src/Company.Service.Dockerfile
  • tests/Company.Component
  • README.md

Many projects merge the source from their Polyrepository to the root of the Monorepository. This would result in the following structure:

Merge Polyrepo contents at root

  • src/
  • src/Company.Common
  • src/Company.Serice
  • src/Company.Service.Dockerfile
  • tests/Company.Component
  • web/index.html
  • web/angular.json
  • web/src/index.ts
  • web/src/app.module.ts
  • README.md

However, this will have several disadvantages:

  • We use the debugger inside the Docker container to develop and debug. Because Visual Studio expects the Dockerfile to be exactly at this location, we cannot move the file to a different location. But since the build context now is outside the src/ directory, it breaks a lot of IDE integrations, the pipeline and will cause additional image changes.
  • The root will be polluted with configuration files from both frontend and backend.
  • Since the frontend files would have been moved to the / of the repository, we would get multiple file conflicts, such as the README.md

Becuase of that, I decided to apply the existing pattern to the frontend too:

Merge Polyrepo with existing structure

  • src/
  • src/Company.Common
  • src/Company.Serice
  • src/Company.Service.Dockerfile
  • src/Company.Web/index.html
  • src/Company.Web/angular.json
  • src/Company.Web/src/index.ts
  • src/Company.Web/src/app.module.ts
  • src/Company.Web/README.md
  • tests/Company.Component
  • README.md

I also prepared the implementation by reading the SPA functionality of ASP.NET and experimented with several approches. They also have a guide for Angular projects, but it seemed that it was rather limited and would not work well with our infrastructure setup.

Implementing it

Retain Git History

One criteria for this change was, that we retain our commit history. We often document reasons for actions in commit messages, which would get lost if we squashed them or added the files as untracked new files in the unrelated repository. The problem is, since both repositories are separate on their own, either can't integrate the changes of the other because it doesn't know and track those files.

However, git has us covered here too:

# add the unrelated repository
git remote add frontend https://....

# fetch the unrelated repository
git fetch frontend

# tell git to fetch commit history from the unrelated repository
git pull frontend master --allow-unrelated-histories

# checkout the master of the repository where we want to merge into
git checkout master

# merge the related commit history to the master
git merge --allow-unrelated frontend/master

# merge conflicts if you have any

# no force push needed, just push the history
git push origin master

Serve SPA

When I analyzed the Spa NuGet of Microsoft, I found that the UseSpa() was the closest to our requirements. However, it did not really work or broke existing contoller methods in the application, because we needed to serve the application from a subpath instead (e.g. /awesome-app/ui). Therefore, I came up with the following extension method:

public static void UseWebUi(this IApplicationBilder app, IHostEnvironment env)
{
  app.MapWhen(request => request.Request.Path.HasValue && request.Request.Path.Value.StartsWith("/awesome-app/ui"), client =>
  {
    client.UseSpaStaticFiles(new StaticFileOptions
    {
      RequestPath = "/awesome-app/ui"
    });
    client.UseSpa(spa =>
    {
      spa.Options.SourcePath = "wwwroot";
      spa.Options.DefaultPage = "/index.html";
    });
  }
}

It will only map requests, that match the frontend subpath /awesome-app/ui and it reuses the static files functionality of ASP.NET. This simply means we copy the frontend artifacts of Angular to the webroot on build and ship them with the Docker container. But there's a problem; in local development, no static files are generated and Angular spins up it's own HTTP web server with HMR. This means we need a different implementation for the local development environment - hence I came up with this addition to the code:

public static void UseWebUi(this IApplicationBilder app, IHostEnvironment env)
{
  app.MapWhen(request => request.Request.Path.HasValue && request.Request.Path.Value.StartsWith("/awesome-app/ui"), client =>
  {
    if (env.IsDevelopment())
    {
      client.RunProxy(proxy => proxy.UseHttp("http://localhost:4200"));
    }
    else
    {
      client.UseSpaStaticFiles(new StaticFileOptions
      {
        RequestPath = "/awesome-app/ui"
      });
      client.UseSpa(spa =>
      {
        spa.Options.SourcePath = "wwwroot";
        spa.Options.DefaultPage = "/index.html";
      });
    }
  }
}

The updated version uses AspNetCore.Proxy to proxy requests to the development server of Angular. I prefer this implementation, because it does not affect the JavaScript debugger or the Angular dev tools in any way unlike other options.

Consolidate Deployment

Next, it was time to update the pipeline and deployment. Originally, I wanted to copy the frontend artifacts in the Dockerfile to the `wwwroot` directory. However, I found out that this copy seemed rather unstable and slow on our CI - so I added one new step in the pipeline, where it copies the frontent files from the frontend distribution directory to the wwwroot recursively:

cp $WEB_PROJECT_PATH/dist/* $PROJECT_DIR_PATH/publish/wwwwroot -r

Using docker inspect and the files viewer in Docker Desktop, I then confirmed that the wwwrootdirectory was present and contained the Angular files.

And remember, that we talked about Ingress almost at the beginning of this article? Of course, I could now remove the resource for the frontend now, since we no longer had any separate pods or deployments for the frontend:

http:
  paths:
  - path: /ui
    pathType: Prefix
    backend:
      service:
        name: backend
        port:
          number: 8080

Next steps

Whew, that was a big bunge of changes. Everything works now, but we actually can establish some interesting next possible steps for our Monorepo:

  • We can optimize our pipeline now more, since everything happens in one pipeline run and can be configured more precisely.
  • We can add logging or middlewares to get more information about the frontend being served
  • We can introduce end to end tests, that spin up the entire frontend and backend, testing them together and making sure that the API matches.
  • We can migrate our Playwright tests in the frontend to the .NET version of Playwright if we prefer writing C#.
  • We can autogenerate the API models more easily for the frontend

Conclusion

Let's wrap up. Here are the learnings & conclusions I've drawn from this migration:

  • We have been seeing significantly less issues with discrepancies in API contracts (e.g. the API controller) since the change
  • Code reviews has gotten much more transparent and easier
  • We were able to eliminate point of failures, that were known to cause downtime for our customers
  • We prepared a foundation for the next steps to perform more broader testing, which further reduces the risk of individual changes and assists the developer
  • The nested project structure can behave slightly different in different products and IDEs
  • Initial investment can be big, especially if you want to keep your VCS history and must coordinate this change between usual sprint increments
  • We needed quite a bit of troubleshooting, since our team primarily focuses on backend applications. Such migrations could therefore be taxing for less experienced teams.

Overall, I definitely think this change was worth the effort and we are already profiting from the advantages of this solution. Even though it may slow down development, the velocity in the team will increase again because additional complexity has been eliminated. Have you made a similar change in your projects too? Comment down below.

Share this post