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:

  1. Clarity: It is clear where to find libraries and what they contain/can be used for
  2. Dependency Management: We can enforce architectural boundaries
  3. Code Reuse: Shared libraries prevent duplication across apps
  4. 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 logic
  • type:ui: Dumb components with no business logic
  • type:data-access: Data layer and API calls
  • type:utility: Pure functions and helpers
  • type:type: TypeScript type definitions only
  • type:testing: Test utilities and mocks

Scope Tags

  • scope:shared: Available to all applications
  • scope:auction: Auction-specific functionality
  • scope:checkout: Checkout flow components
  • scope: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 schemas
  • layer: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:

  1. Version Stamping: Ensures each build is uniquely identified (stamp-version)
  2. Code Generation: Runs GraphQL and OpenAPI generation first, so they are available when compiling the application
  3. Runtime Configs: Service worker and browser support configurations are generated post-build
  4. Build the Application: Run the Angular Compiler (via build)
  5. Single Entry Point: The compile target orchestrates the entire build pipeline, this allows all apps across platforms to have a single compile 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 remote develop APIs for rapid iteration
  • develop: Auto-deployed from the develop branch to the shared dev environment
  • staging: Tag-triggered release candidates (vX.X.X-rc.Y) deployed to staging for QA
  • production: Tag-triggered stable releases (vX.X.X) deployed to live customer environments
  • load-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 typechecking
  • test: Jest and Vitest test suites
  • graphql-codegen: Generates GraphQL types and operations
  • generate-open-api: Builds typed clients for external REST APIs
  • generate-translations: Localization pipeline for i18n
  • typecheck: Type safety for affected projects
  • compile: Compiles SSR and client bundles
  • build: Environment-ready application builds
  • feature-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 coverage
  • generate-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 after develop 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 the develop environment
  • release-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 to staging infrastructure
  • release-only: Finalize staging release in internal tools

Production-Specific CI Targets

Triggered by vX.X.X tags:

  • deploy: Deploy to production infrastructure
  • generate-changelog: Commits a Git-based changelog, AI-generated release notes to a Slack channel, moves all resolved Linear Issues to Production/Done and ensure they are lablled correctly.

Load-Testing-Specific CI Targets

Triggered by vX.X.X-lt.Y tags:

  • deploy: Deploy to load-testing infrastructure
  • release-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

  1. 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.
  2. Custom Plugins: Domain-specific tooling has been worth the effort. The maintenance overhead is minor compared to the benefits they bring us.
  3. Affected-Based CI: Shaving minutes off every build adds up. Our feedback loops are fast, and we're able to ship frequently.
  4. 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

  1. Enforce Boundaries from Day One: We added module boundaries after the fact. Doing it upfront would've saved a lot of time and rework.
  2. Invest in Documentation Earlier: Complex systems need great docs. We waited too long to prioritize this, and we're still catching up.
  3. 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:

  1. git diff: 31,523 files changed, 876,005 insertions(+), 461,666 deletions(-)
  2. develop Releases: 1,474
  3. production Releases: 290
  4. Linear Issues Completed: 2,811

2025 year-to-date (same team size):

  1. git diff: 34,870 files changed, 402,824 insertions(+), 319,192 deletions(-)
  2. develop Releases: 1,172
  3. production Releases: 173
  4. 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.