Skip to content
advanced Phase 19 · Full Stack Project

Full Stack Build & Deploy

Implement the complete full stack application with testing, CI/CD, and production deployment.

3h
0 problems
Topic Progress 0%

Project Scaffolding

Project Scaffolding

Monorepo Structure

A well-organized monorepo keeps frontend, backend, and shared code in a single repository with clear boundaries. Use Turborepo for build orchestration and caching.

myapp/
├── packages/
│   ├── web/                  # React + Vite frontend
│   │   ├── src/
│   │   │   ├── components/   # Reusable UI components
│   │   │   ├── pages/        # Route-level page components
│   │   │   ├── hooks/        # Custom React hooks
│   │   │   ├── lib/          # API client, utilities
│   │   │   ├── stores/       # Zustand state management
│   │   │   └── App.tsx
│   │   ├── public/
│   │   ├── package.json
│   │   ├── vite.config.ts
│   │   └── tsconfig.json
│   ├── api/                  # Express + Prisma backend
│   │   ├── src/
│   │   │   ├── controllers/  # Route handlers
│   │   │   ├── services/     # Business logic
│   │   │   ├── repositories/ # Database access layer
│   │   │   ├── middleware/    # Auth, validation, error handling
│   │   │   ├── routes/       # Express router definitions
│   │   │   ├── lib/          # Shared utilities, config
│   │   │   └── app.ts        # Express app setup
│   │   ├── prisma/
│   │   │   ├── schema.prisma
│   │   │   └── migrations/
│   │   ├── tests/
│   │   └── package.json
│   └── shared/               # Shared types and validation
│       ├── src/
│       │   ├── types/        # TypeScript interfaces
│       │   └── schemas/      # Zod validation schemas
│       └── package.json
├── package.json              # Workspace root
├── turbo.json                # Turborepo pipeline config
├── docker-compose.yml        # Local development services
├── .github/workflows/ci.yml  # CI pipeline
└── .env.example              # Environment variable template

Workspace Configuration

// Root package.json
{
  "name": "myapp",
  "private": true,
  "workspaces": ["packages/*"],
  "scripts": {
    "dev": "turbo run dev",
    "build": "turbo run build",
    "test": "turbo run test",
    "lint": "turbo run lint",
    "db:migrate": "turbo run db:migrate --filter=api",
    "db:seed": "turbo run db:seed --filter=api"
  },
  "devDependencies": {
    "turbo": "^2.0.0",
    "typescript": "^5.4.0"
  }
}
// turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "globalDependencies": [".env.*"],
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "test": {
      "dependsOn": ["build"]
    },
    "lint": {}
  }
}

API Client with Type Safety

// packages/web/src/lib/api.ts
const API_BASE = import.meta.env.VITE_API_URL || '/api';

interface ApiError {
  message: string;
  code?: string;
  details?: Record<string, string[]>;
}

async function request<T>(path: string, options?: RequestInit): Promise<T> {
  const token = localStorage.getItem('accessToken');

  const res = await fetch(`${API_BASE}${path}`, {
    ...options,
    headers: {
      'Content-Type': 'application/json',
      ...(token && { Authorization: `Bearer ${token}` }),
      ...options?.headers,
    },
  });

  if (!res.ok) {
    if (res.status === 401) {
      localStorage.removeItem('accessToken');
      window.location.href = '/login';
      throw new Error('Session expired');
    }
    const error: ApiError = await res.json().catch(() => ({
      message: `HTTP ${res.status}: ${res.statusText}`,
    }));
    throw new Error(error.message);
  }

  if (res.status === 204) return undefined as T;
  return res.json();
}

export const api = {
  get: <T>(path: string) => request<T>(path),
  post: <T>(path: string, body: unknown) =>
    request<T>(path, { method: 'POST', body: JSON.stringify(body) }),
  patch: <T>(path: string, body: unknown) =>
    request<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
  delete: <T>(path: string) =>
    request<T>(path, { method: 'DELETE' }),
};

