Shell Library patterns with Nx and Monorepo Architectures

Post Editor

In this article we will discuss concepts of shell libraries and how they differ. At the end, we will settle on a shell library pattern and discuss under which circumstances it's useful.

14 min read
0 comments
post
Photo by Pratik Patel / Unsplash

Shell Library patterns with Nx and Monorepo Architectures

In this article we will discuss concepts of shell libraries and how they differ. At the end, we will settle on a shell library pattern and discuss under which circumstances it's useful.

post
Photo by Pratik Patel / Unsplash
post
Photo by Pratik Patel / Unsplash
14 min read
0 comments
0 comments

Co-authored with Lars Gyrup Brink Nielsen.

This article is part of the Angular Architectural Patterns series.

In their architecture books, Nrwl and Manfred Steyer define related concepts called feature shell libraries and shell libraries, respectively. We will discuss both of these definitions and how they differ.

Finally, we will settle on a new variation that we call the composite shell library pattern and discuss under which circumstances it's useful.

The Nrwl Feature shell library
Link to this section

In their free e-book "Enterprise Angular Monorepo Patterns", the Nrwl authors state that:

feature-shell is the app-specific feature library

Not very clear, right!? That’s probably because we have omitted some essential parts. First, let’s divide and conquer the definition.

The first part indicates that the feature-shell is app-specific. Therefore, it cannot be appropriately described outside of an application context.

The example used in the Nwrl book represents a Airline company developing different applications for its business. One of those applications is the Booking Application. The Booking Application is the domain-wise encapsulation of two real-world applications, the Booking Web Application and the Booking Mobile Application. Such encapsulation is made under the premise that both applications will share the same functionality and features.

In this case the feature-shell would be the booking-feature-shell.

We now know that the feature-shell is directly connected with the domain-level concept of an application.

The second part of the definition and the final piece of the puzzle is that the feature-shell is a feature library.

According to Nrwl, a feature library contains a set of files that configure a business use case or a page in an application. It includes container components, page components, routed components, and use case-specific presentational components, in general, every type of business-related component.

Besides the booking-feature-shell, there are at least three more feature libraries in the booking application example: flight search, passenger information, and seatmap. Each of these represent different pages of the application. And why not different subdomains?

But, how exactly do these two concepts relate? The Nx book gives us another clue.

<>Copy
export const routes: Routes = [ { path: '', pathMatch: 'full', component: FlightSearchComponent }, { path: 'passenger', loadChildren: () => import('@nrwl-airlines/booking/feature-passenger-info').then( m => m.BookingFeaturePassengerInfoModule ) }, { path: 'seatmap', loadChildren: () => import('@nrwl-airlines/shared/seatmap/feature-seat-listing').then( m => m.SharedSeatmapFeatureSeatListingModule ) } ];
Listing 1. Booking routes.

Listing 1 shows us how navigation would be configured inside the Booking Application. But, this is a concrete implementation and we talked about the Booking Application as an abstraction of the Booking domain.

In which of the two applications do these routes belong? Well, in both.

If these routes were placed in one of our Booking applications, we would need to duplicate it in the others. This kind of duplication has an additional drawback as we would have two or more identical implementations to maintain and keep in sync. In other words, we would be violating the DRY (Don’t Repeat Yourself) principle.

The intuitive solution is to extract this “routing and initialization” logic to a shared library, imported by all of our Booking applications.

Notice, that the above is only true if we assume that our Booking applications behave the same way and have the same routes.

Content imageContent image
Figure 1. Nx booking feature shell library. Made with https://creately.com/

It is evident now that the responsibility of the feature-shell is to act as an orchestrator of the top level routes of the application, aka sub-domains, aka pages. One derived treat of its nature is that we will find exactly one feature-shell library per domain-wise application.

There is exactly one feature-shell library per domain-wise application.

A domain-wise application is the composition of every application that has the same routes, behavior, and functionalities. In the example in Figure 1, the Booking Application is the union of the Booking Web Application, the Booking Desktop Application, and the Booking Mobile Application.

In my opinion, one of the most significant sources of confusion around feature-shell libraries is the fact of them not being treated as a separate library but as a particular case of feature library, even when their responsibilities are different.

Feature shell library use cases
Link to this section

