✅ CANONICAL DATA-ACCESS PATTERN (source of truth, 2026-06). The context-aware Service Layer shown here —
service.use(req.db)with RLS inapplyPermissions— is the official data-access pattern, required for strong multi-tenancy. Directglobal.repository.Xaccess is legacy and is rejected at runtime by a fail-fast Proxy in@volcanicminds/backend/typeorm. Older snippets inllms.txt/README/Context7 may still show the legacy form; this document takes precedence, and the package source code is the ultimate authority.
While @volcanicminds/backend allows for simple route-to-controller logic, building enterprise-grade applications requires a more robust architecture to handle complexity, security, and maintainability.
This guide details the recommended architecture used in complex projects (like volcanic-backend-sample), focusing on the Service Layer Pattern and the BaseService abstraction.
To maintain Separation of Concerns (SoC), the application is divided into distinct layers:
req or res are). It receives a UserContext and raw data.@volcanicminds/backend/typeorm.BaseService PatternTo avoid code duplication for standard CRUD operations (searching, counting, finding by ID), we recommend creating an abstract BaseService class. This class leverages TypeORM’s QueryBuilder to handle filtering, sorting, and pagination dynamically while enforcing security permissions.
Create src/services/base.service.ts. This generic class will be extended by all your specific entity services.
import { ObjectLiteral, Repository, SelectQueryBuilder } from 'typeorm'
import { UserContext } from '../../types/index.js' // Your defined UserContext interface
export abstract class BaseService<T extends ObjectLiteral> {
protected repository: Repository<T>
constructor(repository: Repository<T>) {
this.repository = repository
}
// Abstract method: Forces developers to define security rules for every entity
protected abstract applyPermissions(qb: SelectQueryBuilder<T>, ctx: UserContext, alias: string): SelectQueryBuilder<T>
/**
* Creates a request-scoped clone of the service to inject the Tenant-Aware Transaction/Manager
* usage: await myService.use(req.db).findAll(...)
*/
use(manager: Document | any): this {
const clone = Object.create(this)
clone.repository = manager.getRepository(this.repository.target)
return clone
}
// Hook to add relationships (joins) automatically
protected addRelations(qb: SelectQueryBuilder<T>, _alias: string): SelectQueryBuilder<T> {
return qb
}
// Hook for complex custom filters not handled by standard query parser
protected applyCustomFilters(_qb: SelectQueryBuilder<T>, _queryParams: any, _alias: string): any {
return _queryParams
}
protected createQueryBuilder(alias: string): SelectQueryBuilder<T> {
return this.repository.createQueryBuilder(alias)
}
// Standard FindAll with Pagination, Sorting, Permissions and Relations
async findAll(ctx: UserContext, queryParams: any = {}): Promise<{ headers: any; records: T[] }> {
const alias = this.repository.metadata.tableName
let qb = this.createQueryBuilder(alias)
// 1. Apply Relations
qb = this.addRelations(qb, alias)
// 2. Apply Security Permissions (Row Level Security)
qb = this.applyPermissions(qb, ctx, alias)
// 3. Apply Custom Logic
const paramsToProcess = this.applyCustomFilters(qb, { ...queryParams }, alias)
// 4. Apply Standard Filters, Sort, Pagination via Helper (see Data Layer Magic guide)
// ... implementation of standard filtering mapping ...
// Example logic (simplified for brevity):
if (paramsToProcess.page && paramsToProcess.pageSize) {
const page = parseInt(paramsToProcess.page) || 1
const pageSize = parseInt(paramsToProcess.pageSize) || 25
qb.skip((page - 1) * pageSize).take(pageSize)
}
const [records, total] = await qb.getManyAndCount()
const headers = {
'v-count': records.length,
'v-total': total,
'v-page': paramsToProcess.page || 1,
'v-pageSize': paramsToProcess.pageSize || records.length || 1,
'v-pageCount': Math.ceil(total / (parseInt(paramsToProcess.pageSize) || records.length || 1))
}
return { headers, records }
}
async findOne(ctx: UserContext, id: string): Promise<T | null> {
const alias = this.repository.metadata.tableName
let qb = this.createQueryBuilder(alias)
qb = this.addRelations(qb, alias)
// Crucial: Permissions are applied even on single retrieval
qb = this.applyPermissions(qb, ctx, alias)
qb.andWhere(`${alias}.id = :id`, { id })
return qb.getOne()
}
}
Here is how you extend the BaseService for a specific entity (e.g., Order). Notice how applyPermissions enforces multitenancy or role-based visibility.
// src/services/order.service.ts
import { SelectQueryBuilder } from 'typeorm'
import { BaseService } from './base.service.js'
import { Order } from '../entities/order.e.js'
import { UserContext } from '../../types/index.js'
export class OrderService extends BaseService<Order> {
constructor() {
// Resolve the repository from the global connection (NOT the forbidden `global.repository` accessor);
// `.use(req.db)` swaps it for the request-scoped, multi-tenant-safe repository at call time.
super(global.connection.getRepository(Order))
}
// Automatically load related entities
protected addRelations(qb: SelectQueryBuilder<Order>, alias: string): SelectQueryBuilder<Order> {
return qb.leftJoinAndSelect(`${alias}.client`, 'client').leftJoinAndSelect(`${alias}.items`, 'items')
}
// Define Row Level Security
protected applyPermissions(
qb: SelectQueryBuilder<Order>,
ctx: UserContext,
alias: string
): SelectQueryBuilder<Order> {
// Admin sees everything
if (ctx.role === 'admin') {
return qb
}
// Managers only see orders belonging to their company
if (ctx.role === 'manager') {
if (!ctx.company) {
qb.andWhere('1=0') // Security Fail-safe
return qb
}
qb.andWhere(`${alias}.company = :company`, { company: ctx.company })
return qb
}
// Users see nothing (or define specific logic)
qb.andWhere('1=0')
return qb
}
}
// Export as Singleton
export const orderService = new OrderService()
The controller is now purely an interface adapter. It converts the HTTP Request into arguments for the Service.
// src/api/orders/controller/order.ts
import { FastifyReply, FastifyRequest } from '@volcanicminds/backend'
import { orderService } from '../../../services/order.service.js'
export async function find(req: FastifyRequest, reply: FastifyReply) {
// 1. Extract User Context (Who is calling?)
const ctx = req.userContext
// 2. Extract Data (Query params, Body)
const data = req.data()
// 3. Call Service (Injecting DB Context for Multi-Tenant safety)
// ALWAYS use .use(req.db) to ensure the service uses the correct connection isolation
const { headers, records } = await orderService.use(req.db).findAll(ctx, data)
// 4. Send Response
return reply.type('application/json').headers(headers).send(records)
}
export async function findOne(req: FastifyRequest, reply: FastifyReply) {
const { id } = req.parameters()
const ctx = req.userContext
const order = await orderService.use(req.db).findOne(ctx, id)
// Handle 404 Not Found OR 403 Forbidden (implicitly handled by service returning null)
return order || reply.status(404).send()
}
findOne or findAll from BaseService, the applyPermissions logic is always executed.OrderService without mocking FastifyRequest or FastifyReply objects.