Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Architecting a TypeScript Monorepo

Welcome to the next lesson in our journey to build an agentic engineering assistant. In our previous session, we created the architectural blueprints for our system using C4 diagrams, defining the key containers: a Vue client, a TypeScript API, a background worker, and a database. Now, it's time to lay the foundation and translate that architectural vision into a concrete, organized codebase.

This lesson focuses on creating the project's skeleton. We will set up a TypeScript monorepo to house all our code. You'll learn how to structure the project, create separate packages for the frontend, backend, and shared code, and most importantly, establish explicit rules that govern how these pieces can interact. This structure is essential for maintaining a clean, scalable, and maintainable codebase as the project grows.

By the end of this lesson, you will have a fully configured monorepo ready for development, with clear boundaries that prevent architectural decay over time.

Why a Monorepo?

For a system with distinct but tightly-coupled parts like ours—a frontend, a backend API, and a background worker that all need to agree on data structures—a monorepo (a single repository containing multiple distinct projects) offers significant advantages over managing separate repositories (polyrepo).

The key benefits for our project are:

  • Simplified Code Sharing: Our api, web client, and worker all need to use the same data types and validation schemas. In a monorepo, we can create a shared-contracts package and import it directly, without the overhead of publishing and versioning a private npm package.
  • Atomic Commits: A change to a shared data structure can be updated across the API, worker, and frontend in a single, atomic commit. This eliminates the risk of temporary inconsistencies between services.
  • Unified Tooling: We can have a single, top-level command to run tests, lint, or type-check across the entire project, ensuring consistency everywhere.

To manage our monorepo, we will use two powerful tools:

  1. pnpm: A fast and disk-space-efficient package manager that has excellent support for monorepos through a feature called workspaces.
  2. Turborepo: A high-performance build system designed for monorepos. It intelligently orchestrates tasks, caches outputs, and understands the dependency graph between your packages.

The image below illustrates a common monorepo architecture, similar to what we will build, where a backend API serves multiple frontend applications, all managed within a single Turborepo setup.

This diagram shows a typical monorepo structure where a Nest.js API communicates with two different frontends. Notice how they all exist within the same overarching "Turborepo Monorepo" context.

Setting Up the Skeleton

The standard convention for a monorepo is to separate deployable applications from shareable packages.

  • apps/: This directory will contain our runnable applications: the Vue frontend (web), the backend api, and the worker.
  • packages/: This will house our shared code, such as UI components, utility functions, and most importantly, our shared-contracts.

The following article from Earthly provides a hands-on walkthrough for setting up a monorepo with pnpm and Turborepo. We'll follow its guidance to create our initial structure.

Using Turborepo to Build Your First Monorepo - Earthly Blog

This guide provides a clear, step-by-step process for initializing a monorepo. We will use the concepts it introduces to create the skeleton for our project.

Please read the sections titled Create the Monorepo. As you read, focus on understanding these key steps: Initializing pnpm. Creating the apps and packages directory structure. Setting up the pnpm-workspace.yaml file to tell pnpm where to find the projects. You can skip the parts about creating specific Vue or React apps for now; we're just focused on the directory structure and workspace configuration.

Following those steps, your project's root directory should now have a pnpm-workspace.yaml file and two empty directories: apps and packages. This is the basic skeleton of our monorepo.

Centralized TypeScript Configuration

A common pitfall in monorepos is duplicating configuration files, leading to inconsistencies. To avoid this, we'll use a single base tsconfig.json at the root of our project and have each package extend it. This ensures all our code adheres to the same TypeScript compiler rules.

The article "The Right Way to Structure a Node.js Monorepo" explains this inheritance pattern perfectly.

The Right Way to Structure a Node.js Monorepo in 2026 | Stack Dev Life

This section explains a robust pattern for managing TypeScript configuration across a monorepo, which we will adopt for our project.

Please read the section get the inheritance right. Pay close attention to the distinction between the base tsconfig.base.json (which contains only compilerOptions) and the package-specific tsconfig.json (which extends the base and adds local paths).

