Reuse RAD-System Framework

Building Enterprise-Grade APIs with Abstraction, Security, and Scalability

If n8n orchestrates the workflow and FastAPI handles the heavy NLP lifting, then NestJS is the guardian at the gate. It's the backend that authenticates users, validates permissions, manages state, and orchestrates every interaction between your frontend, n8n workflows, and data layer.

But here's the catch: building a secure, maintainable backend isn't just about validating JWTs or hashing passwords. It's about doing it consistently, repeatably, and without reinventing the wheel.

This is where the RAD-System framework comes in—a set of architectural patterns and base classes that turns common enterprise patterns into reusable abstractions. This article is the story of how I use it to build a backend that scales with confidence.

The Backend Layer in the RAG Stack

Let's contextualize NestJS within the broader RAG system:


┌─────────────────────────────────────────────────────────────┐
│                  Angular Frontend (rag-fe)                  │
│         - Chat interface, admin dashboards, login           │
└─────────────────┬───────────────────────────────────────────┘
                  │ REST API + JWT Auth
┌─────────────────▼───────────────────────────────────────────┐
│              NestJS Backend (rag-be) 🔒                      │
│   - Auth, RBAC, CRUD services, webhook triggers, proxying   │
└─────────────────┬───────────────────────────────────────────┘
                  │
        ┌─────────┴─────────┬─────────────────┐
        │                   │                 │
    ┌───▼──┐          ┌─────▼─────┐    ┌────▼─────┐
    │ n8n  │          │ FastAPI   │    │PostgreSQL│
    │      │          │ (NLP)     │    │(metadata)│
    │      │          │           │    │          │
    └───┬──┘          └─────┬─────┘    └────┬─────┘
        │                   │                │
        └─────────────┬─────┴────────────────┘
                      │
                  ┌───▼────┐
                  │ Qdrant  │
                  │ (vectors)
                  └────────┘

The NestJS backend is where:

  • Users authenticate and receive JWT tokens
  • Permissions are validated on every request
  • Documents are tagged with ownership and context
  • Webhook calls from n8n are validated and secured
  • API responses are formatted consistently
  • Errors are handled gracefully
  • Change logs are maintained for audit trails

Why RAD-System Framework?

Enterprise backends solve the same problems repeatedly:

Problem 1: DRY Services

Without abstraction, every service repeats: find, find all, create, update, delete, remove. That's 6 functions × 20 entities = 120 mostly identical methods.

Problem 2: Ownership Checks

Every action needs to verify: "Does this user own this resource?" Whether it's documents, chats, or contexts, the pattern is identical but easy to get wrong.

Problem 3: Password Hashing

bcrypt hashing is straightforward but gets reimplemented in every service. Forget to hash once, you've compromised security.

Problem 4: Soft Deletes

Audit trails require soft deletes (marking deleted_at instead of removing records). But not every system needs it, and it's easy to accidentally query hard-deleted records.

The Solution: BaseEntity and BaseService

RAD-System provides base classes that handle these patterns:

BaseEntity: Timestamps, IDs, and Audit Trails


abstract class BaseEntity implements IBase {
    @ApiProperty()
    @CreateDateColumn({ name: 'created_at', type: 'timestamp' })
    createdAt: Date;

    @ApiProperty()
    @UpdateDateColumn({ name: 'updated_at', type: 'timestamp' })
    updatedAt?: Date;

    @ApiProperty()
    @DeleteDateColumn({ name: 'deleted_at', nullable: true })
    deletedAt?: Date | null;
}

Every entity in the RAG system extends this. It guarantees:

  • created_at timestamp (immutable)
  • updated_at timestamp (auto-updated)
  • deleted_at timestamp (for soft deletes)

No more "when was this created?" questions—it's always there.

BaseService: CRUD Operations and Ownership Checks


abstract class BaseService<T extends BaseEntity> {
    constructor(protected readonly repository: Repository<T>) {}

