Day 2 : Monorepo setup with shared types and configuration

Lesson 2 60 min

Monorepos: The Unsung Hero of Consistency in Distributed Systems

Welcome back, future system architects! In Day 1, we peeled back the layers of platforms like LeetCode and SPOJ, understanding how their complex architectures handle millions of coding submissions. We saw a myriad of components – frontend, API gateways, judge services, databases, caching layers – all working in concert. But imagine the nightmare if each of these services defined a "Problem" object or a "Submission" status slightly differently. Chaos, right?

Today, we're tackling a foundational element that underpins consistency and developer velocity in large-scale systems: the monorepo setup with shared types and configuration. This isn't just about putting code in one folder; it's about establishing a single source of truth that prevents runtime bugs, enforces contracts, and simplifies the lives of engineers working on distributed services.

The "Why": Beyond Just Code Sharing

When you're building systems that handle 100 million requests per second, consistency isn't a luxury; it's a necessity. Here's why shared types and configuration within a monorepo are indispensable:

  1. Preventing Runtime Mismatches (The Silent Killers): Imagine your frontend expects a problemId as a string, but your backend API accidentally sends it as a number. In a polyrepo world, this might only surface at runtime, leading to cryptic errors, frustrated users, and debugging sessions that span multiple repositories and teams. With shared TypeScript types, this mismatch is caught at compile time, before it even leaves your development environment. This isn't just about saving time; it's about building robustness directly into your system's contracts.

  2. Enforcing API Contracts Across Services: Microservices are great for independent deployment, but they introduce the challenge of maintaining consistent interfaces. A shared IProblem interface in your monorepo acts as a binding contract. If the backend changes IProblem, the frontend (and any other service consuming it) will immediately show a build error, forcing a coordinated update. This mechanism is critical for graceful evolution of APIs.

  3. Simplified Refactoring and Global Changes: Need to rename a field like submissionStatus to submissionVerdict across your entire platform? In a polyrepo setup, that's a multi-PR, multi-repo, potentially multi-team nightmare. In a monorepo, with shared types, a single change in the shared package instantly highlights all dependent services that need updating. Atomic commits encompassing all changes across services become possible, drastically reducing integration friction.

  4. Unified Configuration Management: Environment variables are great, but what about application-level constants like MAX_PROBLEM_DESCRIPTION_LENGTH or API_BASE_URL? Storing these in a shared configuration package ensures every service uses the same values, preventing subtle inconsistencies that can lead to hard-to-diagnose bugs.

Real-World Context: Think about LeetCode again. Their frontend displays problems, the API serves problem data, and the judge system processes solutions against problem constraints. All these components must agree on what a "Problem" looks like – its ID, title, description, constraints, test cases. A shared IProblem type and a ProblemConfig for constraints are absolutely vital for such an ecosystem to function without constant breakage.

Core Concepts for Our Build

State Machine

Consistent Type Mismatch (Local Build Fails) Refactored Change Shared Type Update Consumers Successful Build & Commit

We'll leverage a few key ideas today:

  • Monorepo: A single repository containing multiple distinct projects, often managed with "workspaces."

  • Workspaces (pnpm): A feature of package managers (like pnpm, yarn, npm) that allows you to manage multiple packages within a single repository, treating them as local dependencies. pnpm is particularly efficient with disk space and installation speed, making it a favorite in big tech.

  • TypeScript: Our weapon of choice for type safety. It allows us to define clear interfaces and types that our JavaScript code can adhere to, catching errors early.

Our System Architecture (Simplified)

Component Architecture

monorepo-day2/ packages/shared Types & Config packages/backend packages/frontend Imports Types Imports Types

For today's lesson, we'll build a miniature monorepo with three core packages:

  1. monorepo-day2/packages/shared: This will house our shared TypeScript interfaces and configuration constants.

  2. monorepo-day2/packages/frontend: A simple Node.js application that simulates a frontend, consuming types and config from shared.

  3. monorepo-day2/packages/backend: Another simple Node.js application, simulating a backend API, also consuming from shared.