Now that we have a better understanding of what a  feature-shell is and what its responsibilities are (at least according to the Nrwl guidelines), we have to ask ourselves: is there real value in using them? Are they for every scenario?

In my experience, this kind of feature-shell has very few applicable scenarios. Let’s try to find an example of when it could be useful.

With the Nrwl approach, we would need at least two identical applications in navigation/sub-domain/feature terms. Their only difference would be their platform, but remember that we are creating JavaScript-based libraries and applications.

Therefore, our applications have to be implemented in a JavaScript framework supporting the same routing system. Let’s focus on Angular for now, but the following analysis applies to other frameworks and libraries like React.

If we wanted to share our implementations through a feature-shell we will need a framework compatible with Angular. What are our options?

  • Ionic
    We can build the desktop application as a web application and deploy it on the web while we build a hybrid mobile application for the application stores.
  • NativeScript
    We can share code and use NativeScript’s build process to have platform-specific templates for desktop (web) and mobile (native).
  • Electron
    We can have a regular web application for mobile and a hybrid desktop application in an Electron wrapper.

There are some caveats though. Nx does not support any of the technologies above out-of-the-box. Electron is not supported yet, but the plan is to add it.

Alternatively, there is an open-source extension to integrate Electron with Nx.

Also, all three, Electron, NativeScript, and Ionic, can be used with Nx thanks to xplat.

As we can see, feature-shell is not a library pattern that makes sense for every scenario.

In most cases, our application project is the more convenient place to “set up initialization and routing”. I’d recommend using an AppRoutingModule for routing and a CoreModule for configuration. It is not only a common practice that would help new team members to understand the codebase, but it is also a good way to enforce the Single Responsibility Principle.

Feature shell library example
Link to this section

Content imageContent image
Nx feature-shell file structure example.

The feature-shell library is the orchestrator of the first level routes.

<>Copy
import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { RouterModule, Routes } from '@angular/router'; import { TranslocoConfigModule } from '@nx-feature-shell-variation/shared/utils-transloco-config'; const routes: Routes = [ { path: 'search', loadChildren: () => import('@nx-feature-shell-variation/booking/feature-flight-search').then( m => m.BookingFeatureFlightSearchModule ) }, { path: 'passenger', loadChildren: () => import('@nx-feature-shell-variation/booking/feature-passenger-info').then( m => m.BookingFeaturePassengerInfoModule ) }, { path: 'seatmap', loadChildren: () => import('@nx-feature-shell-variation/shared/feature-seat-listing').then( m => m.SharedFeatureSeatListingModule ) } ]; @NgModule({ imports: [ CommonModule, RouterModule.forRoot(routes), TranslocoConfigModule.forRoot() ], exports: [RouterModule] }) export class BookingFeatureShellModule {}
The Booking Feature Shell Library.

The app.module is almost the same for the Web, Mobile and Desktop applications. It only imports the feature-shell and performs platform-specific configurations.

<>Copy
import { NgModule } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; import { BookingFeatureShellModule } from '@nx-feature-shell-variation/booking/feature-shell'; import { AppComponent } from './app.component'; @NgModule({ declarations: [AppComponent], imports: [BrowserModule, BookingFeatureShellModule], bootstrap: [AppComponent] }) export class AppModule {}
Represent the AppModule of the Booking Web Application.

Manfred Steyer Shell library
Link to this section

In his book — Enterprise Angular — and in his articles Manfred Steyer states that:

shell: For an application that contains multiple domains, a shell provides the entry point for a domain

I’ll take the liberty of rephrasing Steyer’s statement to adjust it to my understanding of Domain-Driven Design.

shell: For an application domain, a shell provides the entry point for each Bounded Context.

Both definitions mean exactly the same thing. But what do they actually mean?

In DDD the domain is the scope of the business problem that is trying to be solved through Software.

In the Airlines example, the domain is the Airlines business. However, the Airlines domain contains different sub-domains, each with its own problems, language, and terminologies. The Bounded Contexts are the partial or complete representation of such sub-domains in software. A Bounded Context is a logical separation in a software system in which a ubiquitous  — general, common —  language is used. The process of identifying the boundaries of each Bounded Context in Domain-Driven Design is named Strategic Design. Booking, Check-in, and Flight tracking are some of the Bounded Contexts that could be found in the Airlines domain.

