Don’t Get Tangled in Your Own Code
Diana describes the benefits and challenges of organizing frontend code in a monorepo.
By: Diana Costea, Frontend Engineer, Digital Distribution Tech
The Digital Distribution Tech (DDT) team at BlackRock builds and maintains the Advisor Center (AC) tool suite. Advisor Center is a set of free-to-use Aladdin based portfolio analysis and proposal tools which help financial advisors scale their practices and build better portfolios. The insights generated from these tools are used to help BlackRock better cater to the needs of the Financial Advisors that we serve.
Like all large product suites, AC started small. What began with a single specific application targeted at US advisors, massively grew in scope over the last 5 years to 12 separate applications. These apps now improve numerous workflows of financial advisors, has also expanded to target the EMEA market with the Portfolio Centre tools. This article aims to outline my team’s approach to managing a growing code base, some challenges that we have faced, and how we are tackling them.
How to Manage the Code of a Growing Tool Suite?
Each of the Advisor Center apps runs in complete isolation, but they have many similar components and workflows which can be accessed across multiple tools. For example, this chart is used to illustrate the asset class breakdown of a portfolio across 3 separate applications under the Advisor Center umbrella (Expected Returns Analyser, 360 Evaluator Tool and Portfolio Centre):
Besides this, all the apps and components are developed and managed by the same team so code accessibility and visibility is an essential requirement for our team’s software development lifecycle.
Monorepo Wins
To avoid duplication and scale better while working on a rapidly expanding code-base, we decided to structure our Angular front-end code using a monorepo. We chose to use NX
, which is a build framework that offers great support tools for managing frontend monorepos. This decision was a game changer and brought us quite a few benefits.
As a global team, keeping up-to-date with engineers cross-regions is not an easy task, and neither is enforcing the same development standards across multiple applications. With a monorepo, everyone in our team has visibility and can more easily collaborate with the other members of the team, wherever they are. Rules can also be set globally across the monorepo using tools like ESLint, Prettier, or StyleLint to ensure the same coding practices are followed across squads and timezones.
We avoided duplication across projects by creating shared libraries in the monorepo. This also helped us maintain the speed of updating these libraries, because a change in a shared component does not require multiple pull-requests to bring the changes into each separate app.
Finally, the quality of our app maintenance and deployments was also improved by using a monorepo. Dependency management was simplified — only one package.json
file is needed for all our apps and libraries - and with a shared config and a common repository, we also eased our release process by leveraging the same build pipeline for publishing new versions for all our apps.
Monorepo Challenges
In spite of the many advantages they offer, monorepos are often avoided because of the downsides they bring. The two challenges that have significantly impacted our development workflows have been the added build slowness and the emergence of circular dependencies.
1. Slow Builds
It’s difficult to ensure your builds are efficient when working on a large codebase. Slow builds delay the pull-request merges and code releases, and ultimately slow down the team’s development lifecycle.
nx:affected
NX
offers some solutions that help speed up the build process. The one that’s made the highest impact on our build pipelines has been the nx:affected
commands. The nx:affected
command allows developers to build and test only the applications which are affected by the new code introduced. Leveraging this command has helped us greatly improve the build speeds of our applications.
But even using the nx:affected
commands, we noticed that as our code-base grew, the pipeline speeds also greatly deteriorated. The build pipeline of even a small one-line change could take up to one hour, which greatly down our software development lifecycle. Why was that the case?
Beyond nx:affected
Analysing our pull-request builds, we found that changing a small function which was only ever called from one application was triggering the build and the test runs for all of our apps. That seems odd, right? The reason this was happening was because the function we updated lived in a common library which was imported by many other libraries, and thus it was indirectly imported into all of our apps.
How nx:affected
decides what to build is by first identifying the changed files, then checking what projects (library/application) they are a part of. It then marks the project as changed and marks all other projects which depend on it as affected.
Imagine a monorepo containing 2 applications which have some shared libraries — one blog application and one reading journal application. If we were to update the user-comments
library, it would affect both the reviews
and the blog-post
libraries, as users can comment on both book reviews and blog-posts. Because these 2 libraries are imported from both of our apps, a change in the user-comments
library would affect a full re-build of both apps.
The solution: In order to best take advantage of the optimisations NX
offers, we have found that libraries should be kept modular and only serve one purpose, as small as possible. That way, our code will comprise of a lot more libraries but we will be able to better define a clean dependency tree between our applications and our libraries, and ensure that none of our projects depend on code that they don’t need.
Breaking down our current library collection into smaller isolated libraries is a huge task. One strategy that we have employed to help us better control the dependency tree of our mono-repo is enforcing dependency rules for our libraries. In the next section, I’ll talk a bit about how we have set this up and how it fits into our development lifecycle.
2. Circular dependencies, monster libraries and spaghetti code
Another issue we faced with structuring our project as a monorepo was the emergence of circular dependencies. Circular dependencies occur when a library A
depends on a library B
whose direct or indirect dependencies ultimately depend again on library A
.
Once the code is in a library, the library can be imported, and its code can be used from any other library. The accessibility to library code that a monorepo offers makes it easier to import shared libraries within the monorepo, which often leads to developers unintentionally creating circular dependencies within the applications.
These cyclic dependencies have unpredictable results — sometimes causing no visible issues, other times slowing down the build process, and often causing unexpected behaviours in the application. While working on the Advisor Center applications, we ran into a few of these unexpected issues, some more obvious than others. One time, an app was not starting at all due to circular imports, whereas another time the equality check between the same type of elements was not working correctly within a cyclic dependency hierarchy.
To avoid issues like this coming up again, we looked into options to help us avoid introducing cyclic dependencies in our project. The first option we implemented was the Madge
circular dependency check at build time. Madge
is an open source developer tool for managing JavaScript dependencies. Using this check, we were able to identify the number of circular dependencies in the code and set a threshold to prevent new ones from being added.
However we found this to be insufficient. Rather than only prevent new circular dependencies being added, we wanted to find a way to guide developers in maintaining a clean dependency hierarchy, beyond simply preventing cycles. An NX feature that has helped us do this is the @nrwl/nx/enforce-module-boundaries ESLint
rule.
ESLint
is a tool which allows us to flag incorrect patterns or code that doesn’t adhere to the set standards in a project. Using ESLint
, we can identify these mistakes in our new code automatically as part of the build pipeline.
@nrwl/nx/enforce-module-boundaries
The @nrwl/nx/enforce-module-boundaries
ESLint
rule allows strict rules to be set in order to prevent projects from depending on anything other than the libraries that it’s supposed to depend on.
The way this can be set up is by first tagging all of the libraries in the nx.json file. Here is an example of how this might look for the blog and reading-journal apps monorepo:
In this case, I have opted for different tags for each library to begin with but if the libraries can be categorised, a common tag can be defined for multiple libraries. If starting to tag libraries in a larger monorepo, it might be easier to define a structure with separate tags for each individual library, whereas for a new project, it could make more sense to define common tags for different levels of libraries. These tags can then be used inside the .eslintrc.json
file to direct exactly which tags another tag can depend on. In this example the rule set is that the reviews
libraries can only depend on 2 other library tags in the monorepo - user-comments
and user-ratings
.
With this rule in place, if someone were to raise a pull-request that adds an import from a different library than those allowed in the ESLint
rule, the lint
stage of the build would fail, preventing the pull-request from being merged:
Additionally, if a circular dependency was introduced as part of the pull-request changes, the lint
stage would also fail, with an error outlining the cycle:
Better dependency hierarchy control leads to cleaner code structure
Having this rule in place does not forever prevent new dependencies from being allowed on a library. Developers can always choose to add another tag to the list of supported dependencies. However, this error gives us a chance to think about what the best choice is for maintaining a clean dependency hierarchy — should we add another tag to the list or would the code we need fit better somewhere else?
In building our dependency hierarchy, we need to aim to keep our libraries small and enforce module boundaries to avoid depending on unneeded code. This is what will also help us best take advantage of the power of nx:affected
.
Bonus: Lessons Learned from Integration Test Coverage Reports
In this final section, I will describe how our integration tests help us find ways to build a better structure in our monorepos.
In the BlackRock DDT team, we use Cypress
integration tests (IT) to test the full user workflows in our applications. NX
is nicely integrated with Cypress
, automatically generating a corresponding e2e (end-to-end)
project for each new application. Recently, we started looking into building reports to determine how much of our monorepo code is covered by our ITs.
Could we remove some of our inter-dependencies?
This is how the report looks for the blog
application - the code of each file in the repository that gets pulled into the app is visible, including the code of every dependent library.
We can use this view to drill into specific directories and files and see which lines of code are covered by the integration tests.
In each source code file, next to the line numbers, there is a column which sometimes shows numbers. These specify how many times a line of code has been hit throughout the integration tests which have been run for the app. For this service, we can see that it has been imported 4 times. However, neither the constructor, nor any of the functions of the service have ever been called.
This could mean one of two things — either our test cases are insufficient and do not cover a workflow that makes use of this service, or this method is never actually used in our application despite being pulled in as a dependency. In this case, it probably doesn’t make sense for us to import the book
library from the blog
application since we don't show any book-related data in the blog. If there is code we use from the book
library, it would make more sense to extract that common code to a separate shared library and stop depending on the book
library directly.
When we first generated the IT coverage reports from our apps our coverage was lower than we had expected because we have an extensive set of integration tests for our apps which all major user workflows. This was due to the fact that our libraries were quite big and sometimes contained code which wasn’t actually ever used in all the apps which depended on them.
This coverage report provided a new way to identify code that could be restructured to clean up our dependency hierarchy.
Conclusion
Overall, using an NX
monorepo has proved invaluable for the DDT team at BlackRock, especially in allowing us to promote better consistency and visibility while working in a global team.
However, this requires more attention to avoid inadvertently getting lost in a set of large and tangled libraries. NX
has many features to help better manage the internal dependency tree. From these, the nx:affected
command and the enforce-module-boundaries
ESLint
rule have been most useful for our team.
Resources
If you’re interested in using the tools mentioned in this article, here are some resources to help you get started!
- NX — https://nx.dev/getting-started/intro
- nx:affected — https://nx.dev/using-nx/affected
- Madge — https://www.npmjs.com/package/madge
- @nrwl/nx/enforce-module-boundaries — https://nx.dev/structure/monorepo-tags
- Cypress — https://www.cypress.io/
- Angular & NX Cypress Coverage — https://github.com/Flaxline/angular-nx-cypress-coverage-example
Check out BlackRock’s Careers Site to learn more about tech at BlackRock and explore our open technology and engineering roles.