The Stages of an Angular Architecture with Nx

Share on facebook
Share on google
Share on twitter
Share on linkedin

Long gone are the times when the frontend was just a dumb static website. Frontend apps have gotten increasingly complex since the rise of single-page application frameworks like Angular. It comes with the price of increased complexity and the ever-changing frontend landscape requires you to have an architecture that allows you to scale and adapt with ease.

I believe over-engineering is just as bad as under-engineering and we only want to choose the architecture that fits our current needs and scale up as needed.

This post will give you an approach to Angular architecture that can take you all the way from an app from scratch to a 100+ engineer monorepo project by taking you through the three stages of Angular architecture I see most projects go through from my experience as an Angular consultant and trainer.

TLDR

The evolution of an Angular architecture will go through these stages:

Stage 1: Organize with grouping folders per domain and use category folders within each.

Stage 2: Move the grouping folders to libs and for each grouping folder (domains and shared) create a library for each of the categories (feature, UI, domain, utils).

Simple folder architecture: Grouping and category folders

When we start from scratch most Angular projects follow the Angular coding style guide’s approach to architecture which, among others, preaches the principle of grouping by feature. Also, this is a universal “clean architecture” principle that is mentioned in Robert C. Martins’s book with the same title.

Combining the Angular style guide with Nx practices

Now, the Angular style guide has some sound principles such as group by feature/domain but they don’t exactly cover a convention for organizing the code within the grouping folders. Nx laid out a layered approach that was traditionally only known in the backend world as domain-driven design or the layered “onion architecture” where within each grouping folder you would organize your code in the following layers (you can only communicate with lower layers):

Feature

Feature contains the smart components of the given sub-domain which is usually the pages and modals that are being lazy loaded by routing or opening a modal. If the components contain high-cohesive dumb components or directives that are only relevant to the specific use case we would put them here as well nested in the folder of the smart component they are used in.

Note: if a grouping folder contains multiple nested grouping folders (domain with sub-domains) then you might create a shell lib to contain the router configuration to load the smart component from the underlying feature libs.

API

Like feature, API libs can communicate with lower layers but is normally only containing barrel index.ts files exporting what should be exposed to other domains feature libs.

UI

UI contains use-case agnostic components and directives that are relevant to that domain. Often time, these components can be useful to the whole app so they will end up in shared/ui.

Domain

The domain category contains business logic and models, facade services, state management, and network communication.

Utils

The util category contains use-case agnostic helpers eg. a date formatter. Often utils will end up in shared/utils as it is not tied to any specific domain.

Shared folder

The shared folder, like the grouping folders, has underlying folders for the categories. If you have multiple apps I recommend you create shared folders at libs/shared which contains the underlying category folders or even nested grouping folders eg. an shared/auth folder.

Note: if you only have one app, I recommend having a shared folder just within the app project but if you have multiple apps you want to share code with I recommend you have a shared folder for that in libs/shared.

What about CoreModule?


Creating a folder named core with singleton services and a CoreModule that ensures you could only import it once (in AppModule) used to be recommended in the Angular style guide but the concept of a CoreModule has been removed altogether from the style guide. Due to provideIn: 'root' and the standalone APIs such as environment providers and standalone components, the term has been omitted altogether.

Also, in my professional experience on many Angular projects, the core module and folder is one of the most misunderstood concepts and I have seen eg. components and other “core” functionality go there whereas it was only intended as a way to provide singleton services and enforce they were only imported once.

With this architecture, everything that would normally be in the core folder is instead in shared/domain which is imported only by the bootstrapApplication in an Angular standalone components app.

Showcase of a todo app archItecture

Here we see how the todo-app has different grouping folders for each domain and each grouping folder contains the category folders and is following the rules of the dependency flow between them. At this point, there is no EsLint module boundaries rule to enforce the dependency flow but we will see in the next stage how this can be enforced.

Also, we have multiple apps so we have a shared folder with category folders (UI, domain, utils) and a grouping folder for auth as this repo has multiple apps that should be able to access this shared code.

Scaled libs architecture: Moving to libraries

In the next stage of the app has grown to a size where the different domain folders have grown to a significant size, test time is increasing and there might even be dedicated feature teams working on each domain. This calls for more independence between the teams, so one team’s work doesn’t necessarily cause all the tests to run and some of these domains might even be deployed independently such as a micro frontend or a NPM package.

In the libs folder, we copy the grouping folder from the app and create a library for each of the category folders in each grouping folder. Furthermore, we want to enforce module boundaries to control the dependency flow between domains and the different layers/categories in each.