How does the shell library pattern fit into this?

In the Manfred Steyer architectures, the responsibility of the shell is to be the glue that organizes all features in a given bounded context.

Figure 2 shows us how it would work.

Content imageContent image
Figure 2. Manfred Steyer shell libraries. Made with https://creately.com

We will not discuss the whys of the decisions made for this specific example, let us focus on how the shell is used and how it could be useful.

However, it is worth mentioning that Manfred Steyer points out in his book that:

a shell only accesses features

In contrast with the Nrwl definition for feature-shell , Manfred’s shell libraries don’t orchestrate the complete application routes and features. Instead, they only arrange the routes and features of a single Bounded Context. It is the application's job to include the slices of the domain that match its needs.

This difference makes a significant impact on how applications are organized. Remember that one of the things we are aiming for when using a Monorepo is to maximize code sharing. With this approach, different applications could share big chunks of functionality through a common API.

Shell library use cases
Link to this section

Although this latest approach provides more extensive flexibility over application composition, it still needs some strong use cases to be useful.

The ideal scenario would be having several applications for the same domain where each application is composed as a combination of Bounded Contexts represented and pre-configured by their shells.

However, a discussion about the usefulness of shells with my friend Lars Gyrup Brink Nielsen made me realize that the granularity of these shells may play against us.

Because, even if in theory we may share a Bounded Context between different applications, they may not need the same features. Of course, that may mean that our Bounded Context does not have the degree of cohesion it should. Nevertheless, the pursuit of the one perfect Bounded Context size that fits all of our applications may be a rabbit hole of refactoring, sometimes even sacrificing the right Bounded Context cohesion for each independent application.

Shell library example
Link to this section

Content imageContent image
Manfred Steyer shell file structure example.

The Manfred Steyer shell libraries orchestrate the routes of their Bounded Context.

<>Copy
import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; const routes: Routes = [ { path: 'seat-listing', loadChildren: () => import('@steyer-shell-variation/booking/feature-seat-listing').then( m => m.BookingFeatureSeatListingModule ) }, { path: 'passenger-info', loadChildren: () => import('@steyer-shell-variation/booking/feature-passenger-info').then( m => m.BookingFeaturePassengerInfoModule ) } ]; @NgModule({ imports: [RouterModule.forChild(routes)] }) export class BookingShellModule {}
The Booking Shell Library.
<>Copy
import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; const routes: Routes = [ { path: 'flight-details', loadChildren: () => import( '@steyer-shell-variation/flight-tracking/feature-flight-details' ).then(m => m.FlightTrackingFeatureFlightDetailsModule) }, { path: 'flight-search', loadChildren: () => import( '@steyer-shell-variation/flight-tracking/feature-flight-search' ).then(m => m.FlightTrackingFeatureFlightSearchModule) } ]; @NgModule({ imports: [RouterModule.forChild(routes)] }) export class FlightTrackingShellModule {}
The Flight Tracking Shell Library.
<>Copy
import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; const routes: Routes = [ { path: 'check-in-info', loadChildren: () => import('@steyer-shell-variation/check-in/feature-check-in-info').then( m => m.CheckInFeatureCheckInInfoModule ) }, { path: 'ticket-finder', loadChildren: () => import('@steyer-shell-variation/check-in/feature-ticket-finder').then( m => m.CheckInFeatureTicketFinderModule ) } ]; @NgModule({ imports: [RouterModule.forChild(routes)] }) export class CheckInShellModule {}
The Check-in Shell Library.

Each application brings the entire functionality of the desired Bounded Contexts by configuring their routes through the shell libraries.