Docker Compose for Local Development

# docker-compose.yml
version: '3.9'
services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: devuser
      POSTGRES_PASSWORD: devpassword
      POSTGRES_DB: myapp_dev
    ports:
      - '5432:5432'
    volumes:
      - pgdata:/var/lib/postgresql/data

  redis:
    image: redis:7-alpine
    ports:
      - '6379:6379'

  mailhog:
    image: mailhog/mailhog
    ports:
      - '1025:1025'
      - '8025:8025'

volumes:
  pgdata:

This scaffolding gives you a fully typed, production-ready foundation where packages share types through the shared package, Turborepo caches builds, and Docker handles local infrastructure.

Full-Stack Feature Implementation

Full-Stack Feature Implementation

Database Schema and Migration

Start with a well-designed database schema using Prisma for type-safe queries and automated migrations.

// packages/api/prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        String   @id @default(uuid())
  email     String   @unique
  name      String
  avatarUrl String?
  tasks     Task[]
  projects  ProjectMember[]
  createdAt DateTime @default(now())
}

model Task {
  id          String   @id @default(uuid())
  projectId   String
  project     Project  @relation(fields: [projectId], references: [id], onDelete: Cascade)
  title       String
  description String?
  status      String   @default("todo") // todo, in_progress, review, done
  priority    String   @default("medium") // low, medium, high, urgent
  assigneeId  String?
  assignee    User?    @relation(fields: [assigneeId], references: [id])
  dueDate     DateTime?
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt

  @@index([projectId, status])
  @@index([assigneeId])
}

model Project {
  id          String          @id @default(uuid())
  name        String
  description String?
  ownerId     String
  owner       User            @relation(fields: [ownerId], references: [id])
  members     ProjectMember[]
  tasks       Task[]
  createdAt   DateTime        @default(now())
  updatedAt   DateTime        @updatedAt
}

model ProjectMember {
  id        String  @id @default(uuid())
  projectId String
  project   Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
  userId    String
  user      User    @relation(fields: [userId], references: [id])
  role      String  @default("member")

  @@unique([projectId, userId])
}

Run migrations with npx prisma migrate dev --name add-task-model and generate the client with npx prisma generate.

Repository Layer

// packages/api/src/repositories/task.repository.ts
import { prisma } from '../lib/prisma';
import { CreateTaskInput, UpdateTaskInput, TaskFilters } from '@myapp/shared';

export const taskRepository = {
  async findByProject(projectId: string, filters?: TaskFilters) {
    return prisma.task.findMany({
      where: {
        projectId,
        ...(filters?.status && { status: filters.status }),
        ...(filters?.assigneeId && { assigneeId: filters.assigneeId }),
        ...(filters?.priority && { priority: filters.priority }),
      },
      include: {
        assignee: { select: { id: true, name: true, avatarUrl: true } },
      },
      orderBy: [
        { priority: 'desc' },
        { createdAt: 'desc' },
      ],
    });
  },

  async findById(id: string) {
    return prisma.task.findUnique({
      where: { id },
      include: {
        assignee: { select: { id: true, name: true, avatarUrl: true } },
        project: { select: { id: true, name: true } },
      },
    });
  },

  async create(data: CreateTaskInput & { projectId: string }) {
    return prisma.task.create({
      data: {
        title: data.title,
        description: data.description,
        priority: data.priority ?? 'medium',
        status: 'todo',
        projectId: data.projectId,
        assigneeId: data.assigneeId,
        dueDate: data.dueDate ? new Date(data.dueDate) : null,
      },
    });
  },

  async update(id: string, data: UpdateTaskInput) {
    return prisma.task.update({
      where: { id },
      data: {
        ...(data.title && { title: data.title }),
        ...(data.description !== undefined && { description: data.description }),
        ...(data.status && { status: data.status }),
        ...(data.priority && { priority: data.priority }),
        ...(data.assigneeId !== undefined && { assigneeId: data.assigneeId }),
        ...(data.dueDate !== undefined && {
          dueDate: data.dueDate ? new Date(data.dueDate) : null,
        }),
      },
    });
  },

  async delete(id: string) {
    return prisma.task.delete({ where: { id } });
  },

  async getProjectStats(projectId: string) {
    const [total, byStatus, byPriority] = await Promise.all([
      prisma.task.count({ where: { projectId } }),
      prisma.task.groupBy({
        by: ['status'],
        where: { projectId },
        _count: true,
      }),
      prisma.task.groupBy({
        by: ['priority'],
        where: { projectId },
        _count: true,
      }),
    ]);
    return { total, byStatus, byPriority };
  },
};