Also, when creating a library for each category folder we want to expose what is public using barrel exports using index.ts files.

Module boundary rules for categories

The basic module boundary rules are:

App can dependent on shell or feature. A shell library is a library that contains routing for multiple sub-domain grouping folders. If a domain doesn’t have subdomains I would omit the shell library and just have the app depend on the feature libraries.

A shell library can only depend on feature libraries to expose smart components for routing.

Feature can depend on all lower layers in its own grouping folder as well as API libs from other domain grouping folders as well as shared.

An API library can expose UI, domain, and utils from the domain to other domains. This is beneficial to keep the architecture cohesive about the domain rather than having to move files to shared (thus breaking cohesion) as soon as another domain needs it.

UI can only depend on domain and utils (in the belonging grouping folder and shared).

Domain can only depend on utils and other shared domain libs.

Utils can not depend on anything other than shared domain libs.

This can all be automated by creating an eslint rule as:

{
  "files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
  "rules": {
    "@nx/enforce-module-boundaries": [
      "error",
      {
        "enforceBuildableLibDependency": true,
        "allow": [],
        "depConstraints": [
          {
            "sourceTag": "scope:shared",
            "onlyDependOnLibsWithTags": ["scope:shared"]
          },
          {
            "sourceTag": "scope:todo-app",
            "onlyDependOnLibsWithTags": ["scope:todo-app", "scope:shared"]
          },
          {
            "sourceTag": "scope:todo-admin",
            "onlyDependOnLibsWithTags": ["scope:todo-admin", "scope:shared"]
          },
          {
            "sourceTag": "type:app",
            "onlyDependOnLibsWithTags": [
              "type:feature",
              "type:shell",
              "type:domain",
              "type:api",
              "type:util"
            ]
          },
          {
            "sourceTag": "type:feature",
            "onlyDependOnLibsWithTags": [
              "type:feature",
              "type:api",
              "type:ui",
              "type:domain",
              "type:util",
            ]
          },
          {
            "sourceTag": "type:api",
            "onlyDependOnLibsWithTags": [
              "type:ui",
              "type:domain",
              "type:util"
            ]
          },
          {
            "sourceTag": "type:ui",
            "onlyDependOnLibsWithTags": ["type:domain", "type:ui", "type:util"]
          },
          {
            "sourceTag": "type:domain",
            "onlyDependOnLibsWithTags": ["type:domain", "type:util"]
          },
          {
            "sourceTag": "type:util",
            "onlyDependOnLibsWithTags": ["type:util"]
          }
        ]
      }
    ]
  }
}

Note also, how the scope rules are set to ensure a scoped lib eg. todo-app lib can only depend on it’s own scope and shared. You might allow for scoped libs to communicate with each other (through their API libs) and then you would update the scope rules.

Showcase of the scaled-up architecture

Putting this to action we see the todo list architecture becomes:

Note the control of module boundaries where only feature libs can interact with its’ domain’s lower layers (UI, domain, and utils) other domains API libs.

With this architecture in place, we ensure a nice domain-driven design that can even group shared code into each subdomain (rather than it all going to shared) optimizing for the best cohesion according to the common closure principle (in a given subdomain, changes to the feature and domain lib often go together so they should be grouped together).

Conclusion

I hope this blog post gave you some insights into how you should create and evolve your architecture based on your needs right now. Many projects go straight to the scaled-up approach which might cause over-engineering and headaches when it is not needed, so you should consider first if you could get by with the simpler folder-based architecture first. You can then scale up as needed later.

I recommend going through this with an experienced expert as it is expensive to get wrong. If you are interested in hands-on feedback and to learn more on how to build Angular apps like an Angular architect I recommend you check out the next cohort of Angular Architect Accelerator.

Do you want to become an Angular architect? Check out Angular Architect Accelerator.

Related Posts and Comments

How to Set up a CI pipeline with Azure Pipelines and Nx

It goes without saying that having a CI pipeline for your Angular apps is a must. Setting one up for regular Angular apps is fairly straightforward but when you have an Nx monorepo there are certain other challenges that you have to overcome to successfully orchestrate a “build once, deploy many” pipeline. This post will

Read More »

How to Set Up Git Hooks in an Nx Repo

Git hooks can be used to automate tasks in your development workflow. The earlier a bug is discovered, the cheaper it is to fix (and the less impact it has). Therefore it can be helpful to run tasks such as linting, formatting, and tests when you are e.g. committing and pushing your code, so any

Read More »