This structure will demonstrate how changes in shared instantly impact frontend and backend during the build process, enforcing consistency.

Control Flow & Data Flow: A Ripple Effect

Flowchart

Update IProblem in /shared Run pnpm build:all Types Valid? Compile FE & BE Build Error (Atomic Failure) System Consistent & Live YES NO

When you modify a type in packages/shared, here's the simplified flow:

  1. Developer Action: You update IProblem in packages/shared/src/index.ts.

  2. Build Trigger: You run a monorepo-wide build command (e.g., pnpm build:all).

  3. Dependency Resolution: pnpm (or your package manager) recognizes that frontend and backend depend on shared.

  4. Compilation: shared is compiled first. Then, frontend and backend are compiled. If your IProblem change is breaking, their compilation will fail, immediately notifying you of the necessary adaptations.

  5. Execution: Once all packages build successfully, your frontend and backend services run, now operating with the updated, consistent types and configuration.

The "data" here isn't just runtime data; it's the schema and configuration data that flows from shared to its consumers, ensuring structural integrity across your entire system.

Hands-On Build-Along: Setting Up Our Monorepo

Let's get our hands dirty and build this. We'll set up a monorepo using pnpm workspaces, create a shared package, and then create frontend and backend packages that consume these shared types and configuration.

Agenda:

  1. Initialize a pnpm monorepo.

  2. Create packages/shared with TypeScript types and a configuration constant.

  3. Create packages/frontend and packages/backend applications.

  4. Demonstrate how frontend and backend consume and use the shared definitions.

  5. Build and run the entire system.

By the end of this lesson, you'll have a running monorepo that clearly demonstrates the power of shared types and configuration, giving you a tangible blueprint for building robust, scalable systems.


Assignment

Your mission, should you choose to accept it, is to extend our monorepo:

  1. New Shared Type: In packages/shared, define a new TypeScript interface IUser with properties like id: string, username: string, and email: string.

  2. New Shared Config: Add a new constant DEFAULT_USER_ROLE: 'user' | 'admin' to packages/shared/src/config.ts.

  3. Consume in Frontend: In packages/frontend/src/index.ts, create a dummy user object using IUser and print its details, along with the DEFAULT_USER_ROLE.

  4. Consume in Backend: In packages/backend/src/index.ts, simulate receiving a user object, type it as IUser, and print a message indicating the user's role using DEFAULT_USER_ROLE.

  5. Verify: Re-run the build and observe the updated output in both frontend and backend console logs.

Solution Hints

  1. Creating IUser:

  • Open packages/shared/src/index.ts.

  • Add export interface IUser { id: string; username: string; email: string; }.

  1. Adding DEFAULT_USER_ROLE:

  • Open packages/shared/src/config.ts.

  • Add export const DEFAULT_USER_ROLE: 'user' | 'admin' = 'user';.

  1. Using in Frontend:

  • Open packages/frontend/src/index.ts.

  • Add import { IUser, DEFAULT_USER_ROLE } from 'shared';

  • Declare a variable: const currentUser: IUser = { id: 'usr-123', username: 'dev_user', email: 'dev@example.com' };

  • Print: console.log(Frontend: Current user is ${currentUser.username} with default role ${DEFAULTUSERROLE});

  1. Using in Backend:

  • Open packages/backend/src/index.ts.

  • Add import { IUser, DEFAULT_USER_ROLE } from 'shared';

  • Simulate data: const receivedUser: IUser = { id: 'usr-456', username: 'api_client', email: 'api@example.com' };

  • Print: console.log(Backend: Processing user ${receivedUser.username}. Assigning default role: ${DEFAULTUSERROLE});

  1. Re-build and Run:

  • Navigate to the monorepo root.

  • Run pnpm build:all.

  • Run pnpm start:all.

  • Observe the console output.

Need help?