Based on real-world patterns from FeedbackFlow
Duration: ~2 hours (with breaks)
Target Audience: Junior developers looking to build their first production application
What You'll Learn:
Before we begin, ensure you have:
Tools needed:
Time: 10-15 minutes
"Weeks of coding can save you hours of planning" - Unknown
Before writing any code, document what you're building:
## Project: FeedbackFlow
### What: Employee feedback management system
### Core Features:
- User authentication (Google OAuth + JWT)
- Multi-tenant organizations
- Performance review cycles
- Feedback submission & tracking
- Analytics dashboards
- Role-based access control (RBAC)
### Users:
- Employees (give/receive feedback)
- Managers (view team feedback)
- Admins (manage organization)
| Layer | Technology | Why |
|---|---|---|
| Frontend | React + TypeScript | Component-based, type-safe, huge ecosystem |
| Styling | Tailwind CSS | Rapid development, utility-first |
| State | Zustand | Simple, minimal boilerplate |
| Server State | TanStack Query | Caching, refetching, loading states |
| Backend | Express.js + TypeScript | Flexible, widely adopted, easy to learn |
| Database | PostgreSQL | ACID compliance, complex queries, full-text search |
| Auth | JWT + HttpOnly Cookies | Secure, XSS-protected |
| Testing | Jest + Playwright | Unit/Integration + E2E |
┌─────────────────────────────────────────────────────┐
│ Frontend (React) │
│ Pages → Stores (Zustand) → API Client (Axios) │
└─────────────────┬───────────────────────────────────┘
│ HTTP/JSON (Cookie Auth)
┌─────────────────▼───────────────────────────────────┐
│ Backend API (Express) │
│ Routes → Controllers → Services → Models │
└─────────────────┬───────────────────────────────────┘
│ SQL
┌─────────────────▼───────────────────────────────────┐
│ PostgreSQL Database │
└─────────────────────────────────────────────────────┘
Rule: Start simple, split later if needed
my-app/
├── backend/
│ ├── src/
│ │ ├── modules/ # Business domains
│ │ │ ├── auth/
│ │ │ ├── users/
│ │ │ └── feedback/
│ │ ├── shared/ # Shared utilities
│ │ └── server.ts # Entry point
│ └── tests/
├── frontend/
│ ├── src/
│ │ ├── pages/ # Route components
│ │ ├── components/ # Reusable UI
│ │ ├── stores/ # Zustand state
│ │ └── services/ # API calls
│ └── e2e/ # Playwright tests
├── database/
│ └── sql/ # Schema files
└── shared/ # Shared types
Each backend module follows the same pattern:
modules/[feature]/
├── routes/ # HTTP routing, middleware
├── controllers/ # Request handlers (thin!)
├── services/ # Business logic (core!)
├── models/ # Database queries
└── types/ # TypeScript interfaces
Key Principle: Keep controllers thin, put logic in services
Time: 10 minutes
| Tool | Version | Purpose |
|---|---|---|
| Node.js | v18+ (recommend v20 LTS) | JavaScript runtime |
| npm | Comes with Node.js | Package manager |
| Git | Latest | Version control |
| Docker | Latest | Database containers |
| VS Code | Latest | Code editor |
node --version # Should be v18+
npm --version # Should be v9+
git --version # Any recent version
docker --version # Any recent version
mkdir my-app && cd my-app
mkdir -p backend/src frontend database shared
cd backend
npm init -y
npm install typescript @types/node --save-dev
npx tsc --init
Edit backend/tsconfig.json:
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "node",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
# Core framework
npm install express cors helmet cookie-parser dotenv
# Database
npm install pg
# Authentication
npm install jsonwebtoken
# Validation
npm install zod
# TypeScript types
npm install -D @types/express @types/cors
npm install -D @types/cookie-parser @types/jsonwebtoken @types/pg
# Development tools
npm install -D nodemon ts-node
# Testing
npm install -D jest ts-jest @types/jest supertest @types/supertest
Add to backend/package.json:
{
"type": "module",
"scripts": {
"dev": "nodemon src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"test": "jest --config jest.config.cjs",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
}
}
cd ../frontend
npm create vite@latest . -- --template react-ts
npm install
# State management
npm install zustand @tanstack/react-query axios
# Routing and forms
npm install react-router-dom react-hook-form @hookform/resolvers zod
# UI
npm install tailwindcss postcss autoprefixer
npm install framer-motion lucide-react react-hot-toast
# Initialize Tailwind
npx tailwindcss init -p
Edit frontend/vite.config.ts:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 3003,
proxy: {
'/api': {
target: 'http://localhost:5000',
changeOrigin: true
}
}
}
});
Why proxy? Avoid CORS issues during development!
cd backend
npm run dev
# Server runs on http://localhost:5000
cd frontend
npm run dev
# App runs on http://localhost:3003
# Terminal 1
cd backend && npm run dev
# Terminal 2
cd frontend && npm run dev
Time: 15 minutes
Create database/docker-compose.yml:
version: '3.8'
services:
postgres:
image: postgres:15-alpine
container_name: my-app-postgres
environment:
POSTGRES_DB: myapp
POSTGRES_USER: myapp_user
POSTGRES_PASSWORD: myapp_password
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myapp_user -d myapp"]
interval: 10s
timeout: 5s
retries: 5
volumes:
postgres_data:
cd database
docker compose up -d
# Verify it's running
docker compose ps
# View logs if needed
docker compose logs -f postgres
docker compose exec postgres psql -U myapp_user -d myapp
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
-- ...
);
Why? Globally unique, no sequence conflicts, harder to guess
CREATE TABLE organization_members (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
UNIQUE(user_id, organization_id)
);
Why? Database enforces data integrity
-- Index frequently queried columns
CREATE INDEX idx_users_email ON users(email);
-- Composite index for common queries
CREATE INDEX idx_org_members_user_org
ON organization_members(user_id, organization_id);
Rule: Index columns used in WHERE and JOIN clauses
-- Every tenant-specific table includes organization_id
CREATE TABLE feedback (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
organization_id UUID NOT NULL REFERENCES organizations(id),
giver_id UUID NOT NULL REFERENCES users(id),
receiver_id UUID NOT NULL REFERENCES users(id),
content TEXT NOT NULL,
-- ...
);
-- Always filter by organization
SELECT * FROM feedback WHERE organization_id = $1;
database/sql/
├── 01_users.sql
├── 02_organizations.sql
├── 03_feedback.sql
└── 04_indexes.sql
// Track which migrations have run
async function migrate() {
// Create tracking table
await db.query(`
CREATE TABLE IF NOT EXISTS migrations (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
// Get executed migrations
const { rows } = await db.query('SELECT name FROM migrations');
const executed = new Set(rows.map(r => r.name));
// Run new migrations
for (const file of migrationFiles) {
if (!executed.has(file)) {
await db.query(readFile(file));
await db.query('INSERT INTO migrations (name) VALUES ($1)', [file]);
}
}
}
-- Users table
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
email VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
profile_picture TEXT,
role VARCHAR(50) DEFAULT 'employee',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Organizations table
CREATE TABLE organizations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(255) NOT NULL,
slug VARCHAR(100) UNIQUE NOT NULL,
settings JSONB DEFAULT '{}',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Link users to organizations
CREATE TABLE organization_members (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
role VARCHAR(50) DEFAULT 'employee',
is_active BOOLEAN DEFAULT true,
joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, organization_id)
);
-- Performance indexes
CREATE INDEX idx_org_members_user ON organization_members(user_id);
CREATE INDEX idx_org_members_org ON organization_members(organization_id);
Time: 20-25 minutes
Create backend/src/server.ts:
import 'dotenv/config';
import app from './app.js';
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => {
console.log(`🚀 Server running on http://localhost:${PORT}`);
});
// Graceful shutdown
process.on('SIGTERM', () => {
console.log('SIGTERM received, shutting down...');
process.exit(0);
});
Create backend/src/app.ts:
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import cookieParser from 'cookie-parser';
const app = express();
// Security middleware
app.use(helmet());
app.use(cors({
origin: process.env.FRONTEND_URL || 'http://localhost:3003',
credentials: true // Important for cookies!
}));
// Body parsing
app.use(express.json());
app.use(cookieParser());
// Health check
app.get('/api/v1/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date() });
});
export default app;
// Security headers (XSS, clickjacking, etc.)
app.use(helmet());
// CORS - allow frontend to call API
app.use(cors({
origin: 'http://localhost:3003',
credentials: true // Allow cookies!
}));
// Parse JSON request bodies
app.use(express.json());
// Parse cookies
app.use(cookieParser());
Order matters! Security middleware first.
Request Flow:
HTTP Request
│
▼
┌──────────────┐
│ Routes │ ← Define endpoints, apply middleware
└──────┬───────┘
│
▼
┌──────────────┐
│ Controllers │ ← Extract request data, call service
└──────┬───────┘
│
▼
┌──────────────┐
│ Services │ ← Business logic, validation, events
└──────┬───────┘
│
▼
┌──────────────┐
│ Models │ ← Database queries
└──────────────┘
Create backend/src/modules/users/types/user.types.ts:
export interface User {
id: string;
email: string;
name: string;
role: 'admin' | 'manager' | 'employee';
profilePicture?: string;
createdAt: Date;
updatedAt: Date;
}
export interface CreateUserDTO {
email: string;
name: string;
role?: 'admin' | 'manager' | 'employee';
}
export interface UpdateUserDTO {
name?: string;
role?: 'admin' | 'manager' | 'employee';
}
Create backend/src/modules/users/models/user.model.ts:
import { Pool } from 'pg';
import { User, CreateUserDTO } from '../types/user.types.js';
export class UserModel {
constructor(private db: Pool) {}
async findById(id: string): Promise<User | null> {
const { rows } = await this.db.query(
'SELECT * FROM users WHERE id = $1',
[id]
);
return rows[0] || null;
}
async findByEmail(email: string): Promise<User | null> {
const { rows } = await this.db.query(
'SELECT * FROM users WHERE email = $1',
[email]
);
return rows[0] || null;
}
}
Create backend/src/modules/users/services/user.service.ts:
import { UserModel } from '../models/user.model.js';
import { User, CreateUserDTO } from '../types/user.types.js';
export class UserService {
constructor(private userModel: UserModel) {}
async getUserById(id: string): Promise<User> {
const user = await this.userModel.findById(id);
if (!user) {
throw new NotFoundError('User not found');
}
return user;
}
async createUser(data: CreateUserDTO): Promise<User> {
// Business logic: check if email exists
const existing = await this.userModel.findByEmail(data.email);
if (existing) {
throw new ValidationError('Email already registered');
}
return this.userModel.create(data);
}
}
Create backend/src/modules/users/controllers/user.controller.ts:
import { Request, Response, NextFunction } from 'express';
import { UserService } from '../services/user.service.js';
export class UserController {
constructor(private userService: UserService) {}
// Arrow functions to preserve 'this' context
getUser = async (req: Request, res: Response, next: NextFunction) => {
try {
const user = await this.userService.getUserById(req.params.id);
res.json(user);
} catch (error) {
next(error); // Pass to error handler
}
};
createUser = async (req: Request, res: Response, next: NextFunction) => {
try {
const user = await this.userService.createUser(req.body);
res.status(201).json(user);
} catch (error) {
next(error);
}
};
}
Create backend/src/modules/users/routes/user.routes.ts:
import { Router } from 'express';
import { UserController } from '../controllers/user.controller.js';
import { authenticate } from '../../../shared/middleware/auth.middleware.js';
import { requireRole } from '../../../shared/middleware/rbac.middleware.js';
export function createUserRoutes(controller: UserController) {
const router = Router();
// GET /api/v1/users/:id - Get user by ID
router.get('/:id', authenticate, controller.getUser);
// POST /api/v1/users - Create user (admin only)
router.post('/', authenticate, requireRole(['admin']), controller.createUser);
return router;
}
app.ts:import { Pool } from 'pg';
import { UserModel } from './modules/users/models/user.model.js';
import { UserService } from './modules/users/services/user.service.js';
import { UserController } from './modules/users/controllers/user.controller.js';
import { createUserRoutes } from './modules/users/routes/user.routes.js';
// Database pool
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
// Dependency Injection (manual)
const userModel = new UserModel(pool);
const userService = new UserService(userModel);
const userController = new UserController(userService);
// Mount routes
app.use('/api/v1/users', createUserRoutes(userController));
For larger apps: Consider InversifyJS or TSyringe
Create backend/src/shared/utils/errors.ts:
export class AppError extends Error {
constructor(
public message: string,
public statusCode: number = 500
) {
super(message);
}
}
export class ValidationError extends AppError {
constructor(message: string) {
super(message, 400);
}
}
export class NotFoundError extends AppError {
constructor(message: string) {
super(message, 404);
}
}
export class ForbiddenError extends AppError {
constructor(message: string) {
super(message, 403);
}
}
Add to app.ts:
import { AppError } from './shared/utils/errors.js';
// Error handling middleware (must be last!)
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
console.error(err);
if (err instanceof AppError) {
return res.status(err.statusCode).json({
error: err.message
});
}
// Unknown errors
res.status(500).json({
error: 'Internal server error'
});
});
Controllers should never know about the database. Models should never know about HTTP.
Time: 20 minutes
Create frontend/src/lib/api.ts:
import axios from 'axios';
import toast from 'react-hot-toast';
const api = axios.create({
baseURL: '/api/v1',
withCredentials: true, // Send cookies with requests!
headers: {
'Content-Type': 'application/json'
}
});
// Response interceptor for global error handling
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
window.location.href = '/login';
} else {
toast.error(error.response?.data?.error || 'Something went wrong');
}
return Promise.reject(error);
}
);
export default api;
withCredentials: true?const api = axios.create({
withCredentials: true // This is critical!
});
credentials: true
Create frontend/src/stores/authStore.ts:
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import api from '../lib/api';
interface User {
id: string;
email: string;
name: string;
role: string;
}
interface AuthState {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
checkAuth: () => Promise<void>;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
isAuthenticated: false,
isLoading: true,
login: async (email, password) => {
const { data } = await api.post('/auth/login', { email, password });
set({ user: data.user, isAuthenticated: true });
},
logout: async () => {
await api.post('/auth/logout');
set({ user: null, isAuthenticated: false });
},
checkAuth: async () => {
try {
const { data } = await api.get('/auth/me');
set({ user: data, isAuthenticated: true, isLoading: false });
} catch {
set({ user: null, isAuthenticated: false, isLoading: false });
}
}
}),
{ name: 'auth-storage' }
)
);
Redux (100+ lines):
// actions.ts
// reducers.ts
// selectors.ts
// store.ts
// types.ts
Zustand (~30 lines):
const useStore = create((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 }))
}));
Create frontend/src/router.tsx:
import { createBrowserRouter } from 'react-router-dom';
import { ProtectedRoute } from './components/auth/ProtectedRoute';
import { Layout } from './components/layout/Layout';
import { LoginPage } from './pages/auth/LoginPage';
import { DashboardPage } from './pages/dashboard/DashboardPage';
export const router = createBrowserRouter([
{
path: '/login',
element: <LoginPage />
},
{
element: <ProtectedRoute />, // Auth guard
children: [
{
element: <Layout />, // Shared layout
children: [
{ path: '/', element: <DashboardPage /> },
{ path: '/profile', element: <ProfilePage /> }
]
}
]
}
]);
Create frontend/src/components/auth/ProtectedRoute.tsx:
import { Navigate, Outlet } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore';
export function ProtectedRoute() {
const { isAuthenticated, isLoading } = useAuthStore();
// Show loading while checking auth
if (isLoading) {
return (
<div className="flex items-center justify-center h-screen">
<div className="animate-spin h-8 w-8 border-4 border-blue-500
rounded-full border-t-transparent" />
</div>
);
}
// Redirect to login if not authenticated
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
// Render child routes
return <Outlet />;
}
import { Navigate, Outlet } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore';
export function AdminRouteGuard() {
const { user } = useAuthStore();
if (user?.role !== 'admin') {
return <Navigate to="/" replace />;
}
return <Outlet />;
}
// Usage in router:
{
element: <AdminRouteGuard />,
children: [
{ path: '/admin', element: <AdminPage /> }
]
}
frontend/src/
├── pages/ # Route-level components
│ ├── auth/
│ │ └── LoginPage.tsx
│ ├── dashboard/
│ │ └── DashboardPage.tsx
│ └── profile/
│ └── ProfilePage.tsx
├── components/ # Reusable components
│ ├── auth/
│ │ └── ProtectedRoute.tsx
│ ├── layout/
│ │ ├── Layout.tsx
│ │ └── Sidebar.tsx
│ └── ui/
│ ├── Button.tsx
│ └── Input.tsx
└── hooks/ # Custom hooks
└── useAuth.ts
Update frontend/src/main.tsx:
import React from 'react';
import ReactDOM from 'react-dom/client';
import { RouterProvider } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Toaster } from 'react-hot-toast';
import { router } from './router';
import './index.css';
const queryClient = new QueryClient();
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<Toaster position="top-right" />
</QueryClientProvider>
</React.StrictMode>
);
withCredentials: true for cookie authTime: 15 minutes
┌────────┐ ┌────────┐ ┌────────┐
│ Client │ │ Server │ │ DB │
└───┬────┘ └───┬────┘ └───┬────┘
│ │ │
│ 1. POST /auth/login │ │
│ { email, password } │ │
│────────────────────────────>│ │
│ │ 2. Verify credentials │
│ │────────────────────────────>│
│ │<────────────────────────────│
│ │ │
│ 3. Set-Cookie: token=JWT │ │
│ (HttpOnly, Secure) │ │
│<────────────────────────────│ │
│ │ │
│ 4. GET /api/data │ │
│ Cookie: token=JWT │ │
│────────────────────────────>│ │
│ │ 5. Verify JWT │
│ │ 6. Query data │
│ │────────────────────────────>│
│<────────────────────────────│<────────────────────────────│
Create backend/src/modules/auth/services/jwt.service.ts:
import jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
const JWT_EXPIRY = '7d';
export class JwtService {
sign(payload: object): string {
return jwt.sign(payload, JWT_SECRET, {
expiresIn: JWT_EXPIRY
});
}
verify(token: string): any {
return jwt.verify(token, JWT_SECRET);
}
}
Security Note: Never use default secret in production!
router.post('/login', async (req, res, next) => {
try {
const { email, password } = req.body;
// Verify user (simplified)
const user = await userService.verifyCredentials(email, password);
// Generate JWT
const token = jwtService.sign({
id: user.id,
email: user.email,
role: user.role
});
// Set HttpOnly cookie
res.cookie('token', token, {
httpOnly: true, // JavaScript can't access
secure: process.env.NODE_ENV === 'production', // HTTPS only
sameSite: 'strict', // CSRF protection
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
});
res.json({ user });
} catch (error) {
next(error);
}
});
| Flag | Purpose |
|---|---|
httpOnly: true |
Prevents XSS - JavaScript can't read the cookie |
secure: true |
Cookie only sent over HTTPS |
sameSite: 'strict' |
Prevents CSRF - cookie only sent to same site |
maxAge |
Token expiration time |
res.cookie('token', token, {
httpOnly: true, // ✅ Prevents XSS
secure: true, // ✅ HTTPS only
sameSite: 'strict', // ✅ Prevents CSRF
maxAge: 604800000 // ✅ 7 days
});
Create backend/src/shared/middleware/auth.middleware.ts:
import { Request, Response, NextFunction } from 'express';
import { JwtService } from '../../modules/auth/services/jwt.service.js';
const jwtService = new JwtService();
export function authenticate(req: Request, res: Response, next: NextFunction) {
const token = req.cookies.token;
if (!token) {
return res.status(401).json({ error: 'Authentication required' });
}
try {
const decoded = jwtService.verify(token);
(req as any).user = decoded; // Attach user to request
next();
} catch {
res.status(401).json({ error: 'Invalid or expired token' });
}
}
Create backend/src/shared/middleware/rbac.middleware.ts:
import { Request, Response, NextFunction } from 'express';
export function requireRole(allowedRoles: string[]) {
return (req: Request, res: Response, next: NextFunction) => {
const user = (req as any).user;
if (!user) {
return res.status(401).json({ error: 'Authentication required' });
}
if (!allowedRoles.includes(user.role)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
import { authenticate } from '../../../shared/middleware/auth.middleware.js';
import { requireRole } from '../../../shared/middleware/rbac.middleware.js';
const router = Router();
// Anyone authenticated can read
router.get('/feedback', authenticate, controller.list);
// Only employees and managers can create
router.post('/feedback',
authenticate,
requireRole(['employee', 'manager']),
controller.create
);
// Only admins can delete
router.delete('/feedback/:id',
authenticate,
requireRole(['admin']),
controller.delete
);
| Endpoint | Method | Auth | Purpose |
|---|---|---|---|
/auth/login |
POST | No | Login with credentials |
/auth/logout |
POST | Yes | Clear auth cookie |
/auth/me |
GET | Yes | Get current user |
/auth/refresh |
POST | Yes | Refresh token (optional) |
// Logout - just clear the cookie
router.post('/logout', (req, res) => {
res.clearCookie('token');
res.json({ success: true });
});
// Get current user
router.get('/me', authenticate, (req, res) => {
res.json((req as any).user);
});
Time: 20 minutes
/\
/ \
/ E2E \ Few - Critical user flows
/------\
/ \
/ Integr. \ Some - API endpoints
/------------\
/ \
/ Unit \ Many - Services, utils
/------------------\
| Type | Speed | Reliability | Maintenance |
|---|---|---|---|
| Unit | ⚡ Fast | ✅ Very reliable | ✅ Low |
| Integration | 🔄 Medium | 🔄 Reliable | 🔄 Medium |
| E2E | 🐢 Slow | ❌ Flaky | ❌ High |
backend/jest.config.cjs:module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/tests'],
testMatch: ['**/*.test.ts'],
moduleFileExtensions: ['ts', 'js', 'json'],
collectCoverageFrom: ['src/**/*.ts'],
coverageDirectory: 'coverage'
};
// tests/unit/users/services/user.service.test.ts
describe('UserService', () => {
let userService: UserService;
let mockUserModel: jest.Mocked<UserModel>;
beforeEach(() => {
// Create mock
mockUserModel = {
findById: jest.fn(),
findByEmail: jest.fn(),
create: jest.fn()
} as any;
userService = new UserService(mockUserModel);
});
describe('getUserById', () => {
it('should return user when found', async () => {
const mockUser = { id: '1', email: 'test@example.com' };
mockUserModel.findById.mockResolvedValue(mockUser);
const result = await userService.getUserById('1');
expect(result).toEqual(mockUser);
expect(mockUserModel.findById).toHaveBeenCalledWith('1');
});
});
});
describe('getUserById', () => {
it('should throw NotFoundError when user not found', async () => {
mockUserModel.findById.mockResolvedValue(null);
await expect(userService.getUserById('1'))
.rejects
.toThrow('User not found');
});
});
describe('createUser', () => {
it('should throw ValidationError when email exists', async () => {
mockUserModel.findByEmail.mockResolvedValue({ id: '1' });
await expect(
userService.createUser({ email: 'existing@test.com', name: 'Test' })
).rejects.toThrow('Email already registered');
});
});
// tests/integration/users/user.integration.test.ts
import request from 'supertest';
import app from '../../../src/app';
describe('User API', () => {
describe('POST /api/v1/users', () => {
it('should create a new user', async () => {
const response = await request(app)
.post('/api/v1/users')
.send({
email: 'new@example.com',
name: 'New User'
});
expect(response.status).toBe(201);
expect(response.body.email).toBe('new@example.com');
expect(response.body.id).toBeDefined();
});
it('should return 400 for invalid email', async () => {
const response = await request(app)
.post('/api/v1/users')
.send({ email: 'invalid', name: 'Test' });
expect(response.status).toBe(400);
});
});
});
describe('Protected Routes', () => {
let authToken: string;
beforeAll(async () => {
// Login to get token
const response = await request(app)
.post('/api/v1/auth/login')
.send({ email: 'test@example.com', password: 'password' });
// Extract cookie
authToken = response.headers['set-cookie'][0];
});
it('should access protected route with auth', async () => {
const response = await request(app)
.get('/api/v1/users/me')
.set('Cookie', authToken);
expect(response.status).toBe(200);
});
it('should reject without auth', async () => {
const response = await request(app)
.get('/api/v1/users/me');
expect(response.status).toBe(401);
});
});
frontend/playwright.config.ts:import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: 'html',
use: {
baseURL: 'http://localhost:3003',
trace: 'retain-on-failure',
screenshot: 'on'
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3003',
reuseExistingServer: !process.env.CI
}
});
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Authentication', () => {
test('should login successfully', async ({ page }) => {
await page.goto('/login');
await page.fill('[name="email"]', 'test@example.com');
await page.fill('[name="password"]', 'password');
await page.click('button[type="submit"]');
// Verify redirect to dashboard
await expect(page).toHaveURL('/');
await expect(page.locator('h1')).toContainText('Dashboard');
});
test('should show error for invalid credentials', async ({ page }) => {
await page.goto('/login');
await page.fill('[name="email"]', 'wrong@example.com');
await page.fill('[name="password"]', 'wrong');
await page.click('button[type="submit"]');
await expect(page.locator('.error-message')).toBeVisible();
});
});
backend/tests/
├── unit/ # Unit tests
│ ├── users/
│ │ ├── services/
│ │ │ └── user.service.test.ts
│ │ └── models/
│ │ └── user.model.test.ts
│ └── auth/
│ └── services/
│ └── jwt.service.test.ts
└── integration/ # Integration tests
├── users/
│ └── user.api.test.ts
└── auth/
└── auth.api.test.ts
frontend/
├── src/test/ # Component tests
│ └── components/
│ └── Button.test.tsx
└── e2e/ # E2E tests
├── auth.spec.ts
└── dashboard.spec.ts
# Backend - Run all tests
cd backend && npm test
# Backend - Watch mode
npm run test:watch
# Backend - Coverage report
npm run test:coverage
# Frontend - Unit tests
cd frontend && npm test
# Frontend - E2E tests
npm run test:e2e
# Frontend - E2E with UI
npm run test:e2e:ui
Time: 15 minutes
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Production stage
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
EXPOSE 5000
CMD ["node", "dist/server.js"]
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Production stage - Nginx
FROM nginx:alpine AS runner
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
# Stage 1: Build (large image with dev tools)
FROM node:20 AS builder
# Install deps, compile TypeScript
# Image size: ~1GB
# Stage 2: Run (small image, production only)
FROM node:20-alpine AS runner
# Only copy compiled code
# Image size: ~150MB
version: '3.8'
services:
postgres:
image: postgres:15-alpine
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
interval: 10s
timeout: 5s
retries: 5
backend:
build:
context: .
dockerfile: Dockerfile.backend
environment:
DATABASE_URL: postgresql://${DB_USER}:${DB_PASSWORD}@postgres:5432/${DB_NAME}
JWT_SECRET: ${JWT_SECRET}
depends_on:
postgres:
condition: service_healthy
frontend:
build:
context: .
dockerfile: Dockerfile.frontend
ports:
- "80:80"
depends_on:
- backend
volumes:
postgres_data:
| Platform | Pros | Cons |
|---|---|---|
| Railway | One-click deploy, free tier | Limited customization |
| Render | Simple, good free tier | Cold starts on free |
| Vercel | Great for frontend | Backend limitations |
| Heroku | Mature, many add-ons | No free tier anymore |
| Provider | Price | Pros |
|---|---|---|
| DigitalOcean | $6/mo | Simple, good docs |
| Linode | $5/mo | Reliable, affordable |
| Vultr | $5/mo | Global locations |
docker compose up -d
| Provider | Use Case |
|---|---|
| AWS EKS | Enterprise scale |
| GCP GKE | Google ecosystem |
| Azure AKS | Microsoft ecosystem |
Skip for MVP! Start simple, migrate later.
#!/bin/bash
set -e
echo "🚀 Starting deployment..."
# Pull latest code
git pull origin main
# Build images
docker compose -f docker-compose.prod.yml build
# Run database migrations
docker compose -f docker-compose.prod.yml \
run --rm backend npm run migrate
# Restart services with zero downtime
docker compose -f docker-compose.prod.yml up -d
# Wait for services to be healthy
sleep 10
# Health check
curl -f http://localhost/api/v1/health || exit 1
echo "✅ Deployment complete!"
Time: 10 minutes
Create .github/workflows/ci.yml:
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test-backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '20'
- run: cd backend && npm ci
- run: cd backend && npm run build
- run: cd backend && npm test
jobs:
test-backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with: { node-version: '20' }
- run: cd backend && npm ci
- run: cd backend && npm run build
- run: cd backend && npm test
test-frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with: { node-version: '20' }
- run: cd frontend && npm ci
- run: cd frontend && npm run type-check
- run: cd frontend && npm test
e2e-tests:
runs-on: ubuntu-latest
needs: [test-backend, test-frontend]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with: { node-version: '20' }
- run: cd frontend && npm ci
- run: npx playwright install --with-deps
- run: cd frontend && npm run test:e2e
import rateLimit from 'express-rate-limit';
// General API rate limit
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
message: { error: 'Too many requests, try again later' }
});
// Stricter limit for auth endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5, // Only 5 login attempts
message: { error: 'Too many login attempts' }
});
app.use('/api/', apiLimiter);
app.use('/api/v1/auth', authLimiter);
const logger = {
info: (message: string, meta?: object) => {
console.log(JSON.stringify({
level: 'info',
message,
...meta,
timestamp: new Date().toISOString()
}));
},
error: (message: string, error?: Error) => {
console.error(JSON.stringify({
level: 'error',
message,
stack: error?.stack,
timestamp: new Date().toISOString()
}));
}
};
// Usage
logger.info('User logged in', { userId: user.id });
logger.error('Database connection failed', error);
app.get('/api/v1/health', async (req, res) => {
const health = {
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
checks: {
database: 'unknown'
}
};
try {
await pool.query('SELECT 1');
health.checks.database = 'healthy';
} catch (error) {
health.status = 'degraded';
health.checks.database = 'unhealthy';
}
const statusCode = health.status === 'ok' ? 200 : 503;
res.status(statusCode).json(health);
});
| File | Purpose |
|---|---|
README.md |
Quick start, overview |
ARCHITECTURE.md |
System design, patterns |
SETUP.md |
Development setup |
docs/API_REFERENCE.md |
API endpoints |
CONTRIBUTING.md |
How to contribute |
Time: 5 minutes
Define requirements and architecture upfront. Weeks of coding can save hours of planning.
TypeScript catches bugs before runtime. Your IDE becomes your best friend.
Routes → Controllers → Services → Models. Separation of concerns improves testability.
Unit tests (many), Integration tests (some), E2E tests (few). Tests are your safety net.
HttpOnly cookies, parameterized queries, RBAC. Security is not an afterthought.
Future you will thank present you. README, ARCHITECTURE, API docs.
Set up CI/CD from day one. Deploy to staging frequently.
Start with a monolith, split later if needed. Boring technology over shiny new things.
# Development
cd backend && npm run dev # Backend on :5000
cd frontend && npm run dev # Frontend on :3003
# Database
cd database && docker compose up -d
# Testing
cd backend && npm test # Unit + Integration
cd frontend && npm test # Component tests
cd frontend && npm run test:e2e # E2E tests
# Build
cd backend && npm run build
cd frontend && npm run build
# Deploy
docker compose -f docker-compose.prod.yml up -d
ARCHITECTURE.md - System designSETUP.md - Development setupdocs/API_REFERENCE.md - API documentationARCHITECTURE.md
SETUP.md
backend/src/server.ts - Entry pointbackend/src/app.ts - Express setup + DIbackend/src/modules/ - Business modulesfrontend/src/main.tsx - Entry pointfrontend/src/router.tsx - Route definitionsfrontend/src/stores/ - Zustand storesdatabase/docker-compose.yml - PostgreSQL setupdatabase/sql/ - Schema files