Nx - Module Boundary Strategy for Full Stack TypeScript Repositories
Do you love circular dependencies and a disorganized code base? Of course not, we want a great developer experience in our monorepositories that organize, enforce architecture, and prevent runtime errors.
Nx ships with a powerful feature to help developers navigate these large code bases, module boundaries. Module boundaries exist to separate projects and certain npm packages from being consumed through lint rules and project tagging.
So what should be considered when creating a module boundary strategy and what does Social Sprinkler's look like?
Before You Begin
It is assumed you have an understanding of Nx workspaces and tooling.
Goals
The Social Sprinkler web application exists in the workspace as a full stack NestJs and Angular app. The overarching goals is to describe the different project types that exist.
- Determine the Tech Stack and Library Types
- Define the Tags All Projects Will Inherit
Determine the Tech Stack and Project Types
Social Sprinkler is a full stack application built on top of several Google Cloud services and connects to multiple social networks. Angular and NestJs frameworks were chosen to ease the ability to share data transfer objects and utility functions between the frameworks. The storage tier consists of a MySql database for structured data and Google Cloud Storage for unstructured data. Finally, TailwindCss is used for the design system of the UI.
Knowing the tech stack now, what types of projects should exist to support the stack? How should I best segment the responsibilities of certain modules?
Segmenting by Domain
The domains are segmented from a technical / design system standpoint then business logic. Many implementations I’ve seen use a shared
directory that any of the business domains can use. These are typically tagged with the format domain:<DOMAIN_NAME>
.
The apps
folder includes organization apps into separate folders. Social Sprinkler run seven different services each of those services belong to a domain of Social Sprinkler.
Shared
exists as the foundation on which apps are built. Shared has it's own set of subdomains logically separated. For instance, a data-access
domain contains further subdomains for unstructured
and structured
data. The Tailwind
domain contains design system components and directives
As developers compose their features, the shared
domain supports Social Sprinkler's composition of Angular components and NestJs services. Because Social Sprinkler is a consumer of shared, a boundary is configured to only allow domain:shared
to consume domain:shared
. Social Sprinkler domains can consume anything within its own domain or shared.
What boundaries exist in the Social Sprinkler Domain?
Drilling down further into Social Sprinkler a ‘core‘ subdomain supplies all other domain capabilities needed. A UserContextService lives in the ‘core’ domain to read JWT authorization claims.
The Media domain contains all the business logic for media creation, storage, and retrieval.
The User Management domain provides user functionality such as creation, forgot password, and organizational team assignments. Because users and organizations have image icons, it is a consumer of Media.
The Social domain consumes both media and user management. The social media channels use features and services of media and user management. Images and videos attached to a social media post are read from the media domain.
Publisher uses media, user management, and social domains. It's responsibility aggregates everything Social Sprinkler is doing for publishing posts to a social media channel.
Knowing the lay of the domains, what do these boundaries look like graphically?
Framework Boundaries
While the repository is Typescript based to promote sharing code easily, I’ve witnessed bugs as Angular code leaks into NestJs code. The most problematic case was the inability for a Docker Container to start.
How do I prevent this in Social Sprinkler?
Each project includes a framework
tag in the format of framework:<FRAMEWORK>
. When a domain contains highly coupled full-stack code in it, the projects live close by to one another in a consistent directory structure.
Three framework types exist. any
, ng
, and nest
.
The any
tag consists of code that can be used in both the ng
and nest
applications. A good example of this is data transfer objects. No framework specific code exists in these projects. Within the aspect of a domain, a common
directory contains framework:any
tagged projects.
Angular specific projects include tag the framework:ng
tag. Angular projects within a domain have a ui
directory to contain its projects.
The api
directory contains NestJs projects tagged with the framework:nest
.
How are the folders organized under the domain?
Under the social
directory, an api
, ui
, common
, and analytics
. The analytics
folder is a subdomain of social containing the ui, api, and common directories.
Define the Library Types
So far boundaries centered around the tech stack and domains. The type
tag relates to the framework. These boundaries help avoid circular dependencies and enforce a hierarchy. The type
tag will be highly dependent on your chosen architecture.
The type tag used the type:<LIBRARY_TYPE>
format.
Common Boundaries - Directory common
The common directory consists of models, data transfer objects, simple validations, interfaces and utilities. These files exported out common can be consumed by framework specific platforms. The types should refrain from using an platform specific types or APIs.
Two libraries exist in here, types
and utilities
. types
may only consume other types
libraries. utilities
may consume types
and other utility
libraries.
Directory: types
, Tag: type:types
The types
libraries contain interfaces, classes, and types shared within the domain or with other domains higher in the domain hierarchy. The types also do not belong to a specific platform allowing them to be used in Angular or NestJs.
Directory: utilities
, Tag: type:utilities
The utilities
libraries also do not belong to a specific platform. They help give parity between the backend and frontend code. For instance, the media domain in Social Sprinkler formats the static assets URL. The utility needs to be used in both the browser and server.
Angular Boundaries - Directory ui
Framework specific libraries depend more on the chosen architecture. Social Sprinkler uses a hierarchical approach.
Directory: <DESIGN_SYSTEM_NAME>
, Tag: design
The design
project contains directives and components for composition of larger smart components. They stand as building blocks. The components are akin to atom or molecules for Atomic Design. For instance, my repository has a Tailwind set of components and another library contains SVG icon components.
Directory: data-access
, Tag: data-access
The data-access
projects contains services for backend communication and state management. If using NgRx, the effects, actions, selectors, reducers belong here. This goes along with other patterns such as service with a subject.
Directory feature-<FEATURE_NAME>
, Tag: feature
feature
projects tie design
and data-access
libraries together. Generally, components responsible for reading and mutating organize into features. They are meant to allow the user to accomplish a particular task. The templates are a composition of the design
project components, and are driven by services from the data-access
projects.
Others in the Angular community include routing into feature libraries. Including routing can complicate reusing certain features in different parts of the application. Social Sprinkler contains a media-feature
which contains components for handling image / video uploads and display.
Directory experience-<EXPERIENCE_NAME>
, Tag: experience
experience
projects can consume design
, feature
, data-access
, and other experience
projects. Routing configuration, template components, and page components exist in this project.
The primary goal of experience
projects is configuring a workflow based on routes. Social Sprinkler's domain user-management-social
ties a customer's organization together with different social media experience
projects.
Directory <APP_NAME>
, Tag: app
app
composes all the experience
projects into a deployable / shippable unit. It sits a the top of the hierarchy.
NestJs Boundaries - Directory api
Using Onion Architecture, the api directory breaks down into domain, data-access, services, and application. The inner layers know nothing about the outer.
The domain
library focuses on entities specific to the database, service contracts, and configuration.
This is the core layer of the api platform. This layer knows of only the types
and utilities
projects.
Directory data-access
, tag: data-access
The data-access library communicates with data storage mechanisms. This could be MySql, GCP’s Storage buckets, or reading writing to disk.
Most of the services with the data-access follow a repository pattern which are responsible for CRUD operations in a MySQL DB.
Directory services
, tag: services
Services contain logic for the business domain. It consumes other services, data-access repositories, domain, and common libraries.
Directory: application
, tag: api
The api project contains controllers and the bundled module that will roll up into a deployable app. It consumes the NestJs services and domain projects.
Directory: <APP_NAME>
, tag: app
app
composes one or multiple application
libraries together for a deployable unit of code.
Summary
Module boundaries enforce Social Sprinkler’s architecture.
Splitting technical / design projects in a shared domain helps reuse code for future projects. Both BEO and Social Sprinkler use common design components allowing for easier updates and smart component composition. This is where domain boundaries help.
Full stack workspaces using the same language as TypeScript could allow server side and browser code to mix. Runtime errors occur when the execution context doesn’t support the global apis. But we do want to share certain models and utilities. So I enforce this with framework boundaries.
Finally, Social Sprinkler’s architecture contains many different project types for the supported frameworks. Tagging the projects with a particular type tag can help determine types of files generated and which project they live. This will be dependent on the architecture / folder organization you want to enforce.