Service Layer with Business Logic

// packages/api/src/services/task.service.ts
import { taskRepository } from '../repositories/task.repository';
import { CreateTaskInput, UpdateTaskInput, TaskFilters } from '@myapp/shared';
import { AppError } from '../middleware/error-handler';

export const taskService = {
  async listByProject(projectId: string, filters: TaskFilters) {
    const tasks = await taskRepository.findByProject(projectId, filters);
    return { data: tasks, count: tasks.length };
  },

  async getById(id: string) {
    const task = await taskRepository.findById(id);
    if (!task) throw new AppError('Task not found', 404, 'TASK_NOT_FOUND');
    return task;
  },

  async create(projectId: string, input: CreateTaskInput, userId: string) {
    if (input.title.trim().length < 3) {
      throw new AppError('Task title must be at least 3 characters', 400, 'VALIDATION_ERROR');
    }
    if (input.dueDate && new Date(input.dueDate) < new Date()) {
      throw new AppError('Due date cannot be in the past', 400, 'VALIDATION_ERROR');
    }
    const task = await taskRepository.create({ ...input, projectId });
    return task;
  },

  async update(id: string, input: UpdateTaskInput) {
    const existing = await taskRepository.findById(id);
    if (!existing) throw new AppError('Task not found', 404, 'TASK_NOT_FOUND');

    if (input.status) {
      const validTransitions: Record<string, string[]> = {
        todo: ['in_progress'],
        in_progress: ['review', 'todo'],
        review: ['done', 'in_progress'],
        done: ['todo'],
      };
      if (!validTransitions[existing.status]?.includes(input.status)) {
        throw new AppError(
          `Cannot transition from ${existing.status} to ${input.status}`,
          400,
          'INVALID_TRANSITION'
        );
      }
    }

    return taskRepository.update(id, input);
  },

  async delete(id: string) {
    const existing = await taskRepository.findById(id);
    if (!existing) throw new AppError('Task not found', 404, 'TASK_NOT_FOUND');
    return taskRepository.delete(id);
  },
};

Controller with Validation

// packages/api/src/controllers/task.controller.ts
import { Request, Response, NextFunction } from 'express';
import { taskService } from '../services/task.service';
import { createTaskSchema, updateTaskSchema } from '@myapp/shared';

export async function listTasks(req: Request, res: Response, next: NextFunction) {
  try {
    const { projectId } = req.params;
    const { status, assigneeId, priority } = req.query;
    const result = await taskService.listByProject(projectId, {
      status: status as string,
      assigneeId: assigneeId as string,
      priority: priority as string,
    });
    res.json(result);
  } catch (error) {
    next(error);
  }
}

export async function createTask(req: Request, res: Response, next: NextFunction) {
  try {
    const validated = createTaskSchema.parse(req.body);
    const task = await taskService.create(
      req.params.projectId,
      validated,
      req.user.id
    );
    res.status(201).json({ data: task });
  } catch (error) {
    next(error);
  }
}

export async function updateTask(req: Request, res: Response, next: NextFunction) {
  try {
    const validated = updateTaskSchema.parse(req.body);
    const task = await taskService.update(req.params.taskId, validated);
    res.json({ data: task });
  } catch (error) {
    next(error);
  }
}

export async function deleteTask(req: Request, res: Response, next: NextFunction) {
  try {
    await taskService.delete(req.params.taskId);
    res.status(204).send();
  } catch (error) {
    next(error);
  }
}

