The Monorepo Blueprint — How to Create a Scalable Architecture for an Angular Monorepo

This article will teach you how to create a scalable architecture for an Angular monorepo. The principles here can be applied to any front end monorepo, as they are based on universal best practices for getting a scalable and maintainable architecture.

But first, what should a good Angular architecture do?

  1. Separate UI from business logic using abstraction layers
  2. UI should be decoupled from technology specific tools such as communication and state management
  3. The architecture should reflect the domain knowledge, not the technologies
  4. The architecture enforce module boundaries to ensure proper encapsulation and dependency control

Let’s consider how this will look like for a todo app that:

  • Have a web and mobile app
  • Can show todo list
  • Can show a todo list admin dashboard
  • Can get and save todos using a todo service (node app)

The overall setup can be created using Angular and NX (for affected commands and TsLint rules for module boundaries).

Let’s look at how all of this ties together.

Apps

Apps here should only focus on the UI and should delegate all data access to the corresponding sandbox. That means that there should be no HttpClient or state management framework referenced inside of an app. This gives us the luxury of having apps decoupled from the data access logic, which makes it easier to later change the state management framework or communication methods.

Libs

Normally you might think of lib as a mean to share code. That is partly true here, but it can also have other purposes: enforce encapsulation of modules. By creating a library we can use the Nx TsLint rules to enforce that libs can only be communicated with through the public interface (normally a barrel export index.ts file) which helps with a more maintainable architecture because of better encapsulation and control of the dependencies. It will also break your app up in smaller independent pieces, which will make troubleshooting and test execution faster. For each lib, you can freely choose eg. the test framework and build configuration without it is affecting other apps’ setup (it might still be a good idea to keep the app consistent).

App-specific libs

We want to create a lib specific to each app to encapsulate all the data access logic. We do this by only exposing “sandboxes”.

Sandboxes

A sandbox is a facade which can contain business logic. It is used for setting and getting data through an easy to use interface. Why is it easy to use? The interface of a sandbox should never contain technology-specific terms such as: dispatch and selector. You will just write eg. saveTodo.

Notice on the picture above that there are no effects/thunk/epic/saga. That is because it is handled in the sandbox. Why don’t I use effects?

  1. Complicates the interaction with indirection when there is no one to many communication
  2. Stream dies if an effect throws an unhandled error, resulting in a broken app and need for defensive error handling in all places
  3. Couples business logic to a framework specific technology — harder to change
  4. A state management framework is for state management, not business logic (IMO!)

This is my personal opinion and experiences from seeing these problems over and over in teams, and simply fixing them by not using effects/epics and such. Also, one big drawback with effects is that you need to write protective error handling to make sure you keep the stream alive and don’t break the app, which complicates things quite a lot

Notice here how the sandbox is the externally exposed interface, which will handle business logic and the delegation to the state management framework and HttpClient. By having this abstraction in our architecture we can easily change the state management and communication framework without it is affecting the apps. This is the power of good abstractions and encapsulation. From the app developers perspective, he might not know anything about NgRx and HttpClient, but because the abstraction is so easy to use, he doesn’t need to if he is only working with UI.

Because an app lib should only be used by that specific app it is a good idea to enforce this constraint by tagging the app and the lib with the same tag, eg. todo-app and enforce that only todo-app lib can be called by todo app using Nx TsLint rules.

Shared

Inside here goes the libs that can be used by multiple apps. You should enforce the module boundary by using tagging and setting the Nx TsLint rules accordingly.

Grouping of shared libs

A shared lib can be grouped after:

  1. Feature
  2. Platform (web, iOS, Android)
  3. Function (UI, data access)

Feature

A feature library is a grouping based on a specific feature in the app. That could eg. be the responsibility for authentication. Here you might have the login page (smart component) which will contain the necessary UI and data-access logic to log users in and out.

Constraints:

Feature libs can be used by apps only.

UI

The UI lib should only contain shared presentation components. That means no feature context should go here. All interaction is through input and output. You want to copy UI component from apps to here whenever a UI component should be shared with the other apps.

Constraints:

UI libs can be used by apps only.

Data Access

Here we have the shared data access and HTTP logic, that can be used by sandboxes. That includes HttpInterceptors, shared reducers, actions, and selectors for example.

Constraints:

Shared data-access libs can be used by all projects except UI and utils libs.

Utils

Shared utils libs are normally containing static pure functions, that provide different kinds of helpers. This is for example date utils.

Constraints:

Utils can be used by all projects.

Conclusion

In this post, we saw how to create a scalable monorepo architecture that will ensure maintainability by:

  • Decoupling UI and business logic
  • Ensure dependency control and module boundaries
  • Domain-based instead of technology-focused

By having this architecture in place it is possible to change eg. state management framework inside of the sandbox without needing to change the apps, because of the decoupling. Also, the module boundaries help the team to make sure the architecture stays clean.

Coming up

I will go through how to set this up completely in the next article on a code level. Make sure you subscribe so you don’t miss it.

References

NRWL book Angular enterprise monorepo patterns


Originally published at Christian Lüdemann IT.