Organize Libraries
Now that you've decided to divide your apps into libraries, you might have a few questions in mind:
- How should we organize these libraries?
- How should we categorize them?
- How should we name them?
- How granular should they be?
While there is no one-size-fits-all answer to these questions, here is some guidance to help you make informed decisions.
Library Categorization
You are free to organize your libraries in any way that makes sense to you, your team, and your organization. However, it is essential to define some rules to avoid ending up with a mess of libraries that are hard to understand and maintain. In addition, this will allow us to leverage certain Nx features that will enforce these rules and assist everyone on the team in following them.
Tags and Categories
Nx allows us to tag our applications and libraries with custom tags.
These tags can be provided when the application or library is generated using the --tags
option:
nx g lib my-lib --tags=my-category
or afterwards by updating the tags
property in the project.json
file:
{
"name": "my-lib",
"tags": ["my-category"],
...
}
Tags can be used to categorize libraries based on different criteria. They are useful for:
- enforcing boundaries and architectural rules,
- or simply running tasks on specific categories of apps and libraries (e.g.
nx run-many -t test --projects=tag:my-category
).
These tags can be used to categorize libraries on different dimensions.
Type Categories Dimension
The first dimension of categories that can help you organize your libraries across various architectural styles is the type
dimension.
This group is commonly used to define horizontal layers, or in other words, to segregate the technical responsibility of the library (e.g., container components vs. presentational components for the frontend, or controllers vs. repositories for the backend). Some common type categories are feature
, ui
, data-access
, or infra
. These are just examples that will be elaborated on below.
The naming convention in the Nx community is to prefix the tags with type:
(e.g. type:ui
).
Scope Categories Dimension
The second most common dimension of categories that can help you organize your libraries is the scope
dimension.
It represents the vertical slices of the workspace. Essentially, it segregates the functional responsibilities of applications and libraries.
For example, given a recipe catalog application, you could have the following scopes: auth
, catalog
, and cart
.
If you are familiar with the Bounded Context of Domain-Driven Design (cf. https://martinfowler.com/bliki/BoundedContext.html or https://www.amazon.com/Domain-Driven-Design-Tackling-Complexity-Software/dp/0321125215), you can view the scope
dimension as a tactical approach to defining the boundaries of a bounded context.
If you're unfamiliar with this concept, consider it as a way to define the scope of a specific set of functional responsibilities. It establishes the boundaries within which certain concepts, terms, and business rules apply, allowing you to focus on the task at hand without distractions from other parts of the workspace. More precisely, a bounded context defines the boundaries where a particular model is applicable.
Other Dimensions
In larger workspaces, you might want to define additional categories to help you organize your applications and libraries.
For instance, the platform
dimension is commonly used to define the platform on which the application or library is intended to run: mobile
, server
, web
, crossplatform
, etc...
In even larger workspaces and organizations, you might want to define the department
or team
dimension to identify the department or team that owns the application or library: sales
, marketing
, finance
, etc.
Library Type Categorization Examples
As mentioned above, you are free to organize your libraries in any way that makes sense to you. However, determining or agreeing on the categories to use, especially for the type dimension, can be challenging. To help you get started, here are some examples:
Light Layered Architecture
For the simplest workspaces and for small teams that can properly apply separation of concerns without enforcing any boundaries, you might want to consider a simple layered architecture like this one. However, remember that merging libraries is often easier than splitting them.
Type | Description | Content |
---|---|---|
app | An application. | Frontend: ✅ App configuration. Backend: ✅ App configuration. ✅ Controllers. ✅ Serverless handlers. |
feature | Feature-specific logic. | Frontend: ✅ Page components. ✅ Container components. ✅ Facades. ✅ Services. ✅ State management, stores, and effects. 🛑 Almost no styling except for some layout. Backend: ✅ Use cases. ✅ Services. |
ui | Abstraction layer of the UI. | Frontend: ✅ Presentational (a.k.a. dumb) components. ✅ UI services (e.g. Dialog). Backend: - |
infra | Abstraction layer of infrastructure concerns. | Frontend: ✅ Repositories or remote service adapters (e.g. HTTP, or GraphQL clients). ✅ Non-UI browser API adapters (e.g. Speech Recognition) Backend: ✅ Remote service adapters (e.g. HTTP, or GraphQL clients). ✅ Repositories or database adapters. |
Pros and Cons
- ✅ This architecture style is straightforward and easy to understand.
- ❌ Without caution, upper layers tend to get contaminated with infrastructure concerns. (e.g. Remote service types (DTOs) can be propagated to the
feature
layer.)
With this architecture, you will quickly notice the need for a model
layer to share the domain model between the feature
, infra
, and ui
layers.
Hexagonal-Inspired Architecture
For the most complex workspaces or for large teams who want to enforce strict separation of concerns, you might want to consider a hexagonal-inspired architecture similar to this one.
Type | Description | Content |
---|---|---|
app | An application. | Frontend: ✅ App configuration. Backend: ✅ App configuration. ✅ Controllers. ✅ Serverless handlers. |
feature | Feature-specific logic. | Frontend: ✅ Page components. ✅ Container components. 🛑 Almost no styling except for some layout. Backend: ✅ Use cases or feature-specific services. |
domain | Reusable business logic. | Frontend: ✅ Facades. ✅ Reusable services. ✅ State management, stores, and effects. ✅ Ports injection tokens. Backend: ✅ Reusable services. ✅ Ports injection tokens. |
model | The model applicable inside a given bounded-context (cf. Scope Dimension). | Frontend: ✅ Entities generally formed by the combination of interfaces/types/enums/functions. 🛑 Almost no external dependencies. 🛑 Framework-agnostic code only. Backend: ✅ Entities: classes or interfaces & functions. 🛑 Almost no external dependencies. 🛑 Framework-agnostic code only. |
ports | Infrastructure abstraction. | ✅ Ports: interfaces abstracting the infrastructure. 🛑 Almost no external dependencies. 🛑 Framework-agnostic code only. |
ui | Abstraction layer of the UI. | Frontend: ✅ Presentational (a.k.a. dumb) components. ✅ UI services (e.g. Dialog). Backend: - |
infra | Infrastructure implementation. | Frontend: ✅ Adapters implementing ports. ✅ Repositories or remote service adapters (e.g. HTTP, or GraphQL clients). ✅ Non-UI browser API adapters (e.g. Speech Recognition). Backend: ✅ Adapters implementing ports. ✅ Remote service adapters (e.g. HTTP, or GraphQL clients). ✅ Repositories or database adapters. |
Pros and Cons
- ✅ This architecture style enforces strict separation of concerns.
- ✅ It prevents the contamination of the application's core with infrastructure concerns. (e.g. changes to remote services have less impact and require less refactoring/restructuring.)
- ✅ It can simplify testing.
- ✅ It can speed up some tests by not loading the
infra
category and its dependencies, and also thanks to Nx graph, Nx will not rerundomain
,feature
orcore
tests wheninfra
category is the only one that changed. - ❌ It is more complex and might be overkill for small teams or simple applications.
- ❌ Dependency inversion can defeat the purpose of tree-shakability (i.e., services must be provided when the app or feature is loaded). This can lead to larger bundles, potentially harming performance for both frontend and backend applications. Indeed, providing unnecessary infrastructure services in the backend can result in slower deployment and more significant cold starts.
- ❌ The dependency inversion requires more boilerplate in this case. (i.e. injection token + interface + implementation.)
ports
can be merged with model
if you want to keep the number of libraries to a minimum.
Note that injection tokens are in the domain
category to prevent implementations in infra
from injecting other ports by mistake.
This also applies to ui
if ports
and model
are merged: if the tokens are in model
, ui
could inject infrastructure services.
Modular Layered Architecture
The example below is a layered architecture with fine-grained horizontal slices that emphasize both separation of concerns and simplicity. It is a balance between the two previous examples.
Type | Description | Content |
---|---|---|
app | An application. | Frontend: ✅ App configuration. Backend: ✅ App configuration. ✅ Controllers. ✅ Serverless handlers. |
feature | Feature-specific logic. | Frontend: ✅ Page components. ✅ Container components. 🛑 Almost no styling except for some layout. Backend: ✅ Use cases or feature-specific services. |
domain | Reusable business logic. | Frontend: ✅ Facades. ✅ Reusable services. ✅ State management, stores, and effects. Backend: ✅ Reusable services. |
model | The model applicable inside a given bounded-context (cf. Scope Dimension). | Frontend: ✅ Entities generally formed by the combination of interfaces/types/enums/functions. 🛑 Almost no external dependencies. 🛑 Framework-agnostic code only. Backend: ✅ Entities: classes or interfaces & functions. 🛑 Almost no external dependencies. 🛑 Framework-agnostic code only. |
ui | Abstraction layer of the UI. | Frontend: ✅ Presentational (a.k.a. dumb) components. ✅ UI services (e.g. Dialog). Backend: - |
infra | Infrastructure implementation. | Frontend: ✅ Adapters implementing ports. ✅ Repositories or remote service adapters (e.g. HTTP, or GraphQL clients). ✅ Non-UI browser API adapters (e.g. Speech Recognition). Backend: ✅ Adapters implementing ports. ✅ Remote service adapters (e.g. HTTP, or GraphQL clients). ✅ Repositories or database adapters. |
Pros and Cons
- ✅ This architecture style plays well with tree-shakability. Infrastructure services do not have to be provided explicitly, they can be implicitly provided when used. (e.g. Angular's
providedIn: 'root'
, or React's context's default value.) - ❌ It can't easily enforce that upper layers are not contaminated by infrastructure concerns. (i.e. Remote service types (DTOs) can be propagated to the
domain
orfeature
layer.)
By implementing infrastructure service interfaces in the model
layer, you will reduce the risk of contaminating the domain
and feature
layers by infrastructure concerns without adding too much complexity and without losing tree-shakability.
In other words, as model
is not allowed to import types from infra
so the interfaces it defines will be infrastructure-agnostic.
Choosing the Right Granularity
Deciding on the right granularity for your libraries is crucial: if they're too big, you lose some of the benefits of splitting your apps into libraries; if they're too granular, you might introduce unnecessary complexity.
Too Big
Creating excessively large libraries could diminish some of the benefits of splitting apps into libraries:
- It could result in a monolithic library that is challenging to maintain and understand.
- It might not fully leverage Nx's caching and parallelization capabilities.
- Progressive migration could become more difficult (e.g., transitioning from Jest to Vitest, or changing lint or build options).
Too Granular
On the other hand, creating excessively granular libraries might introduce unnecessary complexity:
- It could increase the cognitive load for developers who might struggle to understand the purpose of each library.
- It will require more boilerplate as each new symbol must be re-exported by the library's public API (i.e.,
index.ts
) before being used in other apps and libraries. - It might defeat the purpose of parallelization by over-parallelizing.
- Without enforcing the boundaries, it could lead to highly coupled libraries or libraries that export implementation details.
The Right Size
Before deciding on the granularity of your libraries, here are some important factors to consider:
-
Workspace Ambitions: What are the goals of your workspace? Is it a small isolated application that is not meant to last, or is it a long-term product that will evolve over time? Are you planning to merge other repositories into this workspace? For instance, in the extreme case where one or two developers are building a small isolated application that is not meant to last, you might not need to split it into libs.
-
Team's Experience: How experienced is your team with the technologies and architectural styles you are using? Paradoxically, if the separation of concerns is not natural to your team, creating many libraries with clear and enforced boundaries will help them understand and apply the architecture better.
While you can always start with relatively large libraries and gradually split them into smaller ones, note that it is generally easier to merge libraries than to split them. 😉
Defining your Architecture
Before choosing an architectural style and the corresponding categories, consider the following:
- Make sure to involve your team in the decision-making process.
- Avoid dogmatism: the best architecture is the one that fits your team, your workspace, and your organization.
- You are free to mix and match, but make sure that you have a clear understanding of the trade-offs (see the pros and cons of each example above).
- Listen to the signals:
- If you notice that some libraries are growing too large, consider splitting them into smaller ones.
- If you notice the proliferation of passthrough libraries (a.k.a. Sinkhole Anti-Pattern), then you might want to simplify your architecture.
- If your team struggles to understand the architecture, then you might want to simplify it.
- If your team struggles to maintain a clear separation of concerns, then you might want to try a more restrictive architecture.
In order to avoid what is often referred to as the "Sinkhole Anti-Pattern", note that you do not have to always implement all the type categories.
For instance, a really simple application without much business logic might only need the app
, ui
, and infra
categories.
Also, in the same workspace, some scope slices might need less layers than others.
File Structure
You are free to organize the file structure as you see fit. However, the journey will be much smoother if anyone on the team can identify the categories of each library without having to check its tags.
For instance, making the scope, type, and eventually the platform categories appear in the library path is a common practice (e.g. libs/{scope}/{type}
and libs/{scope}/{name}-{type}
):
└── libs
├── cart
│ ├── feature
│ └── infra
└── catalog
├── infra
├── model
├── search-feature
├── search-ui
└── special-offers-feature
Note that, in contrast to a flatter approach (e.g., libs/{scope}-{type}
and libs/{scope}-{name}-{type}
), nesting allows the Nx graph to group libraries that are in the same folder.
You can always move the libraries around later using the move
generator:
nx g @nx/workspace:move --projectName <name> --destination <new-path>
Your new friend, Nx, will take care of everything for you.