Using Angular in Monorepo Projects: Advantages and Strategies

Anton Ioffe - November 30th 2023 - 9 minutes read

Embark on a journey through the monorepo realm where the robustness of Angular melds with the singular coherence of a unified codebase. In the ensuing discourse, you will traverse the intricate landscapes of architectural design, unearth pathways to streamline build and test pipelines, navigate the intricate web of dependency management, and learn the sagacious lessons distilled from the trenches of monorepo misadventures. Prepare to elevate your enterprise-scale Angular projects with tested strategies and introspections that promise to refine your perspective on what it means to develop with a monolithic mindset.

Embracing the Monolithic Brotherhood: Angular in the Monorepo Realm

A monorepo is characterized by its inclusive approach to project management, where multiple Angular applications or libraries reside within a single repository. This structure fosters an environment of shared knowledge, directly addressing the frequent pain points associated with maintaining multiple repositories. In such an ecosystem, developers find a fertile ground for code sharing, where standardized components and services can be built once and utilized across the entirety of the organization's Angular projects, steering clear of the trap of code duplication.

The unified nature of a monorepo enhances consistency across all Angular applications. With all your coding standards, style guides, and architectural patterns in one place, it becomes significantly easier to maintain harmony among disparate project teams. This singularity enables developers to implement a consistent set of tools and frameworks across all sub-projects, reinforcing best practices and reducing the cognitive load associated with context switching.

Large-scale refactoring is a task where monorepos truly shine in the context of Angular development. Suppose a shared service or component requires an update due to a new business requirement; thanks to the interconnected landscape of a monorepo, such changes can be propagated throughout all dependant Angular applications swiftly. The alternatives involving numerous repositories would entail manually replicating such updates, a laborious and error-prone process, highlighting one of the strongest cases for the monorepo approach.

An Angular monorepo strategy also addresses another key aspect of software development: dependency management. A single package.json at the root of your repository can govern all projects, making it easier to keep your Angular applications synchronized with regards to library versions and tooling dependencies. This is instrumental in preventing the "works on my machine" syndrome by ensuring that every developer and every deployment pipeline is drawing from the same set of dependencies.

Lastly, the cohesion provided by a monorepo allows Angular teams to undertake comprehensive testing strategies with increased efficiency. Shared code means shared tests, which translates to robust coverage and higher confidence in the integrity of the applications. All Angular projects can benefit from a common set of testing suites, enabling the detection and resolution of issues before they reach production, and affording developers the ability to make changes knowing that any adverse effects will likely be caught through this collective safety net.

In essence, a monorepo cements a culture of collaboration and unity within the Angular space, ensuring that code quality is not only a responsibility of individual developers but is under the purview of the entire development fraternity—tailored for the enterprise yet adaptable enough for smaller teams who aspire for growth and excellence.

Architectural Design for Angular Monorepos: Structuring for Scale and Efficiency

When adopting a monorepo for managing multiple Angular projects, the architectural design must be thoughtfully considered to ensure successful scaling and efficiency. A pragmatic folder structure is critical; organizing folders by feature or domain can significantly enhance maintainability and clarity. For instance, each feature could reside in its own directory, encapsulating related components, services, and models, thus adhering to Angular's modular nature. Such an approach promotes a clear separation of concerns and makes it easier to onboard new developers, as they can quickly understand the purpose and scope of different parts of the application.

Regarding strategic code separation, leveraging Angular's modules to encapsulate functionality is a powerful way to maintain a clean and decoupled architecture. Consider the following structure for feature modules:

// Feature module definition file: customer.module.ts
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { CustomerComponent } from './customer.component';

@NgModule({
    declarations: [CustomerComponent],
    imports: [CommonModule],
    exports: [CustomerComponent]
})
export class CustomerModule {}

In the snippet above, the CustomerModule encapsulates all components and services related to customer features. This fosters reusability and facilitates lazy loading to improve application performance.

The modularization of services and state management must be handled with care. Services available application-wide and instantiated once, such as an AuthService, are fundamental. Angular's dependency injection system allows services to be provided in the 'root' scope, ensuring that there is only one instance of the service throughout the application, as described below:

// Authentication service: auth.service.ts
import { Injectable } from '@angular/core';

@Injectable({
    providedIn: 'root'
})
export class AuthService {
    private isAuthenticated = false;

    // Authentication logic
}

By using providedIn: 'root', this service is available for injection throughout your application without needing to be included in any providers array of a module.