    async findAll(options?: FindManyOptions<T>): Promise<T[]> {
        return await this.repository.find(options);
    }

    async findOne(id: number, options?: FindOneOptions<T>): Promise<T> {
        return await this.repository.findOne({ where: { id } });
    }

    async create(createDto: any): Promise<T> {
        // Password hashing happens here if present
        if (createDto.password) {
            createDto.password = await this.hashPassword(createDto.password);
        }
        const entity = this.repository.create(createDto);
        return await this.repository.save(entity);
    }

    async update(id: number, updateDto: any): Promise<T> {
        if (updateDto.password) {
            updateDto.password = await this.hashPassword(updateDto.password);
        }
        await this.repository.update(id, updateDto);
        return this.findOne(id);
    }

    async findOneByUser(id: number, userId: number): Promise<T> {
        const hasAccess = await this.checkOwnership(id, userId);
        if (!hasAccess) {
            throw new ForbiddenException('Access denied');
        }
        return this.findOne(id);
    }

    protected abstract checkOwnership(id: number, userId: number): Promise<boolean>;
}

Key patterns:

  • DRY CRUD: One implementation, reused everywhere
  • Ownership enforcement: Every findOneByUser() call checks permissions
  • Password security: Automatic bcrypt hashing on create/update
  • Type safety: Generic T ensures compile-time correctness

Concrete implementation for Documents:


@Injectable()
export class DocumentsService extends BaseService<Document> {
    constructor(
        @InjectRepository(Document) repository: Repository<Document>,
        private usersService: UsersService
    ) {
        super(repository);
    }

    protected async checkOwnership(id: number, userId: number): Promise<boolean> {
        const document = await this.repository.findOne({ where: { id } });
        return document?.userId === userId;
    }
}

That's it. One method. The rest is inherited.

Authentication & JWT Tokens

The auth flow in the RAG system is centered on JWT (JSON Web Tokens) with refresh token support.

Login Flow


User enters email + password
        │
        ▼
POST /auth/login
        │
        ▼
Validate against database (or SSO provider)
        │
        ▼
Generate JWT tokens (access + refresh)
        │
        ▼
Return tokens to frontend
        │
        ▼
Frontend stores refresh token in httpOnly cookie
Frontend stores access token in memory

Access Token: Short-lived (1 hour), contains user ID and role.

Refresh Token: Long-lived (7 days), stored in httpOnly cookie, used to generate new access tokens.

Why Two Tokens?

If you only have one token (short + long TTL is contradictory):

  • Short-lived only: User logs out after 1 hour. Friction.
  • Long-lived only: Token theft = months of compromised access.