Let's put this into practice.

  1. Create a file named tsconfig.base.json in the root of your monorepo with the strict compilerOptions suggested in the article.
  2. Inside each application and package we create (e.g., apps/api), we will add a tsconfig.json file that looks like this:
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src/**/*.ts"],
  "exclude": ["node_modules", "dist"]
}

This simple but powerful pattern ensures consistency while giving each package control over its own source files.

The Shared Contract Package

The most critical shared element in our architecture is the "contract" between the backend and its clients. This includes data types, API endpoint definitions, and validation logic. By placing this in a shared package, we can achieve end-to-end type safety.

We will create a package at packages/contracts. The Stack Dev Life article also demonstrates a modern technique for exposing a package's source code for a seamless development experience, using the exports field in package.json.

The Right Way to Structure a Node.js Monorepo in 2026 | Stack Dev Life

This reading will show you how to configure a shared package so other parts of the monorepo can consume it without needing a separate build step during development.

Read the section how to share code without pain. Focus on how the exports field in package.json is configured to point directly to the TypeScript source files (./src/index.ts).

By using this pattern, when you're developing the api, it can import directly from the TypeScript source of the contracts package. Your IDE will provide instant feedback and type-checking, and you won't need to constantly rebuild the contracts package after every change. For production builds, Turborepo will ensure everything is compiled correctly.

Enforcing Architectural Boundaries

We've established a directory structure, but nothing technically prevents a developer from making an incorrect import, violating our architecture. For example, the api should never import code directly from the web frontend. Manually policing this is tedious and error-prone.

A senior engineering practice is to automate the enforcement of these architectural rules. We can do this using linting. The following video explains how to define and enforce module boundaries using ESLint rules. Although the video uses a tool called Nx, the underlying principle of tagging packages and defining dependency rules is universal and can be applied with the @typescript-eslint/enforce-module-boundaries rule.

Guarding your Module Boundaries with Lint Rules

This video demonstrates a powerful technique for enforcing architectural rules automatically. We will apply this concept to our monorepo to maintain its structure.

Please watch the following segments: The problem: Understand why accidental imports are an issue in monorepos. Classifying projects: Learn the strategy of using "tags" to categorize each package (e.g., by type and scope). Defining rules: See how to write rules in your ESLint configuration that specify which tags can depend on which other tags. Seeing it in action: Observe how the lint rule provides immediate feedback in the editor when a rule is violated.

To apply this to our project, we would:

  1. Tag each package: In each package.json, we can add a custom field like "tags": ["type:app", "scope:api"] for the backend, or "tags": ["type:lib", "scope:shared"] for our contracts package.
  2. Configure ESLint: In our root .eslintrc.js file, we would configure the @typescript-eslint/enforce-module-boundaries rule to define the valid dependency graph. For example:
    • A package with type:app can depend on a package with type:lib.
    • A package with type:lib cannot depend on a package with type:app.
    • A package with scope:api cannot depend on a package with scope:web.

This turns your architectural diagram into a checkable specification that runs continuously in your IDE and in your CI/CD pipeline, preventing costly mistakes.

Conclusion

In this lesson, you have constructed the foundational structure of our application. We've moved beyond diagrams and created a tangible, well-organized TypeScript monorepo. This skeleton isn't just a set of empty folders; it's a system designed for scalability and maintainability.

Key Takeaways:

  • A monorepo is an effective strategy for managing projects with multiple, interconnected parts like our agentic assistant.
  • The apps/ and packages/ directory structure provides a clean separation between deployable applications and shared libraries.
  • pnpm workspaces link the packages together, and Turborepo helps manage the build process efficiently.
  • Using a shared tsconfig.base.json ensures consistent compiler settings across the project.
  • Automated boundary enforcement via ESLint rules is a crucial practice for preventing architectural decay and maintaining clean dependencies.

We've made a significant architectural choice today by opting for a monorepo. In our next lesson, we will formalize this decision. You will learn how to write an Architecture Decision Record (ADR), a short document that captures a key architectural decision, its context, and its consequences. This is a vital skill for communicating design rationale within a team.

Can't find a good explanation? Sign up and we'll make it for you

Sign up