Another aspect is the construction and maintenance of shared component libraries within the monorepo. To maintain visual and functional consistency, building a library of reusable UI components within their own dedicated Angular modules is critical. For example, a shared button component should be part of a shared NgModule like this:

// Shared button component file: button.component.ts
import { Component, Input } from '@angular/core';

@Component({
    selector: 'app-shared-button',
    template: '<button [ngClass]="btnClass">{{ label }}</button>',
})
export class ButtonComponent {
    @Input() label: string;
    @Input() btnClass: string;
}

// Shared module file: shared.module.ts
import { NgModule } from '@angular/core';
import { ButtonComponent } from './button.component';

@NgModule({
    declarations: [ButtonComponent],
    exports: [ButtonComponent]
})
export class SharedModule {}

Finally, avoiding circular dependencies is paramount to maintaining a healthy and manageable codebase. By embedding best practices, standardized tooling, such as linters and custom schematics in the Angular CLI, and strictly managing dependencies, development velocity can be maximized. Iteratively refining the monorepo structure and practices as your projects and teams grow ensures your architecture remains effective and sustainable.

Optimizing Build and Test Pipelines in Angular Monorepos

Incremental building is pivotal for optimizing the Angular build process in a monorepo setting. Nx tooling exemplifies this by employing a project graph to detect changes. Developers typically use Nx's affected commands to build only the altered parts of your application. In practice, this looks like:

nx affected:build --base=main --with-deps

This command employs the Nx capability to target affected projects since the main branch, along with their dependencies, leading to a more efficient build process by avoiding unnecessary work.

Caching is a cornerstone of quick builds in monorepos, with Angular CLI providing built-in support for it. By default, Angular CLI caches the build info, but with careful configuration, the efficiency can be significantly improved. Here's how you might enable advanced caching with Angular CLI:

{
  "cli": {
    "cache": {
      "enabled": true,
      "path": ".cache",
      "environment": "all"
    }
  }
}

Inserting this snippet in your angular.json file instructs Angular CLI to cache build artifacts, which reduces build times for unchanged components.

Targeted testing maximizes test efficiency by running only tests for affected code. Using Nx, this can be configured as:

nx affected:test --parallel --maxParallel=4

This command conducts tests for affected projects in up to four parallel operations, vastly improving the speed of your development cycle by focusing resources where most needed.

To effectively utilize parallel testing in CI pipelines, configuring a CI tool like GitHub Actions is imperative. An example workflow might include steps like:

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [14.x]
        # Define a number of parallel jobs.
        include:
          - job-num: 1
          - job-num: 2
          - job-num: 3
          - job-num: 4
    steps:
      - uses: actions/checkout@v2
      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v1
        with:
          node-version: ${{ matrix.node-version }}
      - run: npm install
      - run: nx affected:test --parallel --maxParallel=4

Such a GitHub Actions configuration enables parallel test execution, leveraging GitHub's matrix strategy for simultaneous job processing.

Implementing these strategies can nonetheless come with trade-offs. Overreliance on caching can lead to stale artifacts if not correctly invalidated, while unbridled parallelism can consume all available CI capacity and slow down the pipeline. Therefore, the implementation must be dynamic; observe your project's behavior and adjust caching strategies and concurrency levels appropriately. Your goal is a finely-tuned pipeline that balances rapid feedback with resource efficiency. Have you mapped out your build and test pipelines' critical paths, and are you exploiting concurrency optimally while maintaining reliability? These are key questions for perfecting your Angular monorepos' CI/CD process.

Managing dependencies within an Angular monorepo encompasses a mindful strategy to maintain single-version consistency while recognizing room for project-centred variability. Establishing a centralized package.json in the monorepo's root offers a streamlined approach to align shared dependencies and act as a single source of truth.

Example: Centralized root package.json

{
  "name": "enterprise-monorepo",
  "version": "1.0.0",
  "dependencies": {
    "@angular/core": "^12.0.0",
    "@angular/common": "^12.0.0",
    "rxjs": "^6.6.0",
    // Additional shared dependencies follow...
  }
}

When it comes to selective dependency inclusion, be aware of potential trade-offs. Project-specific package.json files facilitate clearer boundaries and smaller bundle sizes, yet risk fragmenting the update process. Shared bug fixes and features could also bypass applications shielded by distinct dependency versions, heightening maintenance demands.

Nx demonstrates its prowess in managing Angular monorepo dependencies with features like implicitDependencies, which foster efficient modifications by rerunning tasks only for impacted apps and libraries, thereby increasing developer productivity.

Example: Specifying implicitDependencies in nx.json

{
  ...
  "implicitDependencies": {
    "libs/shared-ui/package.json": ["apps/customer-portal", "apps/admin-dashboard"]
  },
  ...
}

Meticulous adherence to semantic versioning prevents inconsistencies during library updates by communicating the impact of changes. Incrementing the major version number signals breaking changes, guiding downstream projects to adapt accordingly.

When introducing new dependencies, evaluate their role in the existing ecosystem. Can they provide value across multiple projects, or might they contribute to build inefficiencies and redundancy? Applying a consistent assessment framework ensures a judicious decision-making process.

Lockfiles like package-lock.json or yarn.lock are instrumental in stabilizing your dependency graph. Synchronization with package.json is essential, as it enables deterministic builds, guards against rogue updates, and streamlines the developer experience.

Commands for ensuring lockfile consistency

// After adjusting dependencies in `package.json`, run:
npm install 
// or
yarn install
// to update the lockfile accordingly.

Nuanced dependency management within an Angular monorepo is about balancing modularity with shared efficiencies. By following structured criteria, enforcing versioning rigor, and understanding the implications of each dependency decision, development teams can navigate this complexity with finesse.

The Pitfalls of Angular Monorepos: Common Mistakes and Lessons Learned

One common pitfall encountered in managing Angular monorepos is the improper scoping of shared libraries and components. Developers might overgeneralize by extracting too much functionality into shared modules, creating a rigid structure that hinders agility. For instance, consider a shared UtilsModule containing a general-purpose DataService that is used by multiple feature modules:

@Injectable({ providedIn: 'root' })
export class DataService {
    // A broad implementation that attempts to cover too many use cases
    getData(query: QueryOptions): Observable<Data> {
        // Implementation details
    }
}

The DataService above could become bloated with numerous conditional checks for different use cases. The correct approach is to scope shared services appropriately, perhaps creating more specific services that extend a base service, thus preserving modularity:

@Injectable()
export abstract class BaseDataService {
    protected abstract getData(query: QueryOptions): Observable<Data>;
}

@Injectable({ providedIn: 'root' })
export class SpecificDataService extends BaseDataService {
    // Scoped implementation details
    getData(query: QueryOptions): Observable<Data> {
        // Scoped implementation details
    }
}

Another mistake is the improper configuration of CI/CD pipelines that handle monorepo setups ineffectively, resulting in slow build times and resource wastage. A suboptimal approach involves CI/CD pipelines rebuilding and testing all projects in the monorepo upon any change, ignoring the fact that many projects may be unaffected by a given change.

To correct this, implement selective builds and testing strategies utilizing tools that can identify affected projects, only running the necessary build and test commands for those areas of the codebase that have changed:

// A pseudo-code snippet for a CI pipeline step
runSelectiveBuildsAndTests() {
    affectedProjects = getAffectedProjects();
    build(affectedProjects);
    test(affectedProjects);
}

Developers can fall into antipatterns with monorepo management by overlooking access control and failing to implement clear boundaries between projects. It's essential to use configuration and tooling to enforce access at the project level, ensuring team members only modify projects they are authorized to:

// Example of a pseudo-code configuration for access control
configureAccessControl() {
    defineProjectLevelAccess({
        'projectA': ['teamA'],
        'projectB': ['teamB', 'teamC']
        // Additional configurations
    });
}

Question for Reflection: Are you ensuring that your shared modules truly warrant being shared across every project, or could they be more effectively scoped? Consider revisiting the granularity of these shared resources. Additionally, is your CI/CD pipeline smart enough to detect and only act upon changes that affect the relevant parts of the monorepo, or is it wasting valuable time and compute resources with unnecessary operations?

Summary

In this article, I discussed the advantages and strategies of using Angular in monorepo projects. The main advantages of using Angular in a monorepo include code sharing, consistency, efficient large-scale refactoring, streamlined dependency management, and comprehensive testing strategies. I also provided insights into architectural design, building and testing pipelines, and dependency management in Angular monorepos. The key takeaway is that adopting a monorepo approach with Angular can enhance collaboration, improve code quality, and optimize development workflows. As a challenging technical task, I encourage readers to reassess the scope of their shared modules in the monorepo and ensure that they are truly necessary and effectively scoped for each project, to avoid overgeneralization and promote modularity. Additionally, readers should review and optimize their CI/CD pipelines to selectively build and test only the affected parts of the monorepo, rather than unnecessarily rebuilding and testing all projects.

Don't Get Left Behind:
The Top 5 Career-Ending Mistakes Software Developers Make
FREE Cheat Sheet for Software Developers