With both:

  • Access token (1 hour): If stolen, damage is limited. Stolen in a script? At most 1 hour.
  • Refresh token (7 days): Stored in httpOnly cookie (JavaScript can't access it). If the access token expires, get a new one.

JWT Payload Structure


interface JwtPayload {
    sub: number;           // Subject (user ID)
    email: string;
    username: string;
    role: 'USER' | 'ADMIN' | 'SUPERUSER';
    permissions: string[];  // Parsed from role
    iat: number;           // Issued at
    exp: number;           // Expiration
}

The role field is not enough for complex permissions. The permissions array contains specific capabilities (e.g., ['create:document', 'edit:own_documents', 'read:all']).

SSO Integration (Optional)

The auth service also supports SSO (Single Sign-On):


async validateUser(emailOrUsername: string, password: string): Promise<User | null> {
    // Try SSO first (LDAP, Azure AD, etc.)
    const ssoUserData = await this.ssoService.authenticate(emailOrUsername, password);
    
    if (ssoUserData) {
        // Create or update user from SSO data
        return await this.usersService.upsertFromSso(ssoUserData);
    }
    
    // Fall back to local database
    const user = await this.usersService.findByEmailOrUsername(emailOrUsername);
    const isValid = await this.usersService.comparePasswords(password, user.password);
    if (!isValid) return null;
    
    return user;
}

Benefit: Organizations can plug in their existing LDAP, Azure AD, or custom SSO without code changes.

Role-Based Access Control (RBAC)

Three roles form the permission hierarchy:

Role Capabilities
USER Own documents, own chats, read public contexts
ADMIN
All user + manage other users, manage all documents, manage contexts
SUPERUSER
All + system configuration, audit trails, backups

But roles are just the start. The real magic is context-specific permissions.

Context-Based Permissions

A "context" is a knowledge domain (e.g., "HR Policies", "Product Docs", "Internal Wiki"). Users can have different roles within different contexts:


interface UserContextPermission {
    userId: number;
    contextId: number;
    role: 'VIEWER' | 'EDITOR' | 'MANAGER';
    createdAt: Date;
}

Now:

  • Alice is an ADMIN for "HR Policies" → Can add/remove documents
  • Alice is a VIEWER for "Product Docs" → Can only chat
  • Alice is a regular USER for personal contexts

This is fine-grained permission without role explosion.

Guards: Enforcing Permissions

NestJS Guards intercept requests and decide if they can proceed:


@Controller('documents')
@UseGuards(JwtAuthGuard)  // Must be logged in
export class DocumentsController {
    
    @Get()
    @UseGuards(CanReadDocumentsGuard)  // Must have read permission
    async findAll() { ... }
    
    @Post()
    @UseGuards(CanCreateDocumentsGuard)  // Must have create permission
    async create(@Body() createDto: CreateDocumentDto) { ... }
    
    @Patch(':id')
    @UseGuards(CanEditOwnDocumentsGuard)  // Must own the document
    async update(@Param('id') id: number, @Body() updateDto: UpdateDocumentDto) { ... }
}

Implementation example:


@Injectable()
export class CanEditOwnDocumentsGuard implements CanActivate {
    constructor(
        private documentsService: DocumentsService,
        private permissionsService: PermissionsService
    ) {}

    async canActivate(context: ExecutionContext): Promise<boolean> {
        const request = context.switchToHttp().getRequest();
        const user = request.user; // From JWT decoded by JwtAuthGuard
        const documentId = request.params.id;

        // Check 1: User is ADMIN → Auto-allow
        if (user.role === 'ADMIN') return true;

        // Check 2: User owns the document
        const document = await this.documentsService.findOne(documentId);
        if (document.userId === user.id) return true;

        // Check 3: User has context-level permission
        return await this.permissionsService.hasPermission(
            user.id,
            document.contextId,
            'EDITOR'
        );
    }
}

This guard stacks: use multiple guards on a route, and all must pass.

API Documentation with Swagger/OpenAPI

Every NestJS backend should be self-documenting. The RAG system auto-generates OpenAPI 3.0 documentation.

In Code

DTOs (Data Transfer Objects) are decorated:


export class CreateDocumentDto {
    @ApiProperty({ description: 'Document title', example: 'Q2 Financial Report' })
    title: string;

    @ApiProperty({ description: 'Document content' })
    content: string;

    @ApiProperty({ description: 'Context ID', example: 1 })
    contextId: number;
}

Controllers are decorated:


@Controller('documents')
@ApiTags('Documents')
export class DocumentsController {
    
    @Post()
    @ApiOperation({ summary: 'Create a new document' })
    @ApiResponse({ status: 201, description: 'Document created', type: DocumentDto })
    @ApiResponse({ status: 400, description: 'Invalid input' })
    @ApiResponse({ status: 401, description: 'Unauthorized' })
    async create(@Body() createDto: CreateDocumentDto): Promise<DocumentDto> { ... }
}

Automatic Generation

On startup, NestJS generates a full OpenAPI spec and serves it at /api-docs/v1:


{
  "openapi": "3.0.0",
  "info": { "title": "RAG System API", "version": "1.0.0" },
  "paths": {
    "/documents": {
      "post": {
        "summary": "Create a new document",
        "requestBody": { ... },
        "responses": { ... }
      }
    }
  }
}

The Swagger UI (interactive docs) is at /api-docs/.

Frontend teams can generate TypeScript clients automatically from this spec using tools like OpenAPI Generator. No more manual endpoint documentation.

Interceptors: Universal Request/Response Handling

Interceptors wrap every request/response. They're perfect for cross-cutting concerns.

Request Logging


@Injectable()
export class LoggingInterceptor implements NestInterceptor {
    private readonly logger = new Logger('HTTP');

    intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
        const request = context.switchToHttp().getRequest();
        const { method, url, user } = request;

        this.logger.log(`${method} ${url} - User: ${user?.id}`);

        return next.handle().pipe(
            tap(() => this.logger.log(`${method} ${url} completed`)),
            catchError(err => {
                this.logger.error(`${method} ${url} failed: ${err.message}`);
                throw err;
            })
        );
    }
}

