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?
2. What is the correct order of a CI/CD pipeline for a full stack application?
3. What does the health check endpoint verify in a typical full stack application?
Flashcards
Question
What are the three levels of automated testing?
Click to reveal answer
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?
Click to reveal answer
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?
Click to reveal answer
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.