
Sustainably Improving Angular Application Architecture with Verticals
Introduction
The architecture of an application is a crucial factor for long-term quality and maintainability. Unfortunately, this topic is often neglected and rarely supported with appropriate tools.
In this case study, I want to demonstrate the problems and tradeoffs I found in a client’s Angular application and how I was able to sustainably solve them using Verticals and appropriate tooling.
Initial Situation
The starting point was an Angular application that organized its modules using a horizontal slice approach. This meant that modules were organized by technical aspects or layers rather than by business functionality. In simplified terms, the modules in the application were organized as follows:
Angular applications organized according to this principle often contain modules like Core, Shared, Components, Services, Directives, Pipes, etc.
This led to numerous problems:
- Lack of business functionality encapsulation: The modules were unable to encapsulate their own business logic. This meant that changes to one module often required changes to other modules.
- Lack of code colocation: The modules were unable to encapsulate their own code. This resulted in code for a feature being spread across multiple modules.
- Lack of modularization: The modules were unable to encapsulate their own modularity. This made the application difficult to maintain and extend.
- Lack of architecture automation: Due to the unfavorable organization of modules, it was impossible to automatically enforce architectural guidelines.
- Poor developer experience: Developers had to constantly jump between different modules to find code for a feature. This led to poor developer experience and made it difficult to develop new features or modify existing ones. It was particularly difficult for new developers to familiarize themselves with the application.
In the specific client application, the following simplified folder structure was present:
src/
└── app/
├── feature/
│ ├── detail/
│ │ ├── product-detail.component.ts
│ │ └── services-detail.component.ts
│ └── overview/
│ ├── product-overview.component.ts
│ └── services-overview.component.ts
├── shared/
│ ├── models/
│ │ ├── customer.ts
│ │ ├── product.ts
│ │ └── service.ts
│ ├── services/
│ │ ├── products-api.service.ts
│ │ └── services-api.service.ts
│ └── utils/
│ ├── create-log-entry.ts
│ └── list-all-products.ts
├── state/
│ ├── products/
│ │ ├── products.actions.ts
│ │ ├── products.effects.ts
│ │ ├── products.reducer.ts
│ │ ├── products.selectors.ts
│ │ └── products.state.ts
│ └── services/
│ ├── services.actions.ts
│ ├── services.effects.ts
│ ├── services.reducer.ts
│ ├── services.selectors.ts
│ └── services.state.ts
Goals
Based on the problems mentioned above, the following goals ultimately emerge: Encapsulation of business functionality/features, code colocation, modularization, automated architecture enforcement, and good developer experience. By implementing with Verticals, a homogeneous target state naturally emerges where the goals mutually contribute to achievement and don’t conflict with each other.
Theory
What are Verticals?
Verticals, more precisely Vertical Slice Architecture (VSA), is an architectural approach that aims to organize the application by vertical slices, each encapsulating a specific functionality or feature. This means that each vertical layer contains all necessary components to provide a specific functionality. Horizontal slices, on the other hand, organize the application by technical aspects, which leads to the problems mentioned above.
Types and Scopes
A Type and a Scope are two important concepts related to Verticals that help organize and structure Verticals by modules. They each cover a dimension that is important for organizing Verticals.
- Scope: A scope is an area or domain that encapsulates a specific functionality or feature. It defines the context in which the Vertical operates. For example, a scope for an e-commerce application could be “Orders”, meaning that all code related to orders is contained in this Vertical.
- Type: The Type is similar to a layer from layered architecture and defines the type of functionality, e.g., Models or a concern. In the context of Angular and also influenced by Nx, the following Types are common:
- Feature: Contains the logic and components for a specific feature. Particularly Smart Components.
- UI: Contains the UI components (Dumb Components) used for the feature.
- Data Access: Contains the logic for accessing data, e.g., services that perform HTTP requests, as well as models that define the data structure. Can also include State Management.
- Utils: Utility functions.
Deviating from the mentioned Types, which are aligned with Nx, this case study additionally uses the following Types:
- Types: Contains the models used for the feature, e.g., interfaces or enums. This concern is separated from the Data Access type to ensure a clear separation between data structures and data access logic.
- State: Contains the state for the feature, e.g., the NgRx Signal Store.
- Events: Contains the (Store) events for the feature. In the case of an NgRx Signal Store, these are the actions.
Access Rules
Access rules define how modules are allowed to access each other. The rules are important to ensure encapsulation of Verticals so that Verticals are independent of each other or have loose coupling. Within a Vertical, access rules are important to ensure modularity within the Vertical and maintain its desired architecture. That is, access rules regulate access in the dimensions of Scope and Type.
Access rules are also known as Module Boundaries.
Module Boundaries in the Scope Dimension
In the Scope dimension, module boundaries are usually defined so that access between scopes is not allowed with the exception of the shared scope.
This rule ensures that Verticals are independent of each other and only communicate through defined interfaces. This promotes encapsulation of business logic and prevents changes in one scope from affecting other scopes.
Additionally, this would allow easily extracting a Vertical/feature from the application into a separate application or merging them.
Module Boundaries in the Type Dimension
In the Type dimension, module boundaries are usually defined based on the module’s task and significantly influence the architecture within a Vertical (Scope).
Typically, access rules in the Type dimension are defined as follows:
- Feature may access the modules UI, Data Access, Types, Utils, Events, and State.
- UI may access the modules Types, Utils, Events, and Types.
- Data Access may access the modules Types and Utils.
- Types may not access any other module.
- Utils may access the module Types.
- State may access the modules Types, Data Access, Events, and Utils.
- Events may access the module Types.
Tooling
To implement architectural guidelines, there are mainly three tools in the TypeScript and Angular ecosystem that are suitable: NX Module Boundaries, ESLint Plugin Boundaries, and Sheriff. All tools ultimately build on ESLint or can be integrated with ESLint.
In this case study, Sheriff is used.
Sheriff
Sheriff is a tool specialized in defining Module Boundaries and Access Rules or Dependency Rules and verifying them via ESLint.
Sheriff is an open source project developed by softarc-consulting. It is written in TypeScript and can be integrated into any TypeScript-based application, including Angular applications. Sheriff provides a simple way to define and verify architectural guidelines without requiring manual checks. Sheriff is primarily developed and driven by Rainer Hahnekamp.
I have written an introductory article about Sheriff here: Scalable Architecture with Sheriff
Implementation in Practice Example
Integration of Sheriff
First, sheriff
was integrated and the corresponding architectural guidelines were implemented using modules
and dependency rules
:
import { SheriffConfig, sameTag } from '@softarc/sheriff-core';
export const config: SheriffConfig = {
enableBarrelLess: true,
modules: {
'src/app/<feature>/<type>': [
'scope:<feature>',
'type:<type>',
],
},
depRules: {
root: 'noTag',
noTag: 'noTag',
'scope:*': [sameTag, 'scope:shared'],
'app:*': ({ from, to }) => from === to,
'type:feature': ['type:ui', 'type:types', 'type:data-access', 'type:utils', 'type:state', 'type:events'],
'type:ui': ['type:ui', 'type:types', 'type:utils'],
'type:utils': ['type:types', 'type:utils'],
'type:types': ['type:types'],
'type:data-access': ['type:types', 'type:utils', 'type:data-access'],
'type:state': ['type:data-access', 'type:types', 'type:utils', 'type:events'],
'type:events': ['type:types', 'type:events'],
},
};
The Sheriff configuration defines the modules and access rules for the Verticals. Modules are defined by scope and type. Access rules are defined by the tags assigned to the modules. The tags in this case are the scopes and types assigned to the modules. The access rules define which modules may access which other modules.
The Sheriff configuration is stored in the sheriff.config.ts
file in the application’s root directory. Sheriff is then integrated with ESLint. If dependency rules
are violated, you receive corresponding error messages following this pattern when running ng lint
:
1:1 error
module src/app/shared/data-access cannot access src/app/admin/data-access.
Tag scope:shared has no clearance for tags scope:admin, type:data-access @softarc/sheriff/dependency-rule
Implementation of Verticals
Subsequently, the Verticals were implemented. The application’s folder structure was adjusted to represent the Verticals. The Verticals were organized by scopes and types. The folder structure now looks as follows:
src/
└── app/
├── products/
│ ├── data-access/
│ │ └── products-api.service.ts
│ ├── events/
│ │ └── products.actions.ts
│ ├── feature/
│ │ └── product-overview.component.ts
│ ├── state/
│ │ ├── products.effects.ts
│ │ ├── products.reducer.ts
│ │ ├── products.selectors.ts
│ │ └── products.state.ts
│ ├── types/
│ │ └── product.ts
│ ├── ui/
│ │ └── product-detail.component.ts
│ └── utils/
│ └── list-all-products.ts
└── services/
├── data-access/
│ └── services-api.service.ts
├── events/
│ └── services.actions.ts
├── feature/
│ └── services-overview.component.ts
├── state/
│ ├── services.effects.ts
│ ├── services.reducer.ts
│ ├── services.selectors.ts
│ └── services.state.ts
└── types/
└── service.ts
└── ui/
└── services-detail.component.ts
└── shared/
├── types/
│ └── customer.ts
└── utils/
└── create-log-entry.ts
As you can see when comparing the folder structures, the application is now organized by Verticals. Each Vertical contains all necessary components to provide specific functionality. The Verticals are independent of each other and can be easily extended or modified without affecting other Verticals.
At the same time, each individual Vertical could potentially be extracted into a separate application at any time, as the Verticals are independent of each other and only communicate through defined interfaces or decoupled events.
Summary & Outlook
This case study demonstrated how to sustainably improve the architecture of an Angular application by implementing Verticals and automatically verifying architectural compliance with ESLint.
In the real project, this was implemented with a big-bang refactoring. That is, the entire application was converted to the new architecture in one go. This approach was only possible because the application had a manageable size and the features were already quite well decoupled from each other.
However, this approach is usually not possible and the migration must be done incrementally in smaller steps. This approach is also possible with Sheriff - how? I will show this in a future article.
In the client project, after the migration to Verticals and the integration of Sheriff, the developer experience, maintainability, and modularity of the application improved significantly. Developers were able to familiarize themselves with the application faster and develop new features without constantly jumping between different modules. The encapsulation of business functionality was also improved, as the Verticals are now able to encapsulate their own business logic. Architecture automation was also significantly improved through Sheriff integration, as it now automatically checks whether architectural guidelines are being followed.