Skip to content

System Architecture

Technical architecture and design patterns for the MMR Multi-Tenant SaaS Platform.

Table of Contents


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:

  1. Middleware (SubdomainMiddleware) - Runs before guards
  2. Guards (JwtAuthGuard, RolesGuard) - Authentication and authorization
  3. Interceptors (TenantContextInterceptor, ResponseInterceptor) - Before and after handler
  4. Handler (Controller method)
  5. Interceptors (ResponseInterceptor) - Transform response
  6. 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