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.