React Frontend with Optimistic Updates

// packages/web/src/hooks/useTasks.ts
import useSWR, { useSWRConfig } from 'swr';
import { api } from '../lib/api';
import type { Task, CreateTaskInput, UpdateTaskInput } from '@myapp/shared';

export function useTasks(projectId: string) {
  const { mutate } = useSWRConfig();
  const { data, error, isLoading } = useSWR<{ data: Task[] }>(
    `/api/projects/${projectId}/tasks`,
    { revalidateOnFocus: true }
  );

  const createTask = async (input: CreateTaskInput) => {
    const newTask = await api.post<{ data: Task }>(
      `/api/projects/${projectId}/tasks`,
      input
    );
    await mutate(`/api/projects/${projectId}/tasks`);
    return newTask.data;
  };

  const updateTask = async (taskId: string, input: UpdateTaskInput) => {
    await mutate(
      `/api/projects/${projectId}/tasks`,
      async (current) => {
        const optimistic = current?.data.map((t) =>
          t.id === taskId ? { ...t, ...input } : t
        ) ?? [];
        return { data: optimistic };
      },
      { revalidate: false }
    );

    try {
      await api.patch(`/api/tasks/${taskId}`, input);
      await mutate(`/api/projects/${projectId}/tasks`);
    } catch (err) {
      await mutate(`/api/projects/${projectId}/tasks`);
      throw err;
    }
  };

  const deleteTask = async (taskId: string) => {
    await api.delete(`/api/tasks/${taskId}`);
    await mutate(`/api/projects/${projectId}/tasks`);
  };

  return {
    tasks: data?.data ?? [],
    error,
    isLoading,
    createTask,
    updateTask,
    deleteTask,
  };
}

This layered approach keeps business logic isolated from HTTP concerns on the backend, while the frontend uses optimistic updates for a responsive user experience with automatic rollback on failure.

Testing the Full Stack

Testing the Full Stack

Testing Strategy Overview

Full-stack applications need testing at multiple layers: unit tests for isolated logic, integration tests for API contracts, and end-to-end tests for user workflows. Each layer has different tradeoffs in speed, confidence, and maintenance cost.

Unit Tests with Vitest

// packages/api/src/services/__tests__/task.service.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { taskService } from '../task.service';
import { taskRepository } from '../../repositories/task.repository';
import { AppError } from '../../middleware/error-handler';

vi.mock('../../repositories/task.repository', () => ({
  taskRepository: {
    findByProject: vi.fn(),
    findById: vi.fn(),
    create: vi.fn(),
    update: vi.fn(),
    delete: vi.fn(),
  },
}));

describe('taskService', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  describe('create', () => {
    it('should create a task with valid input', async () => {
      const mockTask = {
        id: 'task-1',
        title: 'Implement auth',
        status: 'todo',
        priority: 'high',
        projectId: 'proj-1',
        createdAt: new Date(),
        updatedAt: new Date(),
      };
      vi.mocked(taskRepository.create).mockResolvedValue(mockTask);

      const result = await taskService.create(
        'proj-1',
        { title: 'Implement auth', priority: 'high' },
        'user-1'
      );

      expect(result.title).toBe('Implement auth');
      expect(result.status).toBe('todo');
      expect(taskRepository.create).toHaveBeenCalledOnce();
    });

    it('should reject tasks with short titles', async () => {
      await expect(
        taskService.create('proj-1', { title: 'AB' }, 'user-1')
      ).rejects.toThrow(AppError);
    });

    it('should reject past due dates', async () => {
      const pastDate = '2020-01-01';
      await expect(
        taskService.create('proj-1', { title: 'Valid title', dueDate: pastDate }, 'user-1')
      ).rejects.toThrow('Due date cannot be in the past');
    });
  });

  describe('update', () => {
    it('should enforce valid status transitions', async () => {
      vi.mocked(taskRepository.findById).mockResolvedValue({
        id: 'task-1', status: 'todo',
      });

      await expect(
        taskService.update('task-1', { status: 'done' })
      ).rejects.toThrow('Cannot transition from todo to done');
    });

    it('should allow todo -> in_progress transition', async () => {
      vi.mocked(taskRepository.findById).mockResolvedValue({
        id: 'task-1', status: 'todo',
      });
      vi.mocked(taskRepository.update).mockResolvedValue({
        id: 'task-1', status: 'in_progress',
      });

      const result = await taskService.update('task-1', { status: 'in_progress' });
      expect(result.status).toBe('in_progress');
    });
  });
});

