Capstone Overview — Project Architecture, Monorepo Setup, and Shared Types

The capstone project is where every concept from the previous 22 chapters converges into a single, production-quality application. Rather than isolated exercises, the Task Manager app is built as a real product would be: starting from requirements and architecture decisions, then scaffolding the monorepo, configuring all tooling, and establishing the conventions that will carry through the entire build. This lesson covers the project requirements, technology decisions, folder structure, environment setup, and the foundational configuration files that make the remaining capstone chapters possible.

Project Feature Set

Feature Area Functionality Chapters Applied
Authentication Register, login, JWT refresh, password reset, email verification Ch 17
Task Management CRUD, status workflow, priority, due dates, tags, soft delete Ch 4–8
Workspaces Multi-user workspaces, member invitations, role-based access Ch 17
File Attachments Upload images and documents to tasks, cloud storage Ch 18
Real-Time Updates Live task changes via Socket.io, online presence, typing indicators Ch 18
Search Full-text search with Atlas Search, filters, pagination Ch 13, 18
Notifications In-app and email notifications for assignments, mentions, due dates Ch 5
Dashboard Task stats, completion trends, overdue counts, tag cloud Ch 13
Performance Redis caching, rate limiting, optimised queries Ch 5, 22
Note: The capstone is structured as a Nx monorepo with three packages: apps/api (Express), apps/client (Angular), and packages/shared (TypeScript interfaces, DTOs, and constants shared by both). This structure enables type-safe contracts between the frontend and backend — the Angular service and the Express controller both import from @taskmanager/shared, making it impossible for them to diverge on interface shape without a TypeScript error at compile time.
Tip: Establish coding conventions early with ESLint, Prettier, and commit linting (commitlint + Husky). Once applied to 50+ files, retroactively enforcing conventions is painful. Configure ESLint with @typescript-eslint/recommended, Prettier for formatting, and commitlint with conventional commits (feat:, fix:, chore:) from day one. The pre-commit hook that runs lint and typecheck takes 10 seconds but saves hours of “fix linting” commits later.
Warning: The .env files for the capstone contain real secrets — JWT secrets, MongoDB Atlas connection strings, email service credentials. Never commit these to the repository. The .env.example files committed to the repo show every variable name with placeholder values and comments explaining each. Run echo ".env*\n!.env.example" >> .gitignore immediately after creating the repository, before adding any .env files.

Project Scaffolding

# ── 1. Create monorepo structure ─────────────────────────────────────────
mkdir taskmanager && cd taskmanager
git init
echo "node_modules/\ndist/\n.env\n.env.*\n!.env.example\ncoverage/\n*.heapsnapshot" > .gitignore

# Root package.json — workspace manager
cat > package.json <<'EOF'
{
  "name":    "taskmanager-monorepo",
  "private": true,
  "workspaces": ["apps/*", "packages/*"],
  "scripts": {
    "dev":          "concurrently \"npm run dev:api\" \"npm run dev:client\"",
    "dev:api":      "npm run dev --workspace=apps/api",
    "dev:client":   "npm run dev --workspace=apps/client",
    "test":         "npm run test --workspaces --if-present",
    "lint":         "npm run lint --workspaces --if-present",
    "typecheck":    "npm run typecheck --workspaces --if-present"
  }
}
EOF

# Create workspace directories
mkdir -p apps/api apps/client packages/shared

# ── 2. Shared package ─────────────────────────────────────────────────────
cat > packages/shared/package.json <<'EOF'
{
  "name":    "@taskmanager/shared",
  "version": "1.0.0",
  "main":    "./dist/index.js",
  "types":   "./dist/index.d.ts",
  "exports": {
    ".": {
      "import":  "./dist/index.mjs",
      "require": "./dist/index.js"
    }
  },
  "scripts": {
    "build":     "tsup src/index.ts --format cjs,esm --dts",
    "typecheck": "tsc --noEmit"
  }
}
EOF

mkdir -p packages/shared/src

# ── 3. Scaffold Express API ───────────────────────────────────────────────
cd apps/api
npm init -y
npm install express mongoose redis bull nodemailer bcryptjs jsonwebtoken
npm install multer helmet morgan cors express-rate-limit
npm install -D typescript @types/node @types/express jest supertest nodemon

