Building at Scale: How Trellis Structures Our Nx Monorepo
At Trellis, we've been running a large-scale Nx monorepo since version 7, and it's become the backbone of how we build and deploy our fundraising platform. With 14 applications and over 1,200 libraries, our monorepo contains:
- 1.6 million lines of TypeScript
- 35,000 lines of HTML (not including Angular single file components)
- 15,000 lines of GraphQL files for codegen purposes
- 4,400 lines of Prisma definitions for our PostgreSQL databases
- 11,800 lines of SCSS
In this blog post, we will walk through how we've structured our workspace, the custom tooling we've built, and the architectural decisions that have allowed us to scale our development team and product scope while maintaining speed and code quality.
The Challenge: Managing Complexity at Scale
When we first started building our platform, we had a simple Angular app and a NestJS API. Fast-forward to today, and we're managing:
- 14 Applications: Including donor-facing web apps, internal admin tools, API servers, and specialized services
- 1,200+ Libraries: Shared components, utilities, data access layers, and domain-specific modules
- Multiple Platforms: Web (Angular), Server (NestJS), and shared TypeScript utilities
- Dependencies across systems: GraphQL schemas, Prisma databases, AWS services, and third-party integrations
The traditional approach of separate repositories would have been a nightmare to maintain. Nx has allowed us to keep everything in one place while providing the tooling to manage complexity.
Our Application Architecture
Let's start with the big picture. Our applications fall into several categories:
API Servers (NestJS)
These servers range from APIs serving our "live" traffic (i.e., traffic from donors to the pages our charities create on Trellis) to hyper-specific servers like PDF generators.
The majority of our processing happens on three servers: The donor primary server, the charity primary server, and the PGBoss job/queue processing server.
In addition to the above, we use Nx to wrap k6 and Firebase Emulator for local development and testing and treat them as apps themselves.
Web Applications (Angular)
While we try and keep the number of web applications to a minimum, we do have a few; the major web applications mirror our server setup, one Angular app for the systems our customers (charities) use and one for our customers-customers (donors) that uses Angular Universal.
The rest of the other Angular applications range from internal tools for our team, event management tools for volunteers, and embeddable or hyper-specific web applications meant for integrating into other systems.
We like to have fun names at Trellis for our applications, some examples:
frontier
, glacier
, hogwarts
, orient
, prospector
, thomas
, spirit
,
snowpiercer
, and star
. Can you guess what our naming scheme is?
Library Organization: The Foundation of Our Architecture
Our library structure follows a clear hierarchy based on scope, type, and
platform. The main parent folders are organized by type
or scope
, with
platform
libraries nested underneath:
libs/
├── type/ # TypeScript type definitions
├── testing/ # Test utilities and mocks
├── data-access/ # Data layer abstractions
│ ├── server/ # Server-side data access
│ └── web/ # Web-side data access
├── ui/ # Shared UI components
│ ├── button/
│ ├── container/
│ └── ...
├── auction/ # Domain-specific libraries (scope is akin to a domain within an application)
│ ├── feature/
│ ├── data-access/
│ ├── shell/
│ └── ...
└── <app-name>/ # App-specific libraries
├── feature/ # Smart components with business logic
├── ui/ # Dumb components
├── data-access/ # App-specific data services
└── shell/ # App shells and routing
This structure gives us several benefits:
- Clarity: It is clear where to find libraries and what they contain/can be used for
- Dependency Management: We can enforce architectural boundaries
- Code Reuse: Shared libraries prevent duplication across apps
- Testing Strategy: Each library can be tested in isolation
Module Boundaries: Enforcing Architecture Through Code
One of Nx's most powerful features is the ability to enforce module boundaries through linting rules. We've spent time understanding what we needed from a tagging system through years of managing our monorepo, adding module boundaries after the fact rather than starting with them. While we don't yet enforce these module boundaries as ESLint errors (they're currently warnings), we're working toward that goal.
Our Tagging System
Every library in our workspace gets tagged with multiple dimensions. Here are some real examples from our codebase:
Web UI component (libs/ui/button/project.json
)
{
"tags": ["platform:web", "scope:shared", "type:ui"]
}
Server data access layer (libs/data-access/server/prisma-core/project.json
)
{
"tags": ["layer:db", "platform:server", "scope:shared", "type:data-access"]
}
Web data access with GraphQL
(libs/data-access/web/volunteer-fundraiser-feature-flags/project.json
)
{
"tags": ["platform:web", "scope:shared", "type:data-access"]
}
Let's break down each tag type:
Platform Tags
platform:web
: Browser-only code (Angular components, DOM APIs)platform:server
: Node.js-only code (NestJS, file system, databases)platform:any
: Universal code that works everywhere
Type Tags
type:app
: Applications (can depend on anything)type:feature
: Smart components with business logictype:ui
: Dumb components with no business logictype:data-access
: Data layer and API callstype:utility
: Pure functions and helperstype:type
: TypeScript type definitions onlytype:testing
: Test utilities and mocks
Scope Tags
scope:shared
: Available to all applicationsscope:auction
: Auction-specific functionalityscope:checkout
: Checkout flow componentsscope:dashboard
: Admin dashboard features- etc., for each of our domains/scopes
Layer Tags (Backend Only)
Note: Only backend libraries use layer
tags, web platform libraries don't use
this dimension.
layer:public
: Can be imported by applications—typically endpoints/controllers (public entry points to the application)layer:graphql
: GraphQL resolvers and schemaslayer:db
: Any library specifically designed for interacting with a data store of some sort (PostgreSQL, Valkey, etc.)layer:external
: Third-party service integrations
Enforcing Boundaries
Our module-boundaries.config.mjs
file contains many lines of dependency rules.
Here are some real examples:
export const moduleBoundariesConfig = {
depConstraints: [
// Apps can depend on anything
{
sourceTag: 'type:app',
onlyDependOnLibsWithTags: ['type:*'],
},
// UI components can't import business logic
{
sourceTag: 'type:ui',
onlyDependOnLibsWithTags: ['type:ui', 'type:utility', 'type:type'],
},
// Web code can't import server dependencies
{
sourceTag: 'platform:web',
onlyDependOnLibsWithTags: ['platform:web', 'platform:any'],
bannedExternalImports: ['@nestjs/*', '@aws-sdk/*', '@prisma/*'],
},
// Domain boundaries are enforced
{
sourceTag: 'scope:auction',
onlyDependOnLibsWithTags: [
'scope:shared',
'scope:auction',
'scope:testing',
],
},
],
};
This system, while not fully enforced yet, has been invaluable for:
- Preventing Circular Dependencies: The type hierarchy ensures a clean dependency flow
- Platform Separation: Web code can't accidentally import Node.js modules
- Domain Isolation: For example, auction code can't depend on checkout code
- Performance: Smaller bundle sizes by preventing unnecessary imports
It's worth noting that we're still working on enabling these module boundary lint rules as errors instead of warnings—this is an ongoing effort as we continue to refine our architecture.
Custom Nx Plugins: Solving Domain-Specific Problems
While Nx provides excellent out-of-the-box functionality,
we've built several custom plugins to handle our specific needs. These live in
libs/plugins/
and include:
Apollo GraphQL Plugin (@trellis/plugins/apollo-graphql
)
GraphQL is central to our architecture, and we needed
flexible code generation that works with Nx (specifically remote caching) and
our repository structure. Our custom plugin provides executors that we configure
in individual library project.json
files:
Example from
libs/data-access/web/volunteer-fundraiser-feature-flags/project.json
{
"targets": {
"graphql-codegen": {
"executor": "@trellis/plugins/apollo-graphql:library-codegen",
"inputs": ["bulletGqlSchema", "codegen", "sharedGlobals"],
"options": {
"enumStyle": "const",
"generates": ["angular", "operations", "types"],
"schema": "bullet"
}
}
}
}
This executor:
- Generates TypeScript types from GraphQL schemas
- Creates Angular services with type-safe operations
- Handles multiple schemas (
aurora
,bullet
) in one workspace - Integrates with our build pipeline to codegen only the libraries needed
Repository Plugin (@trellis/plugins/repository
)
Our repository plugin provides generators for common tasks that can be configured at the root level or in individual apps/libs:
- create-library: Scaffolds new libraries with proper tags and structure
- add-storybook: Adds Storybook configuration to component libraries
- create-queue: Generates PGBoss queue handlers with proper typing
- sort-project-json: Keeps project configurations consistently formatted
- Among other things like generating dev statistics, reports, release notes, and more
These plugins help ensure consistency across our large codebase by automating common setup tasks.
Other Custom Plugins
- Prisma Plugin: Handles database schema generation and migrations
- Docker Plugin: Builds and tags container images for deployment (although recently Nx just launched their own Docker support)
- AWS Plugin: Manages deployments to [Lambda](https://aws.amazon.com/lambda/, S3, CloudFront, and other AWS services. Additionally, we use this plugin to communicate with FluxCD running in our EKS clusters
- Dotenv Plugin: Handles environment variable
management across environments and applications, this allows us to do
nx vault-(pull|push) aurora --environment develop
, for example. Makes it easy for us to configure very specific sets of environment variables for certain apps or processes.
CI/CD and Deployment: Automating Everything
Target Dependencies: Orchestrating Complex Builds
Our build system relies heavily on Nx's target dependencies
to ensure everything builds in the correct order. Here is an example of the task
graph for running nx compile orient
:
orient:compile
├── orient:overwrite-fb
└── orient:server
└── orient:build
├── orient:stamp-version <-- Stamps the release version, `vX.X.X` so that the build includes this in the compiled assets.
├── <deps>:generate-open-api
├── <deps>:generate-translations
├── <deps>:graphql-codegen
├── orient:graphql-codegen
├── orient:generate-ngsw-config <-- Generates the service worker configuration with additional metadata like when the version was released.
└── orient:generate-browsers-list-rc <-- Dynamically generates the `.browserslistrc` file so that we can manage the supported browsers in one place across all apps.
This graph establishes an ordered pipeline:
- Version Stamping: Ensures each build is uniquely identified
(
stamp-version
) - Code Generation: Runs GraphQL and OpenAPI generation first, so they are available when compiling the application
- Runtime Configs: Service worker and browser support configurations are generated post-build
- Build the Application: Run the Angular Compiler
(via
build
) - Single Entry Point: The
compile
target orchestrates the entire build pipeline, this allows all apps across platforms to have a singlecompile
target orchestrates that apps specific task pipeline.
Environments
We support five distinct environments, each with its own deployment logic and configuration:
local
: Fully local development using Dockerized services (Postgres, Valkey, DynamoDB)local-dev
: Local frontend running against remotedevelop
APIs for rapid iterationdevelop
: Auto-deployed from thedevelop
branch to the shared dev environmentstaging
: Tag-triggered release candidates (vX.X.X-rc.Y
) deployed to staging for QAproduction
: Tag-triggered stable releases (vX.X.X
) deployed to live customer environmentsload-testing
: Specialized tag-triggered builds (vX.X.X-lt.Y
) for performance validation
Affected-Based CI using Nx
Always-Run CI Targets (All Branches & PRs)
These targets run on every CI pipeline regardless of branch or tag:
lint
: ESLint, repository rules, spec file typecheckingtest
: Jest and Vitest test suitesgraphql-codegen
: Generates GraphQL types and operationsgenerate-open-api
: Builds typed clients for external REST APIsgenerate-translations
: Localization pipeline for i18ntypecheck
: Type safety for affected projectscompile
: Compiles SSR and client bundlesbuild
: Environment-ready application buildsfeature-e2e
: End-to-end (these are more like integration tests, and will be renamed soon) tests for feature flows (with Firebase emulator)build-storybook
: Component visualization and regression coveragegenerate-package-json
: Prunes package.json for deployment artifacts (servers only)deploy-preview
: Deploys to Firebase preview channels (PRs only)circleci-plan
: Prepares deployment metadata and plans (only on release tag runs)circleci-update
: Tracks deployment status for CI and visibility (only on release tag runs)smoke-test
: Runs k6-based validation on key user flows (as of writing, this only runs afterdevelop
deployments)
We are currently working on improving our integration tests (feature-e2e
above) to run against a local cluster compiled in the same manner as deployment
artifacts as well as running Playwright e2e tests for our
Angular apps against this same cluster during our CI
runs.
Develop-Specific CI Targets
Triggered only from the develop
branch:
deploy
: Auto-deploy to thedevelop
environmentrelease-and-move-linear-tickets-to-ready-for-testing
: Moves Linear issues to QA after deployment and pushes a release tag to GitHub.
Staging-Specific CI Targets
Triggered by vX.X.X-rc.Y
tags:
deploy
: Deploy tostaging
infrastructurerelease-only
: Finalize staging release in internal tools
Production-Specific CI Targets
Triggered by vX.X.X
tags:
deploy
: Deploy toproduction
infrastructuregenerate-changelog
: Commits a Git-based changelog, AI-generated release notes to a Slack channel, moves all resolved Linear Issues toProduction/Done
and ensure they are lablled correctly.
Load-Testing-Specific CI Targets
Triggered by vX.X.X-lt.Y
tags:
deploy
: Deploy toload-testing
infrastructurerelease-only
: Finalizes the load testing deployment metadata
Developer Experience: Making Complex Simple
With all this complexity, we've invested heavily in developer experience:
Code Quality Gates
Every commit runs a pre-commit hook that:
- Checks the formatting of all affected files (including JSON configurations, not just source files)
- Checks that production dependencies are pinned.
- Run betterer to ensure we are moving in the right direction and not regressing.
Lessons Learned and Best Practices
After years of running and incrementally improving this setup, here are our key takeaways:
What's Working
- Strict Module Boundaries: Tagging and enforcement rules have been a big win. The investment is paying off, even though we're still untangling some legacy violations and have not turned these linting checks as errors yet.
- Custom Plugins: Domain-specific tooling has been worth the effort. The maintenance overhead is minor compared to the benefits they bring us.
- Affected-Based CI: Shaving minutes off every build adds up. Our feedback loops are fast, and we're able to ship frequently.
- Consistent Structure: A clean, consistent repo layout makes it easier to onboard, debug, and collaborate. You shouldn't have to guess where code live.
What We'd Do Differently
- Enforce Boundaries from Day One: We added module boundaries after the fact. Doing it upfront would've saved a lot of time and rework.
- Invest in Documentation Earlier: Complex systems need great docs. We waited too long to prioritize this, and we're still catching up.
- Enable TypeScript strict mode from day one: Turning on all TypeScript strict flags early would have prevented subtle issues and reduced headache now that we are (almost) done migrating to full strictness in the repo.
Performance Considerations
With a workspace this large, performance matters:
- Nx Cloud: We use Nx Cloud for distributed caching and computation
- Bun: Switching from Yarn to Bun improved installation times significantly
- Selective Testing: We rarely run the full test suite locally
- Build Optimization: We've tuned our TypeScript and Angular build configs to reduce configuration duplication and just recently enabled all TS strict flags globally. We are very excited for TypeScript 7 (TypeScript Go)
The Future: Where We're Heading
We're continuously evolving our workspace:
Upcoming Improvements
- Full-Stack Integration Testing: Running our integration and e2e targets in CI against our entire cluster as it would be once deployed (i.e. build it first and run it)
- Advanced Load Testing: Running load tests using k6 and MSW with our custom MSW IPC Bridging library that allows us to control mocking in running servers from the process running our unit, integration, e2e, load and smoke tests
- Enhanced Smoke Testing: Extending our smoke testing suite (a paired-down version of our load testing suite, also using k6) to run after staging and production deploys
- Automated Rollbacks: Implementing automated rollback mechanisms for failed deployments
- Type-Safe Testing: Integrating our GraphQL code generation into our integration and e2e tests so we get the same high level of type safety during tests that we get during application builds
Scaling Challenges
As we continue to grow, we're watching:
- Build Times: Keeping CI/CD fast as we add more code
- Dependency Management: Preventing the dependency graph from becoming unwieldy
- Tool Maintenance: Balancing custom tooling with off-the-shelf solutions
- Automation: Keeping as much automated as possible so we can focus on product not process.
Conclusion
Maintaining a large monorepo is challenging, but Nx makes it significantly easier. The benefits for us have been considerable. We've been able to:
- Maintain Speed: New features ship faster with shared libraries and tooling
- Ensure Quality: Automated boundaries and testing catch issues early
- Scale the Team: New developers can contribute across the entire platform
- Reduce Duplication: Shared code means fewer bugs and easier maintenance
The key is treating your monorepo as a product itself—invest in tooling, documentation, and developer experience. The upfront cost pays dividends as your team and codebase grow.
If you're considering a similar setup, start small but think big. Begin with strict boundaries, invest in custom tooling where it makes sense, and always optimize for developer productivity.
Our setup isn't perfect, but it's evolved to meet our needs. The most important thing is that it enables our team to build great software for our customers—and at the end of the day, that's what matters most.
Some metrics from our 2024 year in review:
git diff
: 31,523 files changed, 876,005 insertions(+), 461,666 deletions(-)develop
Releases: 1,474production
Releases: 290- Linear Issues Completed: 2,811
2025 year-to-date (same team size):
git diff
: 34,870 files changed, 402,824 insertions(+), 319,192 deletions(-)develop
Releases: 1,172production
Releases: 173- Linear Issues Completed: 2,033
Want to learn more about our architecture or have questions about implementing something similar? Or maybe there is something you are curious about how we do at Trellis. Feel free to reach out to me on Bluesky or LinkedIn. We're always happy to share what we've learned.
Trellis is a fundraising platform that helps charities run more effective fundraising events. Learn more about what we do at trellis.org.