Frontend Component Tests

// packages/web/src/components/__tests__/TaskBoard.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TaskBoard } from '../TaskBoard';
import { server } from '../../test/mocks/server';
import { http, HttpResponse } from 'msw';

describe('TaskBoard', () => {
  it('should render tasks grouped by status', async () => {
    render(<TaskBoard projectId="proj-1" />);

    expect(screen.getByText(/loading/i)).toBeInTheDocument();

    await waitFor(() => {
      expect(screen.getByText('Implement auth')).toBeInTheDocument();
      expect(screen.getByText('Design system')).toBeInTheDocument();
    });

    const todoColumn = screen.getByTestId('column-todo');
    const inProgressColumn = screen.getByTestId('column-in_progress');
    expect(todoColumn).toBeInTheDocument();
    expect(inProgressColumn).toBeInTheDocument();
  });

  it('should open create task dialog on button click', async () => {
    const user = userEvent.setup();
    render(<TaskBoard projectId="proj-1" />);

    await user.click(screen.getByTestId('add-task-button'));

    expect(screen.getByRole('dialog')).toBeInTheDocument();
    expect(screen.getByLabelText(/task title/i)).toBeInTheDocument();
  });

  it('should display error state when API fails', async () => {
    server.use(
      http.get('/api/projects/:projectId/tasks', () => {
        return new HttpResponse(null, { status: 500 });
      })
    );

    render(<TaskBoard projectId="proj-1" />);

    await waitFor(() => {
      expect(screen.getByText(/failed to load tasks/i)).toBeInTheDocument();
    });
  });
});

Integration Tests with Supertest

// packages/api/src/__tests__/tasks.integration.test.ts
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import request from 'supertest';
import { createApp } from '../app';
import { prisma } from '../lib/prisma';
import { generateToken } from '../lib/auth';

const app = createApp();
let authToken: string;
let projectId: string;

beforeAll(async () => {
  const user = await prisma.user.create({
    data: { email: 'test@example.com', name: 'Test User' },
  });
  authToken = generateToken(user.id);

  const project = await prisma.project.create({
    data: { name: 'Test Project', ownerId: user.id },
  });
  projectId = project.id;
});

afterAll(async () => {
  await prisma.task.deleteMany();
  await prisma.project.deleteMany();
  await prisma.user.deleteMany();
});

describe('Tasks API', () => {
  it('POST /api/projects/:projectId/tasks - creates a task', async () => {
    const res = await request(app)
      .post(`/api/projects/${projectId}/tasks`)
      .set('Authorization', `Bearer ${authToken}`)
      .send({ title: 'New Task', priority: 'high' })
      .expect(201);

    expect(res.body.data).toMatchObject({
      title: 'New Task',
      priority: 'high',
      status: 'todo',
    });
  });

  it('GET /api/projects/:projectId/tasks - lists tasks with filters', async () => {
    await request(app)
      .post(`/api/projects/${projectId}/tasks`)
      .set('Authorization', `Bearer ${authToken}`)
      .send({ title: 'Another Task' });

    const res = await request(app)
      .get(`/api/projects/${projectId}/tasks`)
      .set('Authorization', `Bearer ${authToken}`)
      .query({ status: 'todo' })
      .expect(200);

    expect(res.body.data).toHaveLength(2);
    expect(res.body.data.every((t: any) => t.status === 'todo')).toBe(true);
  });

  it('PATCH /api/tasks/:taskId - validates status transitions', async () => {
    const createRes = await request(app)
      .post(`/api/projects/${projectId}/tasks`)
      .set('Authorization', `Bearer ${authToken}`)
      .send({ title: 'Transition Test' });

    const taskId = createRes.body.data.id;

    await request(app)
      .patch(`/api/tasks/${taskId}`)
      .set('Authorization', `Bearer ${authToken}`)
      .send({ status: 'in_progress' })
      .expect(200);

    const badRes = await request(app)
      .patch(`/api/tasks/${taskId}`)
      .set('Authorization', `Bearer ${authToken}`)
      .send({ status: 'done' })
      .expect(400);

    expect(badRes.body.code).toBe('INVALID_TRANSITION');
  });

  it('returns 401 without auth token', async () => {
    await request(app)
      .get(`/api/projects/${projectId}/tasks`)
      .expect(401);
  });
});