# ── 4. Scaffold Angular client ────────────────────────────────────────────
cd ../client
ng new client --routing --style=scss --strict --standalone \
  --no-create-application
ng generate application taskmanager --prefix=tm --style=scss --routing

# ── 5. Docker setup ───────────────────────────────────────────────────────
cd ../..
cat > docker-compose.yml <<'EOF'
version: '3.9'
services:
  mongodb:
    image: mongo:7
    command: ["mongod", "--replSet", "rs0"]
    ports: ["27017:27017"]
    volumes: [mongodb_data:/data/db]
    healthcheck:
      test: ["CMD","mongosh","--eval","db.adminCommand('ping')","--quiet"]
      interval: 10s
      retries: 5
      start_period: 30s

  redis:
    image: redis:7-alpine
    ports: ["6379:6379"]
    volumes: [redis_data:/data]

  api:
    build: { context: ./apps/api, target: development }
    env_file: ./apps/api/.env
    volumes: ["./apps/api:/app", "api_modules:/app/node_modules"]
    ports: ["3000:3000"]
    depends_on:
      mongodb: { condition: service_healthy }

  client:
    build: { context: ./apps/client, target: development }
    volumes: ["./apps/client/src:/app/src"]
    ports: ["4200:4200"]
    environment: [CHOKIDAR_USEPOLLING=true]

volumes:
  mongodb_data:
  redis_data:
  api_modules:
EOF
// ── packages/shared/src/models/task.model.ts ──────────────────────────────
export type TaskStatus   = 'todo' | 'in-progress' | 'in-review' | 'done' | 'cancelled';
export type TaskPriority = 'none' | 'low' | 'medium' | 'high' | 'urgent';

export interface Task {
    _id:          string;
    title:        string;
    description?: string;
    status:       TaskStatus;
    priority:     TaskPriority;
    dueDate?:     string;       // ISO 8601
    startDate?:   string;
    tags:         string[];
    attachments:  Attachment[];
    assignees:    string[];     // User IDs
    workspace:    string;       // Workspace ID
    createdBy:    string;       // User ID
    createdAt:    string;
    updatedAt:    string;
    completedAt?: string;
    isOverdue:    boolean;      // virtual
}

export interface Attachment {
    _id:         string;
    filename:    string;
    url:         string;
    size:        number;
    mimeType:    string;
    uploadedBy:  string;
    uploadedAt:  string;
}

export interface CreateTaskDto {
    title:       string;
    description?: string;
    priority?:   TaskPriority;
    dueDate?:    string;
    startDate?:  string;
    tags?:       string[];
    assignees?:  string[];
    workspaceId: string;
}

export type UpdateTaskDto = Partial<Omit<CreateTaskDto, 'workspaceId'>> & {
    status?: TaskStatus;
};

// ── packages/shared/src/models/user.model.ts ──────────────────────────────
export type UserRole = 'user' | 'admin';

export interface User {
    _id:          string;
    name:         string;
    email:        string;
    avatarUrl?:   string;
    role:         UserRole;
    isVerified:   boolean;
    createdAt:    string;
}

export interface AuthTokens {
    accessToken:  string;
    refreshToken: string;
    expiresIn:    number;
}

// ── packages/shared/src/models/workspace.model.ts ─────────────────────────
export type MemberRole = 'owner' | 'admin' | 'member' | 'viewer';

export interface WorkspaceMember {
    userId:   string;
    role:     MemberRole;
    joinedAt: string;
}

export interface Workspace {
    _id:        string;
    name:       string;
    slug:       string;
    description?:string;
    members:    WorkspaceMember[];
    createdBy:  string;
    createdAt:  string;
    updatedAt:  string;
}

// ── packages/shared/src/index.ts ─────────────────────────────────────────
export * from './models/task.model';
export * from './models/user.model';
export * from './models/workspace.model';
export * from './constants/api-routes';
export * from './constants/socket-events';

// ── packages/shared/src/constants/api-routes.ts ──────────────────────────
export const API_BASE    = '/api/v1';
export const API_ROUTES  = {
    AUTH:        `${API_BASE}/auth`,
    USERS:       `${API_BASE}/users`,
    WORKSPACES:  `${API_BASE}/workspaces`,
    TASKS:       `${API_BASE}/tasks`,
    UPLOADS:     `${API_BASE}/uploads`,
    SEARCH:      `${API_BASE}/search`,
    HEALTH:      `${API_BASE}/health`,
} as const;

