Skip to content
advanced Phase 20 · Full Stack Capstone Project

Capstone Build & Launch

Implement, test, deploy, and monitor the capstone application in production.

3h
0 problems
Topic Progress 0%

Full Stack Implementation

Full Stack Implementation

With your architecture planned, it is time to build the complete application. A production-grade full stack project requires careful structuring of both frontend and backend code, proper database schema design, and robust API implementation.

Backend Structure

Organize your backend with a layered architecture. Each layer handles a specific responsibility:

// src/server.ts - Application entry point
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import { rateLimit } from 'express-rate-limit';
import { connectDB } from './config/database';
import { errorHandler } from './middleware/errorHandler';
import { authRouter } from './routes/auth';
import { apiRouter } from './routes/api';

const app = express();
const PORT = process.env.PORT || 3000;

// Security middleware
app.use(helmet());
app.use(cors({ origin: process.env.CLIENT_URL, credentials: true }));
app.use(rateLimit({ windowMs: 15 * 60 * 1000, max: 100 }));

// Body parsing and routing
app.use(express.json({ limit: '10mb' }));
app.use('/auth', authRouter);
app.use('/api', apiRouter);
app.use(errorHandler);

async function start() {
  await connectDB();
  app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
}

start();

Database Schema and Migrations

Define your data models using an ORM like Prisma or TypeORM. Always version your schema changes:

// prisma/schema.prisma
model User {
  id        String   @id @default(cuid())
  email     String   @unique
  name      String
  password  String
  role      Role     @default(USER)
  orders    Order[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

model Order {
  id        String   @id @default(cuid())
  userId    String
  user      User     @relation(fields: [userId], references: [id])
  items     OrderItem[]
  total     Decimal  @db.Decimal(10, 2)
  status    OrderStatus @default(PENDING)
  createdAt DateTime @default(now())
}

enum Role { USER ADMIN }
enum OrderStatus { PENDING PROCESSING SHIPPED DELIVERED }

Frontend Application

Build your React frontend with proper component composition and state management:

// src/features/orders/OrderList.tsx
import { useQuery } from '@tanstack/react-query';
import { fetchOrders } from '@/api/orders';
import { OrderCard } from './OrderCard';
import { Spinner } from '@/components/ui/Spinner';
import { ErrorMessage } from '@/components/ui/ErrorMessage';

export function OrderList() {
  const { data: orders, isLoading, error } = useQuery({
    queryKey: ['orders'],
    queryFn: fetchOrders,
    staleTime: 5 * 60 * 1000,
  });

  if (isLoading) return <Spinner />;
  if (error) return <ErrorMessage message="Failed to load orders" />;

  return (
    <div className="grid gap-4">
      {orders?.map(order => (
        <OrderCard key={order.id} order={order} />
      ))}
    </div>
  );
}

API Route Handlers

Implement RESTful endpoints with proper validation and error handling:

// src/routes/api.ts
import { Router } from 'express';
import { z } from 'zod';
import { authenticate } from '../middleware/auth';
import { OrderService } from '../services/OrderService';

const router = Router();
const orderService = new OrderService();

const CreateOrderSchema = z.object({
  items: z.array(z.object({
    productId: z.string().uuid(),
    quantity: z.number().int().positive(),
  })).min(1),
});

router.post('/orders', authenticate, async (req, res, next) => {
  try {
    const body = CreateOrderSchema.parse(req.body);
    const order = await orderService.createOrder(req.user.id, body.items);
    res.status(201).json(order);
  } catch (err) {
    next(err);
  }
});

router.get('/orders/:id', authenticate, async (req, res, next) => {
  try {
    const order = await orderService.getOrder(req.params.id, req.user.id);
    if (!order) return res.status(404).json({ error: 'Order not found' });
    res.json(order);
  } catch (err) {
    next(err);
  }
});

export { router as apiRouter };

This layered approach ensures separation of concerns, making your codebase maintainable and testable as it grows.

Automated Testing Strategy

Automated Testing Strategy

A well-tested application prevents regressions and gives confidence during deployments. Your testing strategy should cover three levels: unit tests for isolated logic, integration tests for component interactions, and end-to-end tests for complete user flows.

Unit Testing Services

Test business logic in isolation using mocks for external dependencies:

// src/services/__tests__/OrderService.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { OrderService } from '../OrderService';
import { prisma } from '../../config/database';

vi.mock('../../config/database', () => ({
  prisma: {
    order: { create: vi.fn(), findUnique: vi.fn() },
    user: { findUnique: vi.fn() },
  },
}));

describe('OrderService', () => {
  let service: OrderService;

  beforeEach(() => {
    vi.clearAllMocks();
    service = new OrderService();
  });

  it('should create an order with correct total calculation', async () => {
    const mockUser = { id: 'user-1', email: 'test@example.com' };
    const mockItems = [
      { productId: 'prod-1', quantity: 2, price: 29.99 },
      { productId: 'prod-2', quantity: 1, price: 49.99 },
    ];

    vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser);
    vi.mocked(prisma.order.create).mockResolvedValue({
      id: 'order-1', userId: 'user-1', total: 109.97, status: 'PENDING',
      createdAt: new Date(), updatedAt: new Date(),
    });

    const order = await service.createOrder('user-1', mockItems);

    expect(prisma.order.create).toHaveBeenCalledWith({
      data: {
        userId: 'user-1',
        total: 109.97,
        status: 'PENDING',
        items: {
          create: [
            { productId: 'prod-1', quantity: 2, unitPrice: 29.99 },
            { productId: 'prod-2', quantity: 1, unitPrice: 49.99 },
          ],
        },
      },
      include: { items: true },
    });
    expect(order.total).toBe(109.97);
  });

  it('should throw error when user does not exist', async () => {
    vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
    await expect(
      service.createOrder('invalid-user', [{ productId: 'prod-1', quantity: 1 }])
    ).rejects.toThrow('User not found');
  });
});

Integration Testing API Endpoints

Test the full request-response cycle with a real or in-memory database:

// src/__tests__/orders.integration.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { app } from '../server';
import { prisma } from '../config/database';
import { generateToken } from '../utils/jwt';

describe('Orders API', () => {
  let authToken: string;
  let userId: string;

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

  afterAll(async () => {
    await prisma.order.deleteMany();
    await prisma.user.deleteMany();
    await prisma.$disconnect();
  });

  it('POST /api/orders should create an order', async () => {
    const response = await request(app)
      .post('/api/orders')
      .set('Authorization', `Bearer ${authToken}`)
      .send({
        items: [{ productId: 'prod-1', quantity: 2 }],
      });

    expect(response.status).toBe(201);
    expect(response.body).toHaveProperty('id');
    expect(response.body.userId).toBe(userId);
    expect(response.body.status).toBe('PENDING');
  });

  it('GET /api/orders/:id should return order details', async () => {
    const createRes = await request(app)
      .post('/api/orders')
      .set('Authorization', `Bearer ${authToken}`)
      .send({ items: [{ productId: 'prod-1', quantity: 1 }] });

    const response = await request(app)
      .get(`/api/orders/${createRes.body.id}`)
      .set('Authorization', `Bearer ${authToken}`);

    expect(response.status).toBe(200);
    expect(response.body.items).toBeDefined();
  });

  it('should reject unauthenticated requests', async () => {
    const response = await request(app)
      .get('/api/orders/some-id');

    expect(response.status).toBe(401);
  });
});

End-to-End Testing with Playwright

Validate complete user journeys through the browser:

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

test.describe('Order Creation Flow', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/login');
    await page.fill('[data-testid="email"]', 'user@example.com');
    await page.fill('[data-testid="password"]', 'password123');
    await page.click('[data-testid="submit"]');
    await page.waitForURL('/dashboard');
  });

  test('should create an order and view confirmation', async ({ page }) => {
    await page.click('[data-testid="shop-link"]');
    await page.click('[data-testid="add-to-cart-prod-1"]');
    await page.click('[data-testid="view-cart"]');
    await page.click('[data-testid="checkout"]');
    await page.fill('[data-testid="address"]', '123 Main St');
    await page.click('[data-testid="place-order"]');

    await expect(page.locator('[data-testid="confirmation"]')).toBeVisible();
    await expect(page.locator('[data-testid="order-id"]')).not.toBeEmpty();
  });
});

