Summary
Introduction
I've always wondered, after various Angular projects, how to create organized, maintainable, and testable projects. While Angular documentation provides guidelines on writing components, directives, and pipes, sometimes it's not enough. For enterprise-level Angular projects, it falls short. I started searching on GitHub for various projects and studying several articles on Angular until someone recommended reading articles from angular architects.
In particular, the book "Microfrontend and Moduliths with Angular" can be downloaded here. In one of these chapters, it explains how to structure Angular projects with Domain-Driven Design, supported by NX.
What is DDD?
Domain-Driven Design is an approach to software development that centers the development on programming a domain model that has a rich understanding of the processes and rules of a domain. The name comes from a 2003 book by Eric Evans that describes the approach through a catalog of patterns.
One of the DDD disciplines I have utilized is strategic design. The goal of strategic design is to identify so-called sub-domains that donβt need to know much about each other.
Now, I won't delve further into the topic, but for those interested, I recommend reading the book Domain-Driven Design: Tackling Complexity in the Heart of Software.
What is NX?
NX is a tool that enables deploying, building, and testing scalable JavaScript applications. It can be used to create frontend/backend projects that communicate with each other, or to build microservices applications using Node.js, or a suite of frontend projects utilizing a library of components among themselves. It's a powerful tool with a wide range of capabilities! In this case, I used it to create Domain-Driven Design projects.
A little bit of NX commands
Here are some commands that I've used and that might be useful for you to create a monorepo project with NX.
Create project
npx create-nx-workspace@latest <monorepo-project> --preset=angular-monorepo
Create library
nx g @nx/angular:library <library-name> --tags=ui
Create component
nx g @nx/angular:component <component-name> --directory=path/to/library
Create an Angular component with the "directory" argument that allows specifying where to create the component.
Rick and morty project
Who likes Rick and Morty? Some love it, and some are not telling the truth π. I've created an Angular project with NX where you can see how I've structured the project. The application is not complete yet, but it demonstrates the structure an enterprise application should have. In this repository, you'll find the project. I used The Rick and Morty API to retrieve all the data about Rick and Morty characters.
Structure Folder
From the image below, you can see that there is a project named rick-and-morty-monorepo in the apps folder, and in the libs folder, I have divided them by subdomains, following the principles of Domain-Driven Design with strategic design.
the subdomains I identified are:
- character
- location
- episode
In each subdomain, the following libraries have been created:
- api: Provides functionalities exposed to other domains (service classes, store).
- ui: Library where dumb components are located.
- util: Utility library containing helper functions or models available to ui/util/features libraries.
- domain: Implements the entire business logic, including HTTP services and data management. For data management, I used @ngrx/signals.
- features: Implements use cases using smart components.
- shell: Provides an entry point for the domain. It contains all the routes to be assigned to the rick-and-morty-monorepo application.
Each subdomain can utilize shared libraries, and for this reason, I have created three libraries that can be used by all the other libraries. These are:
- shared-ui: Shareable components across all subdomains.
- mock: Mock classes, objects that can be used in tests.
- util: Helper functions.
Solid Architecture
This structuring of libraries allows for the separation of responsibilities between classes/components and brings a certain order and cleanliness to the project. To maintain this robust architecture, it is necessary to limit the interaction between libraries. Rules can be established through ESLint:
"@nx/enforce-module-boundaries": [
"error",
{
"allow": [],
"depConstraints": [
{
"sourceTag": "scope:app",
"onlyDependOnLibsWithTags": ["type:shell"]
},
{
"sourceTag": "type:api",
"onlyDependOnLibsWithTags": ["type:domain"]
},
{
"sourceTag": "type:shell",
"onlyDependOnLibsWithTags": ["type:feature", "type:util"]
},
{
"sourceTag": "type:feature",
"onlyDependOnLibsWithTags": ["type:domain", "type:util", "type:ui", "type:mock"]
},
{
"sourceTag": "type:ui",
"onlyDependOnLibsWithTags": ["type:util", "type:mock"]
},
{
"sourceTag": "type:domain",
"onlyDependOnLibsWithTags": ["type:util", "type:mock"]
},
{
"sourceTag": "type:mock",
"onlyDependOnLibsWithTags": ["type:util"]
},
{
"sourceTag": "type:shared",
"onlyDependOnLibsWithTags": []
}
]
}
],
These rules define the interaction scope between different libraries:
- scope:app: Applications can only communicate with shell and shared-ui libraries.
- type:domain: Domain libraries can use util and mock libraries for testing.
- type:shell: Shell libraries can use features and util libraries.
- type:feature: Feature libraries can use domain, util, ui, shared-ui, and mock libraries.
- type:ui: UI libraries can use util and mock libraries.
- type:domain: Domain libraries can use util and mock libraries.
- type:mock: Mock libraries can use the util library.
App project
In the main App project, my focus was primarily on creating basic templates. In this instance, I had to develop only one base template, featuring a drawer and footer component with the overall management of HTTP API calls.
Within the main template, <router-outlet></router-outler>
is present, enabling the insertion of content from the shells.
export const TEMPLATE_ROUTES: Routes = [
{
path: '',
component: RickAndMortyTemplateComponent,
children: [
{
path: '',
loadChildren: () => import('@rick-and-morty-monorepo/characters/shell').then(m => m.routes)
},
{
path: 'episodes',
loadChildren: () => import('@rick-and-morty-monorepo/episodes-shell').then(m => m.EPISODES_ROUTES)
},
{
path: 'locations',
loadChildren: () => import('@rick-and-morty-monorepo/locations-shell').then(m => m.LOCATION_ROUTES)
}
]
}
]
As you can see, the template, based on the path, will accept three shell entry points: characters, episodes, and location.
The shell library
The shell library contains nothing but the routes to the features within that domain.
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: '',
loadComponent: () =>
import('./pages/characters-page/characters-page.component').then(
(c) => c.CharactersPageComponent
),
},
];
Of course, in this case, I only had one feature. However, in a shell, I can have many features.
Features Library
Here We find the pages
For these libraries, I've opted to use the container-presentational pattern. In this pattern, the container component is responsible for fetching information through the domain library and passing the result to the dumb component. The page components, on the other hand, handle the skeleton of the page.
//card-list-container.component.ts
@Component({
selector: 'rick-and-morty-monorepo-card-list-container',
standalone: true,
imports: [CardListComponent, CommonModule],
providers: [],
templateUrl: './card-list-container.component.html',
styleUrl: './card-list-container.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CardListContainerComponent implements OnInit {
readonly store = inject(CharacterStore);
ngOnInit(): void {
this.store.charactersRequest({});
}
changePage(page: number) {
this.store.charactersRequest({ page: page });
}
}
<!--card-list-container.component.html-->
<app-card-list
[characters]="store.characters()"
[totalPages]="store.pages()"
(pageSelected)="changePage($event)"
></app-card-list>
<!--characters-page.component.html-->
<div class="p-3">
<rick-and-morty-monorepo-card-list-container></rick-and-morty-monorepo-card-list-container>
</div>
Domain library
In the domain library, you can find services that make HTTP calls and a store for data management. I've delved into @ngrx/signals for data management, and I must say it's quite impressive!
Util library
In the Util libraries, there are models that can be utilized by both the domain and container/presentational components.
Conclusion
Strategic Design is a proven method for breaking down an application into autonomous domains. NX provides a structured way to implement these domains with different libraries grouped by domains. Restrictions enforced through the extension of eslint's enforce-module-boundaries help limit access to other domains and reduce dependencies.
These access restrictions contribute to ensuring easily maintainable systems with minimal impact on other parts of the system
Top comments (0)