End-to-End Tests with Playwright

// e2e/tasks.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Task Management', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/login');
    await page.fill('[data-testid="email"]', 'user@example.com');
    await page.fill('[data-testid="password"]', 'securePassword123');
    await page.click('[data-testid="login-button"]');
    await page.waitForURL('/dashboard');
  });

  test('user can create a task and see it on the board', async ({ page }) => {
    await page.click('[data-testid="project-card"]');
    await expect(page.locator('[data-testid="task-board"]')).toBeVisible();

    await page.click('[data-testid="add-task-button"]');
    await page.fill('[data-testid="task-title-input"]', 'E2E Created Task');
    await page.fill('[data-testid="task-description-input"]', 'Created via Playwright');
    await page.selectOption('[data-testid="task-priority"]', 'high');
    await page.click('[data-testid="save-task"]');

    await expect(page.getByText('E2E Created Task')).toBeVisible();
  });

  test('user can drag task between columns', async ({ page }) => {
    await page.click('[data-testid="project-card"]');
    const taskCard = page.locator('[data-testid="task-card"]').first();
    const inProgressColumn = page.locator('[data-testid="column-in_progress"]');

    await taskCard.dragTo(inProgressColumn);

    await expect(
      inProgressColumn.locator('[data-testid="task-card"]').first()
    ).toBeVisible();
  });

  test('user can filter tasks by priority', async ({ page }) => {
    await page.click('[data-testid="project-card"]');
    await page.selectOption('[data-testid="priority-filter"]', 'high');

    const taskCards = page.locator('[data-testid="task-card"]');
    const count = await taskCards.count();
    for (let i = 0; i < count; i++) {
      await expect(taskCards.nth(i).locator('[data-testid="priority-badge"]')).toHaveText('high');
    }
  });
});

Run all tests with npm test (unit + integration) and npx playwright test (E2E). The combination of fast unit tests, realistic integration tests, and user-scenario E2E tests provides comprehensive coverage while maintaining a practical test suite.

Deployment and Monitoring

Deployment and Monitoring

CI/CD Pipeline with GitHub Actions

# .github/workflows/ci.yml
name: CI/CD Pipeline

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  lint-and-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v2
        with:
          version: 8
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'

      - run: pnpm install --frozen-lockfile
      - run: pnpm run lint
      - run: pnpm run typecheck
      - run: pnpm run test
        env:
          DATABASE_URL: postgresql://test:test@localhost:5432/test_db

  build-and-deploy:
    needs: lint-and-test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Deploy to production
        run: |
          echo "Deploying to production..."
          # Add actual deployment commands (Vercel, Railway, AWS, etc.)

Dockerfile for Production

# Dockerfile
FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@8 --activate
WORKDIR /app

# Install dependencies
FROM base AS deps
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY packages/api/package.json ./packages/api/
COPY packages/web/package.json ./packages/web/
COPY packages/shared/package.json ./packages/shared/
RUN pnpm install --frozen-lockfile