Run all test suites as part of your CI pipeline before merging to the main branch.

CI/CD Pipeline Configuration

CI/CD Pipeline Configuration

Continuous integration and deployment automates quality checks and delivery. A well-configured pipeline runs tests, builds artifacts, and deploys to production on every merged pull request.

GitHub Actions Workflow

Define a complete pipeline that tests, builds, and deploys your application:

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

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

env:
  NODE_VERSION: '20'
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB: testdb
          POSTGRES_USER: testuser
          POSTGRES_PASSWORD: testpass
        ports: ['5432:5432']
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'
      - run: npm ci
      - run: npm run lint
      - run: npm run typecheck
      - run: npm test -- --coverage
        env:
          DATABASE_URL: postgresql://testuser:testpass@localhost:5432/testdb
      - uses: actions/upload-artifact@v4
        with:
          name: coverage
          path: coverage/

  build-and-push:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}

  deploy:
    needs: build-and-push
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
      - name: Deploy to production
        run: |
          echo "Deploying image ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}"
          # Add your deployment command here (e.g., kubectl, AWS ECS, Vercel)

Docker Configuration

Containerize your application for consistent deployment environments:

# Dockerfile
FROM node:20-alpine AS base
WORKDIR /app

FROM base AS deps
COPY package*.json ./
RUN npm ci --only=production

FROM base AS build
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM base AS production
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package*.json ./
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
USER nextjs
EXPOSE 3000
CMD ["node", "dist/server.js"]

Environment-Based Deployment

Use environment variables to manage configuration across stages:

// src/config/env.ts
import { z } from 'zod';

const envSchema = z.object({
  NODE_ENV: z.enum(['development', 'test', 'production']),
  DATABASE_URL: z.string().url(),
  REDIS_URL: z.string().url(),
  JWT_SECRET: z.string().min(32),
  CORS_ORIGIN: z.string().url(),
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']),
});

export const env = envSchema.parse(process.env);

This pipeline ensures every change is tested, validated, and deployed automatically while maintaining security and reproducibility across environments.

Monitoring and Observability

Monitoring and Observability

Once deployed, you need visibility into your application's health and performance. Monitoring, logging, and alerting form the three pillars of observability that help you detect and resolve issues before users are affected.

Structured Logging with Pino

Use structured logging for machine-parseable, searchable logs:

// src/utils/logger.ts
import pino from 'pino';
import { env } from '../config/env';

export const logger = pino({
  level: env.LOG_LEVEL,
  formatters: {
    level: (label) => ({ level: label }),
  },
  base: {
    pid: process.pid,
    service: 'capstone-api',
  },
});

// Usage in route handler
logger.info({ orderId: 'order-1', userId: 'user-1' }, 'Order created successfully');
logger.error({ err, requestId: req.id }, 'Request handler failed');

Health Check Endpoint

Expose a health endpoint for load balancers and orchestrators:

// src/routes/health.ts
import { Router } from 'express';
import { prisma } from '../config/database';
import { redis } from '../config/redis';

const router = Router();

router.get('/health', async (req, res) => {
  const checks = {
    database: false,
    redis: false,
    uptime: process.uptime(),
    timestamp: new Date().toISOString(),
  };

  try {
    await prisma.$queryRaw`SELECT 1`;
    checks.database = true;
  } catch (err) {
    logger.error({ err }, 'Database health check failed');
  }

  try {
    await redis.ping();
    checks.redis = true;
  } catch (err) {
    logger.error({ err }, 'Redis health check failed');
  }

  const healthy = checks.database && checks.redis;
  res.status(healthy ? 200 : 503).json({
    status: healthy ? 'healthy' : 'degraded',
    checks,
  });
});

export { router as healthRouter };

Prometheus Metrics

Collect quantitative metrics for performance analysis:

// src/middleware/metrics.ts
import { Registry, Counter, Histogram, Gauge } from 'prom-client';

export const register = new Registry();

