Front-End Architecture for Scale
By: Manisha Sharma
Tags: Nodejs, React, Architecture, Programming, JavaScript

Three Constraints

From my experience in working on large legacy systems and also immature systems still in development, the following are three important constraints that I have identified for building resilient front-end architectures.

1. Source code dependencies must point inwards

A few ways of organising our dependencies

It is important to think directionally. The picture above demonstrates three well-known ways of organising our application’s dependencies directionally. Your app is a big ball of mud when you don’t know what may not depend on what in your app and there are no rules to restrict mutations in your components. This is something we always aim to avoid.

In a layered architecture, we have rules about what code may depend on and whether the direction of dependencies is only in a single direction.

And then we have our favourite, modular architecture. Almost all of the time, if I asked an interview candidate about best practices, they would drop a line about how the code should be organised in a modular fashion. And when I asked them to elaborate on this, I’d hear about how we should break our code into different modules. Unfortunately, that’s not what makes your code modular. When I speak of modular architecture, I refer to things such as a micro front-end approach or mono-repo, code bases where you have modules with a very small API service area which are then able to call up to other modules which in turn are mono-functional.

I’m sure you must be able to spot the big difference in the three architectures talked about here: dependency organization.

In a big-ball-of-mud architecture, if you make a change in one part of the application, it is very difficult to predict the scope of regression, and that makes it a perfect way to not only cause cross-team conflicts but also to burn bridges with your QA team. After all, nobody likes to see an urgent ticket on their board because another team made a change without realising how it would affect their part of the code.

However, in the layered approach, there is still a lot more predictability and control on the amount of surface area of the code that will be affected by a certain change. And better still, if you’ve organised your application on the basis of how your teams are set up, then the chances of having cross-team conflicts are contained to a large extent.

Real-world example for layered architecture

The image above shows a real world example of layered architecture. The router layer is the entry point to any page in your application, and then comes the data layer which handles all the API requests, caching, and duplication of data. Then comes the different pages/components of the application, each with their own UI layer and business logic. The important thing to note here is that these two pages are completely isolated from each other. Not only does this provide ease of testing and predictability of regression changes — for if they were sharing some code or business logic, changes in one page could have had unintended side effects on the other. However, with this model, it is easier to isolate the impact of changes, which is important for speedy and good quality delivery. And that’s something that directly results in a product that is more resilient to changes.

This brings us to the next constraint.

2. Being conservative about code reuse

The above is a tweet I did long back, and it summarises the point behind this constraint.

When deciding about reusing a piece of code, one must think about it critically rather than seeing it as an abstraction waiting to happen. This helps us to avoid coupling code that can diverge over time due to different business requirements.

As engineers we love DRY, and when we’re able to make some code DRY by introducing abstraction, it just feels like a eureka moment. It is the reason for our existence in this industry despite the tight competition. But the problem crops up when, in the name of DRYness, we link pieces of code that are not supposed to be linked. And this makes our code brittle and prone to side effects.

Hence, when deciding about reusing code, we must try to have some idea about the roadmap and how the business requirements will change over time, and then we must ponder over whether it makes sense to reuse existing code or just copy-paste it to avoid coupling it with the roadmap for some other feature and create problems for future. So in a nutshell, one must always remember decoupled > DRY.

3. Preserving your architecture over time

Coming to the last and very interesting restraint, let me recall one incident in my previous company. We were developing a very complex product, and in the discussions, we decided to maintain a confluence doc about the architectural decisions. It was a very elaborate one, with all the UML diagrams which we thought would be helpful in onboarding new team members as well as maintaining standards.

But what happened over time was that even the existing engineers, let alone the new ones, tended not to consult that confluence in detail before introducing new features and other changes.

Similar-looking pieces of code that were meant to stay separate were abstracted and generalised. PRs were reviewed only on the basis of syntax and not from the point of view of the impact on the initially planned architecture. And after some months, the application gave it back, and we ended up detangling dependencies and rewriting large parts of the code.

Instead of senior engineers being utilised for the solving of more complex problems such as scaling, they were engaged in rewriting code that should have ideally been written as per the constraints initially documented to preserve the architecture.

To prevent this situation in the future, we decided to write forbidden dependency tests. These tests allow you to test the structure of your application from a dependency standpoint.

So next time, if you want to prevent somebody from touching the code you’re writing, you can write a plug-in to traverse your dependency graph and check for forbidden dependencies. More specifically, if you’re working in JavaScript ecosystems, you can try out Dependency Cruiser ( npm i dependency-cruiser ).

It allows you to define rules about what can depend on what in your application inside a file called dependency-cruiser.js , which will help the tool validate your application’s dependency graph against these constraints and report violations as text and visually by generating the dependency graph image for you.

Sample rule in dependency-cruiser.js

You can customise the settings for this tool to check for circular dependencies, orphans and widows, missing dependencies in package.json , and production code relying on dev or optional dependencies.

Better still, you can run these tests as part of CI. So the next time somebody tries to diverge from the original architecture and introduces unwanted complexity in the code, you don’t have to wait until the entropy becomes large enough to stall new development and make you argue with different engineers about reverting the change: The CI will do the argument on your behalf.

Boiled down, it is not just important to document boundaries, but also to enforce them in code.