# Build shared packages and API
FROM deps AS builder
COPY packages/shared ./packages/shared
COPY packages/api ./packages/api
RUN pnpm --filter @myapp/shared build
RUN pnpm --filter @myapp/api build

# Production image for API
FROM node:20-alpine AS production
RUN corepack enable && corepack prepare pnpm@8 --activate
WORKDIR /app

COPY --from=builder /app/packages/shared/dist ./packages/shared/dist
COPY --from=builder /app/packages/shared/package.json ./packages/shared/
COPY --from=builder /app/packages/api/dist ./packages/api/dist
COPY --from=builder /app/packages/api/package.json ./packages/api/
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/packages/api/node_modules ./packages/api/node_modules

EXPOSE 3000

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1

CMD ["node", "packages/api/dist/server.js"]

Health Check and Monitoring Endpoints

// packages/api/src/routes/health.ts
import { Router } from 'express';
import { prisma } from '../lib/prisma';
import { redis } from '../lib/redis';

const healthRouter = Router();

interface HealthCheck {
  name: string;
  status: 'healthy' | 'unhealthy';
  latencyMs?: number;
  error?: string;
}

async function checkDatabase(): Promise<HealthCheck> {
  const start = Date.now();
  try {
    await prisma.$queryRaw`SELECT 1`;
    return { name: 'database', status: 'healthy', latencyMs: Date.now() - start };
  } catch (error) {
    return {
      name: 'database',
      status: 'unhealthy',
      latencyMs: Date.now() - start,
      error: error instanceof Error ? error.message : 'Unknown error',
    };
  }
}

async function checkRedis(): Promise<HealthCheck> {
  const start = Date.now();
  try {
    await redis.ping();
    return { name: 'redis', status: 'healthy', latencyMs: Date.now() - start };
  } catch (error) {
    return {
      name: 'redis',
      status: 'unhealthy',
      latencyMs: Date.now() - start,
      error: error instanceof Error ? error.message : 'Unknown error',
    }:
  }
}

healthRouter.get('/health', async (req, res) => {
  const checks = await Promise.all([checkDatabase(), checkRedis()]);
  const allHealthy = checks.every((c) => c.status === 'healthy');

  res.status(allHealthy ? 200 : 503).json({
    status: allHealthy ? 'healthy' : 'unhealthy',
    uptime: process.uptime(),
    timestamp: new Date().toISOString(),
    version: process.env.APP_VERSION || 'unknown',
    checks,
  });
});

healthRouter.get('/ready', async (req, res) => {
  const dbCheck = await checkDatabase();
  const ready = dbCheck.status === 'healthy';
  res.status(ready ? 200 : 503).json({ status: ready ? 'ready' : 'not ready' });
});

export { healthRouter };

Error Tracking and Logging

// packages/api/src/middleware/error-handler.ts
import { Request, Response, NextFunction } from 'express';
import * as Sentry from '@sentry/node';
import pino from 'pino';

export const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  transport:
    process.env.NODE_ENV === 'development'
      ? { target: 'pino-pretty', options: { colorize: true } }
      : undefined,
});

export class AppError extends Error {
  constructor(
    message: string,
    public statusCode: number = 500,
    public code: string = 'INTERNAL_ERROR',
    public details?: Record<string, string[]>
  ) {
    super(message);
    this.name = 'AppError';
  }
}

export function errorHandler(
  err: Error,
  req: Request,
  res: Response,
  _next: NextFunction
) {
  if (err instanceof AppError) {
    logger.warn({
      err,
      requestId: req.id,
      path: req.path,
      method: req.method,
    }, err.message);

    return res.status(err.statusCode).json({
      error: err.message,
      code: err.code,
      details: err.details,
    });
  }

  logger.error({
    err,
    requestId: req.id,
    path: req.path,
    method: req.method,
  }, 'Unhandled error');

  Sentry.captureException(err);

  res.status(500).json({
    error: 'Internal server error',
    code: 'INTERNAL_ERROR',
  });
}

Production Readiness Checklist

