Modular Monoliths with Nx

Building scalable Angular applications

cover-image
portrait-image

Frontend architecture is a crucial part of every application. It is the foundation for maintainable and scalable applications. In this blog, I will show you how to build scalable Angular applications using Nx. We will learn about monorepositories, Nx, and the Enterprise Monorepo Pattern. We will also discuss best practices and patterns for building scalable Angular applications..

But why should you even care about frontend architecture? Why not just build a regular monolith? Well, because if you do so, over time, things just evolve and get messy. You will end up with a big ball of mud. And it will be hard to maintain and extend your application. You will have a hard time onboarding new developers. And you will have a hard time scaling your application. And you will have a hard time scaling your team. And you will have a hard time scaling your organization.
Therefore, a better approach would be to build a modular monolith. A modular monolith is a monolith that is built with modularity in mind. Hence, we get the simplicity of a monolith, but with a lot more maintainability and scalability.

By modularizing the code, changes become more predicatble, because the scope of a change is limited to its module.

Talk - Modular Monoliths with Nx

Monorepos and Nx

A monorepo is a single repository that contains multiple projects. In contrast to a polyrepo, where each project has its own repository. This is a big deal, because in an enterprise environment, you usually are dealing with loads of dependencies and it is not uncommon, that you encounter ugly version mismatches. And this is where monorepos come into play. Because in a monorepo, you can easily manage all your dependencies in one place. In an integrated monorepo, all apps and libraries included in a monorepo share the same package.json and node_modules.

This means, that all apps and libraries share the same version of Angular, RxJS, NgRx and basically all third-party dependencies in the project. Therefore, you will never have version mismatches again. And you will never have to deal with dependency hell again. On the other hand, you will have to organize migrations carefully, because you will have to migrate all apps and libraries at once. This is a challenge for your organization, but it is definitely worth it.

Since, all libs and apps are in one place, you no longer have to distribute and consume them via NPM. Instead, you can just import them via relative paths. So you no longer have the burden of thinking about versioning and publishing. All you have to do in a monorepo, is a simple import statement. This means, that your latest checkout of your master/main branch is always the latest version of your application in a trunk based development workflow.

All the above mentioned benefits may make you wondering why not everybody is already using monorepos. Well, the answer is simple. Because monorepos are hard to manage. In a monorepo, an app is usually split into many tiny libraries, which are then imported into the app. This makes for better module boundaries, but managing a three-digit number of libraries still remains a challenge. Such an amount of projects has a huge impact on the performance of linting, testing, building, and serving.

But Nx solves this problem. Nx is a set of tools and libraries that help you to build, test, and serve your applications. It is built on top of the Angular CLI and provides a lot of additional functionality. Interactive dependency graphs. Computation caching. Distributed task execution. Generators. Executors. And much more. But the most underrated feature of them all are Module Boundaries, which can be used to architect applications with strict boundaries and architectural validations.

Module Boundaries

First of all, let's discuss why NgModule's are not strict boundaries and a better approach is needed. Alhtough, many applications use NgModule's to organize their code and separate concerns, they are not guaranteed to be used as intended. For, example a component, which is declared but not exported in an NgModule can still be used outside of the NgModule. Even though, the intention clearly was to use it only inside of the NgModule. By going through the file-system, the component can be lazy-loaded and dynamically bootstraped - hence breaking the boundary.
Obviously, in a standalone world without any NgModule's, we need a different solution either way to enforce architectural boundaries.

module boundaries
Module Boundaries
And this is where Nx comes into play. First, we can define arbitrary tags for our apps and libs inside their project.json files. These tags are crucial for the next step, because they are a reference, which can be used to define dependencies between apps and libs.
Nx provides the nx/enforce-module-boundaries rule, which can be adapted in the .eslintrc.json file. In there, we can explicitly define which tags are allowed to be imported by which other tags.
"rules": {
  "@nx/enforce-module-boundaries": [
    "error",
    {
      "enforceBuildableLibDependency": true,
      "allow": [],
      "depConstraints": [
        {
          "sourceTag": "scope:boundary-a",
          "onlyDependOnLibsWithTags": ["scope:boundary-a", "scope:shared"]
        },
        {
          "sourceTag": "scope:boundary-b",
          "onlyDependOnLibsWithTags": ["scope:boundary-b", "scope:shared"]
        },
        {
          "sourceTag": "scope:shared",
          "onlyDependOnLibsWithTags": ["scope:shared"]
        }
      ]
    }
  ]
}
.eslintrc.json
Having defined the boundaries as such, importing something from scope:boundary-b into scope:boundary-a will result in a linting error. This is a huge benefit, because it enforces architectural boundaries and prevents accidental violations of the architecture. When doing exactly this, we would get the following linting error:
linting error via IDE
Module Boundary Linting Error
Now, that we know how to enforce architectural boundaries, let's discuss how we should define them and where to draw the line. For this, I would like to introduce a slight variation to the Enterprise Monorepo Pattern - which is used as a blueprint architecture for many enterprise Angular applications.

Enterprise Monorepo Pattern

The Enterprise Monorepo Pattern stems from the Enterprise Angular Monorepo Patterns book by Nx, which covers multiple patterns, but since the pattern I will be showing in this section is the most popular pattern from this book - I would just title it the Enterprise Monorepo Pattern. In fact, I will introduce you to a slight variation of the traditional pattern, which solves a few issues, that I encountered in the past on real-world projects.

Let's consider the following example. We have an application, which is called insurance-portal. It is a CRM which is used to manage claims, contracts, complaints and customers.

insurance portal
Insurance Portal
Instead of building on big application without any boundaries, we slice the application into domains, which embody a specific business domain. In our case, we have the domains claims, contracts, complaints and customers. Additionally, every application has a shared domain, which contains shared code, which can be used by all other domains. Such generic domain is especially useful, because the business domains should be as isolated as possible, hence ideally there should not be any dependencies between them.
slicing of domains
Domain Slicing
Now, that we have defined the domains, let's discuss how to organize the code inside of them. We have the following library types: routes, api, feature, ui, data-access, utils, models.
Enterprise Monorepo Pattern dependency graph
Enterprise Monorepo Pattern
  • Routes are used to define the routing configuration of a domain. By having a dedicated library type for just routes makes it possible to compose multiple features on one page.
  • API libraries are private API's which act as a gateway between domains. They are used to share the least minimal amount of code between domains.
  • Feature libraries are used to implement a specific feature. They are context-aware components, which are not meant to be reused.
  • UI libraries are used to implement reusable components, which are not context-aware. They are meant to be reused.
  • Data Access libraries are used to implement state, business logic and services, which are used to fetch data from the backend. They usually hydrate the feature layer with data and business logic.
  • Utils libraries are used to implement utility functions, which are used by multiple other libraries. An example would be a date formatter.
  • Models libraries are used to implement models, which are used by multiple other libraries. An example would be a claim interface definition.

There is a clear dependency flow from top to bottom. Meaning that libraries are allowed to depend on libraries down below, but not upwards. For example, a feature library is allowed to depend on a ui library, but not vice versa. This is crucial to ensure that the smart vs dumb component principle is not violated.

For more information about the Enterprise Monorepo Pattern, I would recommend you to read this blog post I did a while ago on the more traditional approach: Enterprise Monorepo Angular Patterns
Definitely make sure to checkout the repository linked below containing all the code examples from this blog post (and a lot more).

GitHub Repository

Comments