Security and Abstraction by Design

The question I asked myself when I started designing the RAD-System backend was simple: what is the code I write every single time I start a new project? Authentication, audit fields, CRUD operations, ownership checks. Always the same things. Always with the risk of getting one of them slightly wrong.

My answer was to write them once, correctly, and never write them again. That is the entire philosophy of the RAD-System backend — a NestJS layer where the repetitive 90% is handled by two abstract classes, and your job is only the 10% that is actually specific to your product.

The full source is on GitHub.

1. The Foundation: BaseEntity and Audit Columns

Every record in a serious application needs to be traceable — when it was created, when it was last modified, whether it has been deleted. Instead of defining these fields in every entity, I built BaseEntity.

Every table in my PostgreSQL database inherits this automatically, including native soft-delete via TypeORM's @DeleteDateColumn. I keep the primary key in the concrete entity so I can choose UUID or integer depending on the project.

import { CreateDateColumn, UpdateDateColumn, DeleteDateColumn } from 'typeorm';
import { ApiProperty } from '@nestjs/swagger';
import { IBase } from '../interfaces/models.interface';

export abstract class BaseEntity implements IBase {

    @ApiProperty()
    @CreateDateColumn({ name: 'created_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
    createdAt: Date;

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

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

2. The Engine: BaseService and TypeScript Generics

BaseService is where the real time saving happens. Using TypeScript generics, this single abstract class provides ready-to-use CRUD methods for any entity. I write the logic once; the compiler enforces the types everywhere.

Two principles drive it:

  • DRY — I never write findOne or findAll logic twice across the entire application.
  • Type Safety — the compiler knows exactly what object type it is handling at every step.
export abstract class BaseService<T extends BaseEntity> {
  constructor(protected readonly repository: Repository<T>) {}

  // Abstract method that must be implemented by concrete services
  protected abstract checkOwnership(id: number, userId: number): Promise<boolean>;

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

  async findOne(id: number | string, options?: FindOneOptions<T>): Promise<T> {
    const entity = await this.repository.findOne({ where: { id } as any, ...(options as any) });
    if (!entity) throw new NotFoundException(`Entity with ID ${id} not found`);
    return entity;
  }
}

3. Security in the DNA: Ownership Validation

I have seen too many applications where security is an afterthought — a layer added on top after the fact, full of gaps. In RAD-System, security is structural.

The checkOwnership() method is abstract, which means every concrete service that extends BaseService is forced to implement it. You cannot accidentally skip it. If a user tries to access a resource they do not own, the system raises a ForbiddenException at the core level, before any business logic runs.

async findOneByUser(id: number, userId: number, options?: FindOneOptions<T>): Promise<T> {
    const hasAccess = await this.checkOwnership(id, userId);
    if (!hasAccess) {
        throw new ForbiddenException('Access denied');
    }
    const entity = await this.findOne(id, options);
    if (!entity) {
        throw new NotFoundException(`Entity not found`);
    }
    return entity;
}

// Note: Concrete services must implement checkOwnership, for example:
// protected async checkOwnership(id: number, userId: number): Promise<boolean> {
//   const count = await this.repository.count({ where: { id, userId } as any });
//   return count > 0;
// }

4. The Result: A New Module in Minutes

This is what the abstraction buys in practice. When I need to add a new feature — Invoices, Tasks, Documents, anything — my work is:

  1. Define the entity extending BaseEntity.
  2. Define the service extending BaseService.

That is it. I already have secure, validated CRUD endpoints with soft-delete and full audit trail, ready for the frontend. No repeated code. No forgotten security checks.

5. The Common Layer: Consistency Without Repetition

The src/common folder is the backbone of the entire backend. Everything that needs to be consistent across all modules lives here.

Response Standardisation

Every API response goes through TransformInterceptor. Raw objects never leave the backend — every response is wrapped in a consistent structure with data, timestamp, and request path. The @Exclude() serialisation rules from class-transformer are applied automatically.

// src/common/interceptors/transform.interceptor.ts
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, Response<unknown>> {
    intercept(context: ExecutionContext, next: CallHandler): Observable<Response<unknown>> {
        const request = context.switchToHttp().getRequest();
        return next.handle().pipe(
            map(data => ({
                data: instanceToPlain(data), // Applies entity serialization rules
                timestamp: new Date().toISOString(),
                path: request.url,
            })),
        );
    }
}

Secure by Default

The JwtAuthGuard is registered globally. Every endpoint requires authentication unless explicitly marked otherwise with @Public(). I prefer this approach — opt-out of security rather than opt-in. Forgetting to protect an endpoint is a much more dangerous mistake than forgetting to open one.

// src/common/guards/jwt-auth.guard.ts
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') implements CanActivate {
  constructor(private reflector: Reflector) { super(); }

  canActivate(context: ExecutionContext) {
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (isPublic) return true;
    return super.canActivate(context);
  }
}

6. Database Agnosticism

I built the system to run on PostgreSQL, MySQL/MariaDB, or SQLite without changing a single line of business logic. The TypeORM driver is selected dynamically via the DB_TYPE environment variable in database.config.ts.

One specific problem I solved is raw SQL dialect differences — string concatenation is || in PostgreSQL and CONCAT() in MySQL. The SqlHelper utility abstracts this so custom queries work everywhere without branching.

// src/common/utils/sql.helper.ts
export class SqlHelper {
    static concat(...values: (string | string[])[]): string {
        const flatValues = values.flat();
        switch (this.getDbType()) {
            case 'postgres':
            case 'sqlite':
                return flatValues.join(' || '); // Standard SQL
            case 'mysql':
            default:
                return `CONCAT(${flatValues.join(', ')})`; // MySQL specific
        }
    }
}

7. Module Structure

Each feature is self-contained in rad-be/src/modules. The current modules out of the box:

  • admin/ — system administration
  • auth/ — JWT authentication, SSO, guards
  • config/ — dynamic configuration management
  • departments/ — organisation structure
  • email/ — notifications with Handlebars templates
  • users/ — user management and profiles

8. Declarative Security: Custom Decorators

Controllers stay clean. Security decisions live in decorators, not mixed into business logic.

  • @Public() — removes the JWT guard from a specific endpoint
  • @User() — injects the authenticated user directly into the method parameter, cleaner than req.user
  • @AdminRequired() — enforces role-based access at the controller level
// Example usage in a Controller
@Controller('users')
export class UsersController {
  
  @Get('profile')
  getProfile(@User() user: UserEntity) {
    return user;
  }

  @Post()
  @AdminRequired() // Only admins can create users
  create(@Body() createUserDto: CreateUserDto) {
    return this.usersService.create(createUserDto);
  }
}

9. Auto-Generated API Documentation

The system generates OpenAPI 3 documentation automatically on every build. Interactive docs are available at /api-docs/v1, and a rad-openapi3-spec.json file is produced for frontend client generation — no manual documentation to maintain.