// ── packages/shared/src/constants/socket-events.ts ───────────────────────
export const SOCKET_EVENTS = {
    // Task events
    TASK_CREATED:   'task:created',
    TASK_UPDATED:   'task:updated',
    TASK_DELETED:   'task:deleted',
    TASK_ASSIGNED:  'task:assigned',
    // User events
    USER_TYPING:    'user:typing',
    USER_JOINED:    'user:joined',
    USER_LEFT:      'user:left',
    // Workspace events
    WORKSPACE_JOIN: 'workspace:join',
    ONLINE_USERS:   'online_users',
    // Notifications
    NOTIFICATION:   'notification',
} as const;

How It Works

Step 1 — Monorepo Workspaces Share Code Without Publishing

npm workspaces link packages in the packages/ directory into node_modules/@taskmanager/shared automatically. The Angular and Express apps import { Task } from '@taskmanager/shared' — no publishing to npm required. Changes to the shared package are immediately reflected in both consumers. TypeScript’s project references ensure the shared package is compiled before the apps that depend on it.

Step 2 — Shared Models Are the Contract Between API and Client

When a shared Task interface changes — adding a new field — TypeScript compile errors appear in both the Express controller (which must return the field) and the Angular component (which must display it). This compile-time enforcement replaces runtime “undefined is not a field” bugs discovered in production. The shared package also exports API route constants and Socket.io event names, preventing the frontend and backend from diverging on string values.

Step 3 — Single-Node Replica Set Enables Transactions and Change Streams in Development

The Docker Compose MongoDB container uses command: ["mongod", "--replSet", "rs0"] to run as a replica set. After the container starts, the replica set must be initialised with rs.initiate() — the mongo-init.js script handles this. Without a replica set, transactions and Change Streams throw errors. The health check waits for the replica set to be ready before the API container starts.

Step 4 — Shared Constants Prevent Magic Strings

API route strings like '/api/v1/tasks' and Socket.io event names like 'task:created' appear in both the frontend and backend. Defining them once in @taskmanager/shared and importing them in both eliminates the class of bug where the frontend emits 'taskCreated' (camelCase) but the backend listens for 'task:created' (colon-separated) — a completely silent failure with no error message.

Step 5 — Strict TypeScript Catches Issues at Compile Time

The Angular app is generated with --strict, enabling strictNullChecks, noImplicitAny, and strict template checking. The Express API is configured with strict TypeScript settings. Combined with the shared types, this means a Task field used in a template must be typed — accessing task.nonExistentField is a compile error. This strict baseline prevents a category of runtime errors before the application runs.

Quick Reference — Project Structure

taskmanager/
├── apps/
│   ├── api/                  # Express API
│   │   ├── src/
│   │   │   ├── config/       # env, logger, redis, mongoose
│   │   │   ├── middleware/   # auth, rbac, rate-limit, error
│   │   │   ├── modules/      # feature modules (tasks, auth, workspaces)
│   │   │   │   └── tasks/
│   │   │   │       ├── task.controller.ts
│   │   │   │       ├── task.service.ts
│   │   │   │       ├── task.model.ts
│   │   │   │       ├── task.routes.ts
│   │   │   │       └── task.validation.ts
│   │   │   ├── queues/       # Bull job queues
│   │   │   ├── sockets/      # Socket.io handlers
│   │   │   ├── app.ts
│   │   │   └── server.ts
│   │   ├── Dockerfile
│   │   └── .env.example
│   └── client/               # Angular app
│       └── src/app/
│           ├── core/         # singleton services, guards, interceptors
│           ├── shared/       # reusable components, pipes, directives
│           └── features/     # feature modules (tasks, auth, workspaces)
│               └── tasks/
│                   ├── components/
│                   ├── services/
│                   └── stores/
└── packages/
    └── shared/               # shared TypeScript models and constants
        └── src/
            ├── models/
            └── constants/

🧠 Test Yourself

The Task interface in @taskmanager/shared adds a new required field workspaceId: string. The Angular component creates a Task object without this field. What happens without running the application?