// packages/api/src/lib/startup.ts
import { logger } from '../middleware/error-handler';
import { prisma } from './prisma';
import { redis } from './redis';

export async function validateEnvironment() {
  const required = [
    'DATABASE_URL',
    'JWT_SECRET',
    'REDIS_URL',
    'SENTRY_DSN',
  ];

  const missing = required.filter((key) => !process.env[key]);
  if (missing.length > 0) {
    throw new Error(`Missing required env vars: ${missing.join(', ')}`);
  }
}

export async function gracefulShutdown(signal: string) {
  logger.info({ signal }, 'Received shutdown signal');

  await Promise.allSettled([
    prisma.$disconnect(),
    redis.quit(),
  ]);

  logger.info('All connections closed, exiting');
  process.exit(0);
}

export function registerShutdownHandlers() {
  const signals: NodeJS.Signals[] = ['SIGTERM', 'SIGINT'];
  for (const signal of signals) {
    process.on(signal, () => gracefulShutdown(signal));
  }
  process.on('unhandledRejection', (reason) => {
    logger.fatal({ reason }, 'Unhandled rejection');
    process.exit(1);
  });
  process.on('uncaughtException', (err) => {
    logger.fatal({ err }, 'Uncaught exception');
    process.exit(1);
  });
}

A complete deployment strategy includes automated CI/CD, containerized builds, health checks at multiple levels (liveness and readiness), structured logging with Pino, centralized error tracking with Sentry, and graceful shutdown handling that cleans up database and cache connections before exiting.

Quiz

1. Why use a monorepo with Turborepo instead of separate repositories for frontend and backend?

Question 1 options

2. What is the purpose of optimistic UI updates in the React task board hook?

Question 2 options

3. Why do status transitions on tasks need to be validated server-side rather than relying only on frontend validation?

Question 3 options

Flashcards

Question

What is Full Stack Project Build?

Answer

Full Stack Project Build covers important concepts and best practices.

Question

What is Full Stack Project Build?

Answer

Full Stack Project Build covers important concepts and best practices.

Question

What is Full Stack Project Build?

Answer

Full Stack Project Build covers important concepts and best practices.

Revision Notes

Key Takeaways

  • 1. Structure your monorepo with clear package boundaries: web (React), api (Express), and shared (types/validation) with Turborepo orchestrating builds
  • 2. Layer your backend as controller -> service -> repository to isolate HTTP handling from business logic from database access
  • 3. Use Zod schemas from the shared package to validate requests on both frontend and backend, catching errors early
  • 4. Test at three levels: unit tests for isolated logic, integration tests with Supertest for API contracts, and E2E tests with Playwright for user workflows
  • 5. Deploy with CI/CD pipelines that run lint, typecheck, and tests before building Docker images and deploying to production
  • 6. Implement health checks that verify database and cache connectivity, and configure graceful shutdown to clean up connections

Interview Tips

  • Explain how you would structure a full-stack project and why you chose that architecture over alternatives
  • Describe the testing pyramid for a full-stack app: which tests go where, and what each layer catches that others miss
  • Walk through how optimistic updates work and what problems they solve for user experience
  • Discuss how status machine transitions prevent invalid state changes and why this matters for data integrity
  • Explain the difference between liveness and readiness probes in health checks and when to use each
  • Describe a production incident caused by missing error handling and how you would prevent it

Cheat Sheet

Full-Stack Architecture: monorepo (Turborepo) with packages/web, packages/api, packages/shared. Backend layers: Controller (validation + HTTP) -> Service (business logic) -> Repository (Prisma queries). Frontend: React + SWR for data fetching with optimistic updates. Testing: Vitest (unit) + Supertest (integration) + Playwright (E2E). Deployment: GitHub Actions CI -> Docker build -> health checks (/health, /ready). Monitoring: Pino structured logging + Sentry error tracking. Key patterns: Repository pattern for data access, AppError class for typed errors, Zod schemas for shared validation, graceful shutdown handlers for clean connection cleanup.