<>Copy
import { AppComponent } from './app.component'; import { environment } from '../environments/environment'; const routes: Routes = [ { path: 'booking', loadChildren: () => import('@steyer-shell-variation/booking/shell').then( m => m.BookingShellModule ) }, { path: 'check-in', loadChildren: () => import('@steyer-shell-variation/check-in/shell').then( m => m.CheckInShellModule ) } ]; @NgModule({ declarations: [AppComponent], imports: [ BrowserModule, RouterModule.forRoot(routes), HttpClientModule, TranslocoConfigModule.forRoot(environment.production) ], bootstrap: [AppComponent] }) export class AppModule {}
The Airline Admin web Application.
<>Copy
import { HttpClientModule } from '@angular/common/http'; import { NgModule } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; import { RouterModule, Routes } from '@angular/router'; import { TranslocoConfigModule } from '@steyer-shell-variation/shared/utils-transloco-config'; import { environment } from '../environments/environment'; import { AppComponent } from './app.component'; const routes: Routes = [ { path: 'flight-tracking', loadChildren: () => import('@steyer-shell-variation/flight-tracking/shell').then( m => m.FlightTrackingShellModule ) }, { path: 'check-in', loadChildren: () => import('@steyer-shell-variation/check-in/shell').then( m => m.CheckInShellModule ) } ]; @NgModule({ declarations: [AppComponent], imports: [ BrowserModule, RouterModule.forRoot(routes), HttpClientModule, TranslocoConfigModule.forRoot(environment.production) ], bootstrap: [AppComponent] }) export class AppModule {}
The Airline Client web Application.

With shell libraries, initialization and configuration is done in the applications, as each application can have several shell libraries.

Composite Shell libraries
Link to this section

So far we have seen two different shell library patterns, each with its own use cases, strengths, and limitations. However, we don’t have to settle. It is our job as professionals to find better ways to solve problems and to adjust the tools to our needs.

As a result of the aforementioned conversations with Lars Gyrup Brink Nielsen, we came up with an alternative shell library pattern. The strategy is to have one shell library per application using our Bounded Context as seen in Figure 3. In this way, we ensure that every application is getting the exact features that it needs and our Bounded Context size matches the right level of cohesion for the business logic. Now, the feature libraries are the ones being shared over the Bounded Context, maximizing the code-sharing without losing cohesion.

Content imageContent image
Figure 3. Booking and check-in Composite shell libraries. Made with https://creately.com

Composite shell library use cases
Link to this section

The Composite Shell library is more flexible than the others, therefore the number of use cases depends mostly on our application needs.

However, for us its prime use case would be having two or more applications that would consume a subset of features per Bounded Context. One common example would be having a full-featured Web/Desktop Application and a second scaled-down Mobile Application with limited functionality. Each application would have its own shell libraries providing routing and configuration for the slice of the Bounded Context that it needs.

Another useful scenario would be managing applications with different responsibilities over the same domain. For example, we could have an admin application responsible for entering all the data for a given system and a user-end application responsible for interacting with the user and for showing the managed data in the admin-app. A good way to use our alternative shell library could be having an admin-shell with access to some end user feature libraries, allowing the preview of the managed values as the end user would see them.

Composite shell library example

Content imageContent image
Composite shell file structure example.

A Composite Shell library selects precisely the features from our Bounded Contexts that our application needs. It is a customized set of functionalities wrapped by a shell library that orchestrates a Bounded Context subset of functionality.

<>Copy
import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; const routes: Routes = [ { path: 'passenger-info', loadChildren: () => import('@composite-shell-variation/booking/feature-passenger-info').then( m => m.BookingFeaturePassengerInfoModule ) }, { path: 'passenger-info', loadChildren: () => import('@composite-shell-variation/booking/feature-seat-listing').then( m => m.BookingFeatureSeatListingModule ) } ]; @NgModule({ imports: [RouterModule.forChild(routes)] }) export class BookingShellWebModule {}
The Booking Shell (web) Library.
<>Copy
import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; const routes: Routes = [ { path: 'check-in', loadChildren: () => import('@composite-shell-variation/check-in/feature-check-in').then( m => m.CheckInFeatureCheckInModule ) } ]; @NgModule({ imports: [RouterModule.forChild(routes)] }) export class CheckInShellMobileModule {}
The Check-in Shell (mobile) Library.
<>Copy
import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; const routes: Routes = [ { path: 'check-in', loadChildren: () => import('@composite-shell-variation/check-in/feature-check-in').then( m => m.CheckInFeatureCheckInModule ) }, { path: 'ticket-finder', loadChildren: () => import('@composite-shell-variation/check-in/feature-ticket-finder').then( m => m.CheckInFeatureTicketFinderModule ) } ]; @NgModule({ imports: [RouterModule.forChild(routes)] }) export class CheckInShellWebModule {}
The Check-in Shell (web) Library.