export const httpRequestDuration = new Histogram({
  name: 'http_request_duration_seconds',
  help: 'Duration of HTTP requests in seconds',
  labelNames: ['method', 'route', 'status_code'],
  buckets: [0.01, 0.05, 0.1, 0.5, 1, 2, 5],
  registers: [register],
});

export const httpRequestTotal = new Counter({
  name: 'http_requests_total',
  help: 'Total number of HTTP requests',
  labelNames: ['method', 'route', 'status_code'],
  registers: [register],
});

export const activeConnections = new Gauge({
  name: 'active_connections',
  help: 'Number of active connections',
  registers: [register],
});

// Middleware to record metrics
export function metricsMiddleware(req, res, next) {
  const end = httpRequestDuration.startTimer();
  res.on('finish', () => {
    end({ method: req.method, route: req.route?.path || 'unknown', status_code: res.statusCode });
    httpRequestTotal.inc({ method: req.method, route: req.route?.path || 'unknown', status_code: res.statusCode });
  });
  next();
}

// Expose metrics endpoint
app.get('/metrics', async (req, res) => {
  res.set('Content-Type', register.contentType);
  res.end(await register.metrics());
});

Alerting Rules

Define alerts for critical conditions:

# alerting/prometheus-rules.yml
groups:
  - name: application
    rules:
      - alert: HighErrorRate
        expr: rate(http_requests_total{status_code=~"5.."}[5m]) > 0.05
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "High 5xx error rate detected"
          description: "Error rate is {{ $value }} per second"

      - alert: HighLatency
        expr: histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m])) > 2
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "High latency detected"
          description: "95th percentile latency is {{ $value }}s"

      - alert: DatabaseDown
        expr: up{job="postgres"} == 0
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "Database connection lost"

With structured logs, health checks, metrics, and alerts, you have complete visibility into your production application and can respond to incidents quickly.

Quiz

1. Why should you use structured logging instead of plain text logs in production?

Question 1 options

2. What is the correct order of a CI/CD pipeline for a full stack application?

Question 2 options

3. What does the health check endpoint verify in a typical full stack application?

Question 3 options

Flashcards

Question

What are the three levels of automated testing?

Answer

Unit tests verify isolated functions and methods, integration tests verify component interactions (API endpoints, database queries), and end-to-end tests simulate complete user journeys through the browser to validate the full application workflow.

Question

What is the purpose of a Docker multi-stage build?

Answer

A multi-stage build separates the build environment from the production runtime. Dependencies and source code used only during compilation are not included in the final image, resulting in a smaller, more secure production container with fewer attack surfaces.

Question

What are the three pillars of observability?

Answer

Logs provide detailed records of events, metrics provide quantitative measurements of system behavior (request rate, latency, error rate), and traces track the flow of requests across distributed services. Together they give complete visibility into application health.

Revision Notes

Key Takeaways

  • 1. Structure your backend with layered architecture: routes handle HTTP, services contain business logic, and repositories manage data access
  • 2. Write tests at three levels: unit tests for logic, integration tests for endpoints, and E2E tests for user flows
  • 3. CI/CD pipelines should lint, type-check, test, build, and deploy automatically on every merge to main
  • 4. Use structured logging, health checks, and Prometheus metrics to maintain production observability
  • 5. Containerize with multi-stage Docker builds to keep production images small and secure

Interview Tips

  • Explain your testing pyramid: many fast unit tests at the base, fewer integration tests in the middle, and minimal E2E tests at the top
  • Describe how you would set up a zero-downtime deployment strategy using rolling updates or blue-green deployments
  • Discuss trade-offs between logging everything (high storage cost) vs. logging too little (hard to debug) and how structured logging solves this
  • Walk through what happens when a production alert fires: detection, triage, investigation, mitigation, and post-mortem
  • Explain why environment variables should be validated at startup with a schema library like Zod rather than accessed directly

Cheat Sheet

Build: Layered backend architecture (routes → services → repositories) + React component composition + Zod schema validation. Test: Unit (Vitest + mocks) → Integration (supertest + test DB) → E2E (Playwright). CI/CD: GitHub Actions with lint → test → build → deploy stages + Docker multi-stage build. Monitor: Pino structured logs + health endpoint + Prometheus metrics + alerting rules for error rate, latency, and dependency failures.