System Architecture¶
Technical architecture and design patterns for the MMR Multi-Tenant SaaS Platform.
Table of Contents¶
- High-Level Architecture
- Multi-Tenancy Architecture
- Backend Architecture
- Frontend Architecture
- Database Schema
- Authentication & Authorization
- Security Considerations
- API Design Patterns
- Caching Strategy
- Scalability Considerations
High-Level Architecture¶
System Components¶
┌─────────────────────────────────────────────────────────────────┐
│ Client Layer │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Browser │ │ Mobile │ │ Third-Party │ │
│ │ (Next.js) │ │ (Future) │ │ APIs │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ Frontend Layer │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Next.js 15 (App Router) │ │
│ │ - Server Components (RSC) │ │
│ │ - Client Components (Interactivity) │ │
│ │ - TanStack Query (Server State) │ │
│ │ - Zustand (Client State) │ │
│ └───────────────────────────────────────────────────────────┘ │
│ ↓ HTTP/REST │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ API Gateway │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ NestJS Backend │ │
│ │ - Controllers (REST Endpoints) │ │
│ │ - Services (Business Logic) │ │
│ │ - Guards (Auth & RBAC) │ │
│ │ - Interceptors (Logging, Response, Tenant Context) │ │
│ └───────────────────────────────────────────────────────────┘ │
│ ↓ ↓ │
└─────────────────────────────────────────────────────────────────┘
↓ ↓
┌──────────────────────────┐ ┌──────────────────────────┐
│ Data Layer (MySQL) │ │ Cache Layer (Redis) │
│ - Users │ │ - Sessions │
│ - Organizations │ │ - Auth Tokens │
│ - Tenants │ │ - Rate Limiting │
│ - Multi-tenant Data │ │ - Blacklist │
└──────────────────────────┘ └──────────────────────────┘
Component Responsibilities¶
| Component | Responsibility | Technology |
|---|---|---|
| Frontend | UI rendering, user interactions, client-side validation | Next.js 15, React 18, Tailwind CSS |
| Backend API | Business logic, data validation, authentication, authorization | NestJS 10, TypeScript |
| Database | Persistent data storage with ACID guarantees | MySQL 8.0 |
| Cache | Session storage, token blacklist, rate limiting | Redis 7.x |
| Monorepo | Shared types, schemas, UI components | Turborepo, PNPM |
Multi-Tenancy Architecture¶
Row-Level Isolation Strategy¶
The platform uses row-level tenant isolation with automatic context injection to prevent data leaks across organizations.
Key Components¶
1. TenantContext (AsyncLocalStorage)¶
Location: apps/backend/src/common/utils/tenant-context.ts
Purpose: Thread-safe storage of current request's tenant and user context.
export class TenantContext {
private static readonly asyncLocalStorage = new AsyncLocalStorage<TenantContextData>();
static run<T>(context: TenantContextData, callback: () => T): T {
return this.asyncLocalStorage.run(context, callback);
}
static getCurrentTenantId(): string | undefined {
return this.asyncLocalStorage.getStore()?.tenantId;
}
static getCurrentUserId(): string | undefined {
return this.asyncLocalStorage.getStore()?.userId;
}
}
Flow: 1. Request arrives 2. TenantContextInterceptor sets context from user token or subdomain 3. All subsequent code in that request can access tenant ID 4. Repository queries automatically filter by tenant ID
2. Subdomain Middleware¶
Location: apps/backend/src/common/middleware/subdomain.middleware.ts
Purpose: Extract organization from subdomain (e.g., acme.mmr.com → organizationId: 1)
@Injectable()
export class SubdomainMiddleware implements NestMiddleware {
constructor(
@InjectRepository(Organization)
private readonly orgRepository: Repository<Organization>,
) {}
async use(req: Request, res: Response, next: NextFunction) {
const host = req.headers.host || '';
const subdomain = host.split('.')[0];
if (subdomain && subdomain !== 'localhost') {
const org = await this.orgRepository.findOne({
where: { subdomain },
});
if (org) {
req['organizationId'] = org.id;
}
}
next();
}
}
3. TenantContextInterceptor¶
Location: apps/backend/src/common/interceptors/tenant-context.interceptor.ts
Purpose: Set tenant context from authenticated user or subdomain.
@Injectable()
export class TenantContextInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const user = request.user;
// Get organization ID from user (authenticated) or subdomain (public)
const organizationId = user?.organizationId || request.organizationId;
const userId = user?.id;
// Set tenant context for this request
return from(
TenantContext.run(
{
tenantId: organizationId?.toString(),
userId: userId?.toString(),
roles: user?.getRolesArray() || [],
},
() => next.handle().toPromise(),
),
);
}
}
4. TenantScopedRepository¶
Location: apps/backend/src/common/repositories/tenant-scoped.repository.ts
Purpose: Automatically add WHERE organizationId = :organizationId to all queries.
export class TenantScopedRepository<Entity> extends Repository<Entity> {
async find(options?: FindManyOptions<Entity>): Promise<Entity[]> {
const tenantId = TenantContext.getCurrentTenantId();
if (!tenantId) {
throw new Error('Tenant context is required for find operation');
}
return super.find({
...options,
where: {
...options?.where,
organizationId: parseInt(tenantId, 10),
},
});
}
// Similar overrides for findOne, findBy, count, etc.
createTenantQueryBuilder(alias: string): SelectQueryBuilder<Entity> {
const tenantId = TenantContext.getCurrentTenantId();
if (!tenantId) {
throw new Error('Tenant context is required for query builder');
}
return this.createQueryBuilder(alias).where(
`${alias}.organizationId = :organizationId`,
{ organizationId: parseInt(tenantId, 10) },
);
}
}
5. BaseEntity¶
Location: apps/backend/src/common/entities/base.entity.ts
Purpose: Base class for all entities with automatic tenant ID injection.
export abstract class BaseEntity {
@Column({ name: 'tenant_id', type: 'varchar', length: 36, nullable: true })
tenantId?: string;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
@Column({ name: 'created_by', nullable: true })
createdBy?: string;
@Column({ name: 'updated_by', nullable: true })
updatedBy?: string;
@BeforeInsert()
setTenantId() {
if (!this.tenantId) {
this.tenantId = TenantContext.getCurrentTenantId();
}
}
@BeforeUpdate()
preventCrossTenantModification() {
const currentTenantId = TenantContext.getCurrentTenantId();
if (this.tenantId && this.tenantId !== currentTenantId) {
throw new Error('Cannot modify entity from different tenant');
}
}
}
Request Flow (Tenant Isolation)¶
1. Request: GET /api/products
Host: acme.mmr.com
Authorization: Bearer <JWT>
2. SubdomainMiddleware
↓
Extracts "acme" from subdomain
Finds Organization where subdomain = 'acme'
Sets req['organizationId'] = 1
3. JwtAuthGuard
↓
Validates JWT token
Attaches user to req.user
user.organizationId = 1
4. TenantContextInterceptor
↓
Sets TenantContext:
{
tenantId: "1",
userId: "123",
roles: ["admin", "staff"]
}
5. Controller → Service → Repository
↓
Repository.find() automatically becomes:
SELECT * FROM products WHERE organizationId = 1
6. Response: Only products from Organization 1
Security Guarantees¶
- ✅ Automatic filtering: All queries filtered by organizationId
- ✅ Cross-tenant protection: Cannot modify entities from other tenants
- ✅ Context isolation: Each request has its own tenant context
- ✅ No data leaks: Impossible to query across tenants without explicit bypass
Learn More: apps/backend/docs/TENANT_ISOLATION.md
Backend Architecture¶
Module Structure¶
apps/backend/src/
├── main.ts # Application entry point
├── app.module.ts # Root module
├── common/ # Shared utilities
│ ├── entities/
│ │ └── base.entity.ts # Base entity with tenant isolation
│ ├── interceptors/
│ │ ├── tenant-context.interceptor.ts
│ │ └── response.interceptor.ts
│ ├── filters/
│ │ └── all-exceptions.filter.ts
│ ├── guards/
│ │ ├── jwt-auth.guard.ts
│ │ └── roles.guard.ts
│ ├── middleware/
│ │ └── subdomain.middleware.ts
│ ├── utils/
│ │ ├── tenant-context.ts
│ │ └── response-builder.ts
│ ├── repositories/
│ │ └── tenant-scoped.repository.ts
│ ├── logger/
│ │ └── winston.logger.ts
│ └── decorators/
│ ├── auth.decorator.ts
│ ├── current-user.decorator.ts
│ └── roles.decorator.ts
├── modules/ # Feature modules
│ ├── auth/
│ ├── users/
│ ├── organizations/
│ ├── tenants/
│ ├── cache/
│ ├── monitoring/
│ ├── dashboard/
│ ├── activity-logs/
│ └── system-metrics/
├── config/ # Configuration
│ ├── database.config.ts
│ └── data-source.ts # Migration data source
└── database/
└── migrations/ # TypeORM migrations
Layered Architecture¶
┌───────────────────────────────────────────────────────┐
│ Controller │
│ - REST endpoint definitions │
│ - Request/Response handling │
│ - DTO validation │
│ - Swagger documentation │
└───────────────────────────────────────────────────────┘
↓
┌───────────────────────────────────────────────────────┐
│ Service │
│ - Business logic │
│ - Orchestration of repositories │
│ - Transaction management │
│ - Error handling │
└───────────────────────────────────────────────────────┘
↓
┌───────────────────────────────────────────────────────┐
│ Repository │
│ - Database queries (CRUD) │
│ - Tenant-scoped queries │
│ - QueryBuilder operations │
│ - Automatic tenant filtering │
└───────────────────────────────────────────────────────┘
↓
┌───────────────────────────────────────────────────────┐
│ Entity │
│ - Table mapping (TypeORM) │
│ - Relationships │
│ - Lifecycle hooks │
│ - Tenant context validation │
└───────────────────────────────────────────────────────┘
Common Utilities¶
ResponseBuilder¶
Location: apps/backend/src/common/utils/response-builder.ts
Standardizes all API responses:
export class ResponseBuilder {
static success<T>(
data: T,
message: string = 'Operation successful',
): ApiResponse<T> {
return { success: true, data, message };
}
static error(
error: string,
message: string = 'Operation failed',
statusCode: number = 500,
): ApiErrorResponse {
return { success: false, error, message, statusCode };
}
static paginated<T>(
data: T[],
page: number,
limit: number,
total: number,
message: string = 'Data retrieved successfully',
): PaginatedResponse<T> {
return {
success: true,
data,
message,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
};
}
}
Global Exception Filter¶
Location: apps/backend/src/common/filters/all-exceptions.filter.ts
Catches and formats all errors:
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message = 'Internal server error';
if (exception instanceof HttpException) {
status = exception.getStatus();
const exceptionResponse = exception.getResponse();
message = typeof exceptionResponse === 'string'
? exceptionResponse
: (exceptionResponse as any).message;
}
response.status(status).json(
ResponseBuilder.error(exception.message, message, status),
);
}
}
Middleware & Interceptors¶
Execution Order:
- Middleware (SubdomainMiddleware) - Runs before guards
- Guards (JwtAuthGuard, RolesGuard) - Authentication and authorization
- Interceptors (TenantContextInterceptor, ResponseInterceptor) - Before and after handler
- Handler (Controller method)
- Interceptors (ResponseInterceptor) - Transform response
- Filters (AllExceptionsFilter) - If error occurs
Guards & Decorators¶
@Auth() Decorator¶
Combines JWT authentication and role-based authorization:
export const Auth = (...roles: string[]) => {
return applyDecorators(
UseGuards(JwtAuthGuard, RolesGuard),
Roles(...roles),
);
};
// Usage:
@Auth() // Any authenticated user
@Auth('admin') // Admin only
@Auth('admin', 'staff') // Admin or staff
@CurrentUser() Decorator¶
Extracts current user from request:
export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext): User => {
const request = ctx.switchToHttp().getRequest();
return request.user;
},
);
// Usage:
async getProfile(@CurrentUser() user: User) {
return user;
}
Frontend Architecture¶
Next.js App Router Structure¶
apps/web/src/
├── app/ # Next.js 15 App Router
│ ├── layout.tsx # Root layout (global)
│ ├── page.tsx # Home page (/)
│ ├── (auth)/ # Route group (no path)
│ │ ├── login/
│ │ │ └── page.tsx # /login
│ │ └── signup/
│ │ └── page.tsx # /signup
│ ├── dashboard/
│ │ ├── layout.tsx # Dashboard layout
│ │ ├── page.tsx # /dashboard
│ │ └── products/
│ │ ├── page.tsx # List view
│ │ ├── create/
│ │ │ └── page.tsx # Create page
│ │ └── [id]/
│ │ ├── page.tsx # Detail view
│ │ └── edit/
│ │ └── page.tsx # Edit page
│ └── api/ # API routes (Next.js)
│ └── auth/
│ └── [...nextauth]/
│ └── route.ts # NextAuth handler
├── components/ # Shared components
│ ├── ui/ # Shadcn/UI components
│ │ ├── button.tsx
│ │ ├── form.tsx
│ │ └── table.tsx
│ └── common/ # Custom components
│ ├── Navbar.tsx
│ └── Sidebar.tsx
├── modules/ # Feature modules
│ ├── auth/
│ ├── dashboard/
│ └── products/
├── services/ # API client services
│ ├── auth.service.ts
│ └── products.service.ts
├── hooks/ # Custom React hooks
│ ├── useAuth.ts
│ └── useProducts.ts
├── stores/ # Zustand stores
│ ├── authStore.ts
│ └── uiStore.ts
├── lib/ # Utilities
│ ├── api-client.ts
│ ├── utils.ts
│ └── cn.ts # Tailwind class merging
└── types/ # Local types
└── index.ts
State Management Strategy¶
Server State (TanStack Query v5)¶
For data fetched from backend API:
// hooks/useProducts.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { productsApi } from '@/services/products.service';
export function useProducts(params?: ProductQueryParams) {
return useQuery({
queryKey: ['products', params],
queryFn: () => productsApi.getAll(params),
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
export function useCreateProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateProductDto) => productsApi.create(data),
onSuccess: () => {
// Invalidate and refetch
queryClient.invalidateQueries({ queryKey: ['products'] });
},
});
}
Client State (Zustand)¶
For UI state, user preferences, temporary data:
// stores/authStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface AuthState {
user: User | null;
tokens: Tokens | null;
setUser: (user: User) => void;
setTokens: (tokens: Tokens) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
tokens: null,
setUser: (user) => set({ user }),
setTokens: (tokens) => set({ tokens }),
logout: () => set({ user: null, tokens: null }),
}),
{ name: 'auth-storage' },
),
);
API Client Integration¶
Location: packages/api-client/src/index.ts
Shared Axios instance with interceptors:
import axios from 'axios';
export const apiClient = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api',
timeout: 10000,
});
// Request interceptor: Add auth token
apiClient.interceptors.request.use((config) => {
const tokens = localStorage.getItem('auth-storage');
if (tokens) {
const { accessToken } = JSON.parse(tokens).state.tokens;
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
});
// Response interceptor: Handle errors and token refresh
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
// Attempt token refresh
try {
const refreshToken = getRefreshToken();
const { data } = await axios.post('/auth/refresh', { refreshToken });
setTokens(data.tokens);
// Retry original request
return apiClient(error.config);
} catch (refreshError) {
// Redirect to login
window.location.href = '/login';
}
}
return Promise.reject(error);
},
);
Shared Packages¶
@mmr/types¶
Shared TypeScript interfaces:
// packages/types/src/auth/index.ts
export interface User {
id: number;
email: string;
firstName: string;
lastName: string;
organizationId: number;
roles: string[];
}
export interface AuthResponse {
user: User;
organization: Organization;
tokens: Tokens;
}
@mmr/schemas¶
Shared Zod validation schemas:
// packages/schemas/src/auth/login.schema.ts
import { z } from 'zod';
export const loginSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
});
export type LoginDto = z.infer<typeof loginSchema>;
Database Schema¶
Entity Relationship Diagram¶
┌─────────────────────┐
│ Organization │
│─────────────────────│
│ id (PK) │
│ name │
│ subdomain (unique) │
│ sponsor_id │
│ rep_id │
└─────────────────────┘
│
│ 1:N (has many)
│
↓
┌─────────────────────┐
│ User │
│─────────────────────│
│ id (PK) │
│ email │
│ password_hash │
│ first_name │
│ last_name │
│ organization_id (FK)│
│ roles │
│ status │
│ last_login_time │
└─────────────────────┘
User Table¶
CREATE TABLE `user` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
`email` VARCHAR(255) NOT NULL,
`password_hash` VARCHAR(255) NOT NULL,
`first_name` VARCHAR(40) NOT NULL,
`last_name` VARCHAR(40) NOT NULL,
`username` VARCHAR(255) NULL,
`roles` VARCHAR(40) NOT NULL DEFAULT '[c]', -- "[a][s][c]" format
`status` SMALLINT NOT NULL DEFAULT 10, -- 10 = active
`organization_id` INT UNSIGNED NOT NULL,
`advisor_id` INT NULL,
`sponsor_id` INT NULL,
`created_at` INT NOT NULL, -- Unix timestamp
`updated_at` INT NOT NULL,
`last_login_time` TIMESTAMP NULL,
INDEX `idx_organization_id` (`organization_id`),
INDEX `idx_email` (`email`),
UNIQUE INDEX `unique_email_org` (`email`, `organization_id`),
CONSTRAINT `fk_user_organization`
FOREIGN KEY (`organization_id`)
REFERENCES `organization` (`id`)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
Organization Table¶
CREATE TABLE `organization` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
`name` VARCHAR(256) NOT NULL,
`sponsor_id` INT NOT NULL,
`rep_id` INT NOT NULL,
`bcc_audit_email_address` VARCHAR(255) NULL,
`details` TEXT NULL,
`subdomain` VARCHAR(50) NULL,
UNIQUE INDEX `unique_subdomain` (`subdomain`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
Tenant Table (Future/Alternative)¶
CREATE TABLE `tenants` (
`id` VARCHAR(36) NOT NULL PRIMARY KEY, -- UUID
`name` VARCHAR(255) NOT NULL,
`subdomain` VARCHAR(100) NOT NULL,
`is_active` BOOLEAN NOT NULL DEFAULT TRUE,
`settings` JSON NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE INDEX `unique_tenant_name` (`name`),
UNIQUE INDEX `unique_tenant_subdomain` (`subdomain`),
INDEX `idx_is_active` (`is_active`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
Indexes¶
Critical Indexes: - organization_id on all tenant-scoped tables - email and (email, organization_id) on user table - subdomain on organization table - status for soft deletes
Composite Indexes:
-- Efficient user lookup by email within organization
CREATE INDEX idx_user_org_email ON user(organization_id, email);
-- Active users only
CREATE INDEX idx_user_org_status ON user(organization_id, status);
Authentication & Authorization¶
JWT Flow¶
1. Login Request
POST /api/auth/login
Body: { email, password }
2. Backend Validates
↓
- Find user by email + organizationId
- Verify password (bcrypt)
- Check status === 10 (active)
- Generate JWT tokens
3. JWT Payload
{
sub: 123, // userId (int)
email: "user@example.com",
organizationId: 1, // tenant ID
jti: "unique-token-id", // for blacklist
iat: 1640995200,
exp: 1640996100 // 15 min expiry
}
4. Response
{
user: { ... },
organization: { ... },
tokens: {
accessToken: "eyJhbGc...", // 15 min
refreshToken: "eyJhbGc..." // 7 days
}
}
5. Client Stores Tokens
- localStorage (auth-storage)
- Sent in Authorization header
6. Subsequent Requests
Authorization: Bearer <accessToken>
↓
JwtAuthGuard validates token
↓
Attaches user to req.user
Session Caching (Redis)¶
// On login:
// Key: org:{orgId}:session:{userId}
// Value: { userId, email, organizationId, roles, organization, lastActivity }
// TTL: 3600 seconds (1 hour, sliding window)
await cacheService.set(
organizationId,
`session:${userId}`,
{
userId,
email,
organizationId,
roles: user.getRolesArray(),
organization,
lastActivity: Date.now(),
},
3600, // 1 hour TTL
);
// On GET /api/auth/me:
const cachedSession = await cacheService.get(orgId, `session:${userId}`);
if (cachedSession) {
return cachedSession; // Ultra-fast response <10ms
}
Token Blacklist¶
// On logout:
// Key: system:auth:blacklist:{jti}
// Value: true
// TTL: 900 seconds (15 min = access token expiry)
await cacheService.setSystem(`auth:blacklist:${jti}`, true, 900);
// JWT Strategy checks blacklist:
const isBlacklisted = await cacheService.getSystem(`auth:blacklist:${jti}`);
if (isBlacklisted) {
throw new UnauthorizedException('Token has been revoked');
}
Refresh Token Rotation¶
// On /api/auth/refresh:
// 1. Validate old refresh token
// 2. Delete old refresh token from Redis
// 3. Generate NEW access token + NEW refresh token
// 4. Store new refresh token in Redis
// 5. Return both tokens
// Key: system:auth:refresh:{userId}:{jti}
// Value: true
// TTL: 604800 seconds (7 days)
RBAC Implementation¶
Role Format: Stored as "[a][s][c]" in database, parsed to array.
// User entity method
getRolesArray(): string[] {
if (!this.roles) return [];
const roleMap = {
'[a]': 'admin',
'[s]': 'staff',
'[c]': 'client',
'[su]': 'superuser',
'[co]': 'coordinator',
};
return Object.entries(roleMap)
.filter(([key]) => this.roles.includes(key))
.map(([, value]) => value);
}
RolesGuard Implementation:
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.get<string[]>('roles', context.getHandler());
if (!requiredRoles) return true;
const request = context.switchToHttp().getRequest();
const user = request.user;
// OR logic: User must have at least one required role
return requiredRoles.some(role => user.getRolesArray().includes(role));
}
}
Security Considerations¶
CSRF Protection¶
// main.ts
import * as csurf from 'csurf';
app.use(csurf({
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
},
}));
// Get CSRF token endpoint
@Get('auth/csrf-token')
getCsrfToken(@Req() req: Request) {
return { csrfToken: req.csrfToken() };
}
Rate Limiting¶
// Global rate limiting
@Module({
imports: [
ThrottlerModule.forRoot({
ttl: 60, // 60 seconds
limit: 100, // 100 requests per minute
}),
],
})
// Login-specific rate limiting
async checkRateLimit(email: string): Promise<void> {
const key = `auth:failed:${email}`;
const attempts = await this.cacheService.getSystem(key);
if (attempts && attempts.count >= 5) {
const blockedUntil = new Date(attempts.blockedUntil);
if (blockedUntil > new Date()) {
throw new BadRequestException('Too many failed attempts. Try again later.');
}
}
}
Input Validation¶
// DTO with class-validator
import { IsEmail, IsString, MinLength, MaxLength } from 'class-validator';
export class CreateProductDto {
@IsString()
@MinLength(3)
@MaxLength(255)
@ApiProperty({ example: 'iPhone 15 Pro' })
name: string;
@IsNumber()
@Min(0)
@ApiProperty({ example: 999.99 })
price: number;
@IsString()
@Length(5, 50)
@ApiProperty({ example: 'PROD-001' })
sku: string;
}
Password Hashing¶
import * as bcrypt from 'bcrypt';
// Hash password (12 rounds)
const passwordHash = await bcrypt.hash(password, 12);
// Verify password
const isValid = await bcrypt.compare(password, user.passwordHash);
Security Headers (Helmet)¶
import helmet from 'helmet';
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
scriptSrc: ["'self'"],
imgSrc: ["'self'", 'data:', 'https:'],
},
},
hsts: {
maxAge: 31536000,
includeSubDomains: true,
},
}));
API Design Patterns¶
Standardized Response Format¶
// Success
{
"success": true,
"data": { ... },
"message": "Operation successful"
}
// Paginated
{
"success": true,
"data": [ ... ],
"message": "Products retrieved successfully",
"pagination": {
"page": 1,
"limit": 10,
"total": 100,
"totalPages": 10
}
}
// Error
{
"success": false,
"error": "NotFoundException",
"message": "Product not found",
"statusCode": 404
}
Pagination¶
// Query DTO
export class PaginationQueryDto {
@IsOptional()
@IsInt()
@Min(1)
@Type(() => Number)
page?: number = 1;
@IsOptional()
@IsInt()
@Min(1)
@Max(100)
@Type(() => Number)
limit?: number = 10;
}
// Service implementation
async findAll(query: PaginationQueryDto) {
const [data, total] = await this.repository.findAndCount({
skip: (query.page - 1) * query.limit,
take: query.limit,
});
return ResponseBuilder.paginated(data, query.page, query.limit, total);
}
Error Handling¶
// Service throws exceptions
throw new NotFoundException(`Product with ID ${id} not found`);
throw new BadRequestException('Invalid product data');
throw new UnauthorizedException('Invalid credentials');
// AllExceptionsFilter catches and formats
{
"success": false,
"error": "NotFoundException",
"message": "Product with ID 999 not found",
"statusCode": 404
}
Caching Strategy¶
Redis Key Structure¶
System-level (global):
- system:auth:blacklist:{jti} → true (TTL: 900s)
- system:auth:refresh:{userId}:{jti} → true (TTL: 604800s)
- system:auth:failed:{email} → { count, blockedUntil } (TTL: 900s)
Organization-scoped:
- org:{orgId}:session:{userId} → { user, org, roles } (TTL: 3600s)
- org:{orgId}:cache:{key} → value (custom TTL)
Cache TTLs¶
| Data Type | TTL | Reason |
|---|---|---|
| Session | 3600s (1h) | Balance freshness vs performance |
| Access Token Blacklist | 900s (15m) | Match token expiry |
| Refresh Token | 604800s (7d) | Match token expiry |
| Failed Login Attempts | 900s (15m) | Temporary lockout |
Cache Invalidation¶
// On user update
await this.cacheService.del(organizationId, `session:${userId}`);
// On logout
await this.cacheService.del(organizationId, `session:${userId}`);
await this.cacheService.delSystem(`auth:refresh:${userId}:${jti}`);
await this.cacheService.setSystem(`auth:blacklist:${jti}`, true, 900);
Scalability Considerations¶
Horizontal Scaling¶
Stateless Backend: - No in-memory sessions (Redis stores all state) - Load balancer can route to any instance - Auto-scaling based on CPU/memory
Configuration:
# docker-compose.yml (multiple backend instances)
services:
backend-1:
image: mmr-backend
ports:
- "3001:3001"
backend-2:
image: mmr-backend
ports:
- "3002:3001"
nginx:
image: nginx
# Load balance between backend-1 and backend-2
Database Optimization¶
Connection Pooling:
// config/database.config.ts
export const databaseConfig = {
type: 'mysql',
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT, 10),
database: process.env.DB_NAME,
extra: {
connectionLimit: 10, // Max connections per instance
},
};
Read Replicas (Future): - Master: Writes - Replicas: Reads (sessions, analytics)
Redis Clustering¶
For high-traffic scenarios:
// Redis Cluster config
const redis = new Redis.Cluster([
{ host: 'redis-node-1', port: 6379 },
{ host: 'redis-node-2', port: 6379 },
{ host: 'redis-node-3', port: 6379 },
]);
CDN for Static Assets¶
- Next.js static files served by CDN (Cloudflare, AWS CloudFront)
- Image optimization with next/image
- Gzip/Brotli compression
Version: 2.0.0 | Last Updated: 2026-06-20