Response Formatting


@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, any> {
    intercept(context: ExecutionContext, next: CallHandler<T>): Observable<any> {
        return next.handle().pipe(
            map(data => ({
                statusCode: context.switchToHttp().getResponse().statusCode,
                message: 'Success',
                data,
                timestamp: new Date().toISOString()
            }))
        );
    }
}

Now every response follows the same structure:


{
  "statusCode": 200,
  "message": "Success",
  "data": { ... },
  "timestamp": "2026-02-18T10:30:00.000Z"
}

Validation Pipes: Input Sanitization

Before hitting the controller, requests are validated:


export class CreateDocumentDto {
    @IsString()
    @MinLength(3)
    @MaxLength(255)
    title: string;

    @IsString()
    @MinLength(10)
    content: string;

    @IsNumber()
    @IsPositive()
    contextId: number;
}

If validation fails:


{
  "statusCode": 400,
  "message": [
    "title must be longer than or equal to 3 characters",
    "contextId must be a positive number"
  ],
  "error": "Bad Request"
}

Users know exactly what went wrong.

n8n Webhook Integration

Documents don't index themselves. When a new document is created, n8n workflows need to know.

Webhook Trigger from NestJS


@Controller('documents')
export class DocumentsController {
    constructor(
        private documentsService: DocumentsService,
        private webhookService: WebhookService
    ) {}

    @Post()
    async create(@Body() createDto: CreateDocumentDto): Promise<DocumentDto> {
        const document = await this.documentsService.create(createDto);

        // Trigger n8n workflow
        await this.webhookService.trigger('document.created', {
            documentId: document.id,
            title: document.title,
            content: document.content,
            contextId: document.contextId,
        });

        return document;
    }
}

The n8n workflow then:

  1. Extracts text from the document
  2. Chunks it semantically
  3. Generates embeddings
  4. Stores vectors in Qdrant
  5. Updates metadata in PostgreSQL

If anything fails, the workflow logs it, and the document is marked as "pending indexing". No silent failures.

Database-Agnostic Design

TypeORM abstracts away database differences. The same code works with:

  • PostgreSQL (production)
  • MySQL (compatible)
  • SQLite (local development)
  • MariaDB (enterprise alternative)

Just change ormconfig.json:


{
  "type": "postgres",
  "host": "localhost",
  "port": 5432,
  "username": "rag_user",
  "password": "secure_password",
  "database": "rag_system",
  "entities": ["src/**/*.entity.ts"],
  "migrations": ["src/migrations/**/*.ts"],
  "synchronize": false
}

No TypeORM-specific queries in the code. No stored procedures. Just:


await this.repository.find({ where: { contextId: id } });

This works identically on Postgres, MySQL, or SQLite.

Soft Deletes and Audit Trails

Instead of DELETE, the system uses soft deletes:


// Old way (hard delete)
DELETE FROM documents WHERE id = 5;

