Building an Angular UX Framework

Posted By: Brett Mayen
Dated: March 22, 2021

kid on white table painting
Photo by cottonbro on Pexels.com

Preparation and Planning (Part I of III)

This is the first in a series of three posts discussing the process of building and maintaining an Angular UX Framework and deploying its libraries to NPM. We will tackle this through the case study lens of the Softrams-built “Health Plan Management System (HPMS) UX Framework,” but general principles should apply in most cases.

Overview

In Part I, we’ll cover the thought process and planning that’s required up front to help ensure we don’t paint ourselves into a corner down the line. Measure twice, cut once!

  1. Preamble
  2. Organization
    • Separation of concerns
    • Monorepo vs. Individual Repositories
  3. Understand Your Consumer Audience
    • Dependency Management

Preamble

A little background and context before we dive in.

HPMS is built on a Microservice/Microfrontend architecture. Each Microfrontend is its own Angular application that can be developed, tested, and deployed separate from the others. While this provides much-needed flexibility across the board, it comes at a cost. Keeping these codebases up-to-date and consistent becomes difficult to maintain as organizations scale. Add to this the fact that multiple, isolated organizations contribute Microfrontends to the HPMS ecosystem, and a need to address this centrally arises.

By building a UX Framework consisting of Angular Schematics, reusable themes and components, and Storybook documentation, we are able to keep disparate projects on the same Angular version with the same look and feel, referencing the same central documentation, regardless of which organization or team is building them. As business requirements and designs evolve, these changes are easily reintegrated into existing projects by running Angular Schematics’ ng update command and updating library versions. Creating a new project is simply a matter of running ng new and all of the project starter requirements are generated automatically.

Now that we have the “why”, let’s explore the “how”!

Organization

The first decision you’ll face is how to organize your code. The choices made here will be difficult to pivot from down the road, so take your time and be confident you’re making the right decision from the outset.

Separation of Concerns

While we could put everything into a single library and call it a day, depending on the framework’s scope, it may benefit from being broken up into multiple libraries. Consider the following Pros and Cons of doing so:

Pros

  • Library versions more accurately reflect changes to affected code
  • Each library lends itself to reusability
  • Simplified individual codebases

Cons

  • More deployment overhead
  • More dependency management overhead for consuming projects

Ultimately, you’re looking to strike a balance. Don’t separate your code enough and you risk excessive deployments, semantic version bumps for disparate features, and a convoluted codebase. Get too fine-grained with multiple libraries and you risk managing cross-dependencies, unnecessary deployment overhead, and difficulty keeping projects up-to-date.

Case Study: HPMS UX Framework Libraries

When planning the HPMS UX Framework, the following requirements needed to be addressed up front:

  1. Look and feel of common Angular components
  2. Functionality of common Angular components
  3. Project generation and future migrations
  4. Application Skeleton and common styles consumable by non-Angular applications

Since our Framework was being built on Angular, support for #3 is provided via Angular Schematics and support for #4 is provided via Angular Elements. We decided to organize the code into the following libraries:

  1. @hpms/ux-ng: Look and feel of common Angular components
  2. @hpms/core-ng: Functionality of common Angular components
  3. @hpms/schematics: Project generation and future migrations
  4. @hpms/ux-vanilla: Re-exporting @hpms/ux-ng Application Skeleton and common styles via Angular Elements

At first glance, this seems like a perfectly reasonable separation of concerns given the Framework requirements. In practice, however, it proved to be too fine-grained as we ran into Dependency Management and Schematics development issues.

The Dependency Management Problem

Although the responsibilities of @hpms/ux-ng, @hpms/ux-vanilla, and @hpms/core-ng were separate, consuming projects often relied on specific version pairings. The problem domains of these libs were separate, but they were functionally dependent on each other. For instance, a ux-ng component might rely on some functionality being provided to the consuming project via core-ng.

One way to handle this is to manage these relationships via PeerDependencies and rely on consuming projects to satisfy them. This works, but is prone to error both when developing the libraries and consuming them.

On the library development side of things, you must be very careful that the PeerDependency ranges aren’t overly relaxed or overly restrictive. This can be particularly difficult to manage as the size/scope of the Framework increases.

On the consumer side of things, it’s up to individual devs to make sure dependencies are always within the correct range. NPM helps with this via warnings, but it’s not a guarantee that developers will adhere to them. Especially when incorrect versions will not necessarily fail compilation. This creates a high probability of latent bugs in the project.

Lastly, a breaking change to one library would often necessitate a breaking change to the other. This largely defeats the purpose of having separate libs in the first place.

The Schematics Problem

By having a single library responsible for creating/migrating all project code, we spent a lot of time maintaining this one library and releasing new versions. Updates to @hpms/ux-ng and @hpms/core-ng often necessitated updates to @hpms/schematics. Each version became more intrinsically tied to others as time went on. It became clear this wasn’t a tenable separation of concerns.

The Solution/Challenge

The solution to this began with getting rid of @hpms/core-ng and moving its code into @hpms/ux-ng. This immediately resolved the majority of our dependency management issues since code from both libs were now versioned under a single library and no longer required PeerDependency ranges to ensure installed versions were compatible.

A downside to this approach is that the @hpms/ux-ng package is larger and @hpms/ux-vanilla, which has a dependency on it, now has to download additional code it doesn’t actually use. This is ultimately inconsequential since that code gets tree-shaken out on build, but it’s worth noting.

@hpms/schematics is still used, but it is only concerned with initial project generation and migrating changes to core project files. All of the responsibility of wiring up @hpms/ux-ng and incorporating any breaking changes in a project was moved into library-specific “add” and “update” schematics. This decouples the @hpms/ux-ng semantic version from the @hpms/schematics semantic version.

Reorganizing the code in this manner after it has been deployed and is being used in production applications is no small task, and one that is best avoided by taking the time to make the right organizational decisions up front. Since we already leveraged Schematics, we were able to continue using them to automate all of the above changes, but it was a heavy lift that took much more time and effort than getting it right from the beginning.

Monorepo vs Individual Repositories

We won’t retread general arguments for and against monorepos here. There are plenty of other articles already written covering that topic and quick Google search will spoil you for choice there. However, here are a couple NPM-specific things to consider:

  • If using semantic-release to handle automatic semantic-versioning, at the time of this writing, it does not integrate well with monorepos.
  • If using a library such as Lerna to manage a monorepo, you must commit to how versions are handled across libraries. If you bump them all in lockstep, that has additional consequences for semantic-versioned libraries.

We started with a Monorepo but soon migrated to individual repositories to address the concerns above. There’s not a one-size-fits-all approach here though, so YMMV.

Understanding Your Consumer Audience

Are you building this Framework to be consumed exclusively in-house, or will it be made available to the general public? This is another way of asking, “Will you have complete control over how this framework is consumed, or will you need to support a wider variety of use-cases?”

Dependency Management

For anything but the simplest of libraries, you will need to have a plan in place to manage its dependencies. Much of this decision will depend on how much control you have over how the library is consumed. If you are making this available to the general public, you’ll likely want them to be able to use a range of dependency versions in a way that best suits their particular needs. However, if your audience is exclusively in-house, you might choose to be more strict in favor of more reliable code and easier installations.

Case Study: @hpms/ux-ng

@hpms/ux-ng leverages Angular Material as its primary Component Library. It provides a custom Theme and a number of additional style and functional overrides to Angular Material, but largely uses its out-of-the-box capabilities. Additionally, it leverages functionality from a number of other libraries such as Bootstrap, Moment, etc. Consequently, these are all required dependencies of @hpms/ux-ng.

The Flexible Approach

By defining @angular/material as a *PeerDependency* and providing a range of compatible versions, @hpms/ux-ng can assume that consuming projects will provide this dependency.

Pros

  • Consumers can choose which version within the range best suits them

Cons

  • NPM <7 relies on consumers installing the correct version manually
  • Limited control at the library level
The Strict Approach

By defining @angular/material as a *direct* Dependency of @hpms/ux-ng, consuming projects will automatically get the dependency upon installation without having to manually specify it in their package.json dependencies.

Pros

  • Library controls dependency version and can force updates to a specific version as needed
  • Simplifies installation and updates
  • Increases reliability since the library can be built to support a specific version

Cons

  • If the dependency includes Angular “update” Schematics, they will not be run when project executes ng update
  • Less flexibility for consuming projects
  • Requires “whitelistedNonPeerDependencies” array in ngPackagr config
Our solution

@hpms/ux-ng uses a mix of both the flexible and strict approaches described above. In earlier iterations, it exclusively used the strict approach to handle all of its dependencies (Angular Material, Bootstrap, Moment, etc), defining them as direct dependencies of the library itself. However, in order to leverage the “update” Schematics of libraries such as Angular Material, we defined *just those dependencies* as PeerDependencies, to be satisfied by the consuming projects. This solution allows us to trigger ng update on libraries that define their own Schematics, while retaining stricter versioning and easier installation of all other dependencies of the library.

Depending on your consumer audience, you might choose one approach over the other, or some combination of both.

Conclusion

Taking time to think through these considerations in the early stages of framework development will save time and heartache down the road. Identify what your requirements and target audience are beforehand and plan things out accordingly!

In Part II of this series, we’ll dive into Development and Maintenance.