Each application brings the customized functionality of the desired Bounded Contexts by configuring their routes through the shell libraries.

<>Copy
import { NgModule } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; import { RouterModule, Routes } from '@angular/router'; import { TranslocoConfigModule } from '@composite-shell-variation/shared/utils-transloco-config'; import { environment } from '../environments/environment'; import { AppComponent } from './app.component'; const routes: Routes = [ { path: 'booking', loadChildren: () => import('@composite-shell-variation/booking/shell-mobile').then( m => m.BookingShellMobileModule ) }, { path: 'check-in', loadChildren: () => import('@composite-shell-variation/check-in/shell-mobile').then( m => m.CheckInShellMobileModule ) } ]; @NgModule({ declarations: [AppComponent], imports: [ BrowserModule, RouterModule.forRoot(routes), TranslocoConfigModule.forRoot(environment.production) ], bootstrap: [AppComponent] }) export class AppModule {}
The Airline Mobile Application.
<>Copy
import { HttpClientModule } from '@angular/common/http'; import { NgModule } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; import { RouterModule, Routes } from '@angular/router'; import { TranslocoConfigModule } from '@composite-shell-variation/shared/utils-transloco-config'; import { environment } from '../environments/environment'; import { AppComponent } from './app.component'; const routes: Routes = [ { path: 'booking', loadChildren: () => import('@composite-shell-variation/booking/shell-web').then( m => m.BookingShellWebModule ) }, { path: 'check-in', loadChildren: () => import('@composite-shell-variation/check-in/shell-web').then( m => m.CheckInShellWebModule ) } ]; @NgModule({ declarations: [AppComponent], imports: [ BrowserModule, RouterModule.forRoot(routes), HttpClientModule, TranslocoConfigModule.forRoot(environment.production) ], bootstrap: [AppComponent] }) export class AppModule {}
The Airline Web Application.

Extra - Shell for Microfrontends
Link to this section

In his book, Manfred Steyer mentions shell in another context: as the orchestrator of several micro-frontend applications. Here, each micro-frontend application implements each of our Bounded Contexts instead of having them isolated into libraries.

This technique has similarities with what we have described, but it does not represent a library, therefore no further analysis will be made. You can find more about this technique in Manfred Steyer’s free e-book.

Acknowledgements
Link to this section

This article wouldn’t exist without the invaluable help of my friend Lars Gyrup Brink Nielsen. The original inspiration came from our conversations. But without his encouragement, support and mentoring, this article would probably never have been written.

Thanks to Alexander Poshtaruk for his joyful review and useful suggestions.

Thanks to Max Koretskyi for all his support to the Angular inDepth community of writers and for bringing inspiration to us all.

Thanks to Manfred Steyer for his review and for bringing architecture enlightenment to the community.

Conclusion
Link to this section

Modern software architectures are showing an increased tendency to split the organization’s codebase into high cohesion libraries aiming to enhance code sharing and maintenance.

Techniques vary from using individual repositories with deployable libraries to having massive Monorepos with applications and libraries living together. Front-end projects aren’t excluded and we have seen it applied using patterns and techniques like Domain-Driven Design and Clean Architecture that years ago we thought was only possible in the Server-side.

In this article, we have discussed in the differences, usages, and limitations of the Manfred Steyer and Nrwl/Nx variations of the shell library pattern.

While analyzing the different use case combinations for those two variations, we were able to design the Composite Shell library. This shell pattern presents a new way to compose our applications with a higher focus on flexibility through composition.

Build upon this and adjust it to your own needs. Combine, rename, and reassemble the analyzed shell library patterns or invent your own. They only have the value they bring to your team and to your project.

References
Link to this section

Comments (0)

Be the first to leave a comment

Share

About the author

author_image

inDepth.dev writer, front-end architect, meetup speaker, scuba diving lover. Passionate about Software architecture and sharing knowledge.

author_image

About the author

Nacho Vazquez

inDepth.dev writer, front-end architect, meetup speaker, scuba diving lover. Passionate about Software architecture and sharing knowledge.

About the author

author_image

inDepth.dev writer, front-end architect, meetup speaker, scuba diving lover. Passionate about Software architecture and sharing knowledge.

Featured articles