// New way (soft delete)
UPDATE documents SET deleted_at = NOW() WHERE id = 5;

Why?

  • Audit trails: "What happened to this document?" Check the deleted_at timestamp.
  • Recovery: "Oops, deleted the wrong document." Easy undelete—just set deleted_at to NULL.
  • Referential integrity: Foreign keys to deleted records still work (the record exists, just marked as deleted).

Queries automatically exclude soft-deleted records:


async findAll(): Promise<Document[]> {
    return this.repository.find({
        where: { deletedAt: IsNull() }  // Only non-deleted
    });
}

For admin audits, include deleted records:


async findAllWithDeleted(): Promise<Document[]> {
    return this.repository.find({
        withDeleted: true  // Include soft-deleted
    });
}

Configuration Management

Environment variables configure everything:


NODE_ENV=production
DATABASE_URL=postgres://user:pass@db:5432/rag
JWT_SECRET=your_secret_key_here
JWT_EXPIRY=1h
REFRESH_TOKEN_EXPIRY=7d
N8N_WEBHOOK_URL=http://n8n:5678/webhook/rag
FASTAPI_URL=http://fastapi:8000
REDIS_URL=redis://redis:6379

Loaded via ConfigService:


@Injectable()
export class AppService {
    constructor(private configService: ConfigService) {}

    isDevelopment(): boolean {
        return this.configService.get('NODE_ENV') === 'development';
    }

    getJwtSecret(): string {
        return this.configService.get('JWT_SECRET');
    }
}

No hardcoded values. Secrets aren't in git.

Error Handling: Consistent Responses

All errors follow the same structure:


import { HttpException, HttpStatus } from '@nestjs/common';

throw new HttpException(
    {
        statusCode: HttpStatus.BAD_REQUEST,
        message: 'Document title is required',
        error: 'ValidationError'
    },
    HttpStatus.BAD_REQUEST
);

Response:


{
  "statusCode": 400,
  "message": "Document title is required",
  "error": "ValidationError",
  "timestamp": "2026-02-18T10:35:00.000Z"
}

Clients always know:

  • The HTTP status code
  • A human-readable message
  • The error category
  • When it happened

The RAD-System Advantage: From Days to Hours

Let's quantify the time savings with RAD-System:

Building a new entity without RAD-System:

  1. Create entity class (TypeORM decorators, relationships) → 30 min
  2. Create DTO (request/response shapes) → 20 min
  3. Create service (CRUD operations, ownership checks) → 60 min
  4. Create controller (endpoints, validation, error handling) → 45 min
  5. Create guards (permission checks) → 30 min
  6. Add to module → 10 min

Total: 195 minutes (3+ hours)

Building the same with RAD-System:

  1. Create entity (extend BaseEntity, add fields) → 15 min
  2. Create DTO → 15 min
  3. Create service (extend BaseService, implement checkOwnership) → 10 min
  4. Create controller (reuse standard routes) → 15 min
  5. Reuse existing guards → 0 min
  6. Add to module → 5 min

Total: 60 minutes (1 hour)

That's a 3.25x speedup. On a 20-entity backend, that's 15+ hours saved.

Looking Ahead

The NestJS backend is the foundation. In the next article, we'll explore how the Angular frontend consumes these APIs, manages state, and implements the RAG chat interface.

Then we'll tackle LLM provider switching—how configuration alone lets you swap from OpenAI to local Ollama without redeploying.

---

Key Takeaways:

BaseEntity + BaseService = DRY, secure, maintainable CRUD

JWT tokens = Stateless, scalable authentication

Guards + Interceptors = Centralized permission enforcement

TypeORM = Database-agnostic, no vendor lock-in

Soft deletes = Audit trails + easy recovery

API documentation = Auto-generated, always in sync

n8n integration = Workflows triggered on data changes

The backend is boring. That's good. Boring means it works reliably, scales predictably, and lets your team focus on features instead of infrastructure.

---

GitHub: