28 KiB
Backend Factories Module
Overview
The Factories module provides code generation patterns that eliminate boilerplate for standard CRUD operations across the backend. Two factory functions generate consistent, standardized router and service classes for all entity types.
Location: backend/src/factories/
Files:
| File | Purpose | LOC |
|---|---|---|
router.factory.ts |
Generates Express routers with CRUD endpoints | 429 |
service.factory.ts |
Generates service classes with transaction handling | 350 |
Architecture
┌─────────────────────────────────────────────────────────────────────────┐
│ Factory Pattern Flow │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Route File │ │ Service File │ │ DB API File │
│ (3-5 lines) │ │ (3-5 lines) │ │ (extends base) │
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ createEntity │ │ createEntity │ │ GenericDBApi │
│ Router() │ │ Service() │ │ (base.api.ts) │
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
│ │ │
└──────────────────────┼──────────────────────┘
│
▼
┌───────────────────────┐
│ Sequelize Model │
│ (db/models/*.js) │
└───────────────────────┘
Boilerplate Reduction:
- Without factories: ~300-500 lines per entity
- With factories: ~10-20 lines per entity (97% reduction)
Factory Files
router.factory.ts
Purpose: Generates Express routers with standardized CRUD endpoints, permission checking, CSV export, and error handling.
Location: backend/src/factories/router.factory.ts
Function Signature
function createEntityRouter(entityName, Service, DBApi, options = {})
Parameters
| Parameter | Type | Description |
|---|---|---|
entityName |
string |
Entity name for routes and permissions |
Service |
class |
Service class with CRUD methods |
DBApi |
class |
Database API class extending GenericDBApi |
options |
object |
Configuration options |
Options
| Option | Type | Default | Description |
|---|---|---|---|
permissionEntity |
string |
entityName |
Override permission entity name |
csvFields |
string[] |
DBApi.CSV_FIELDS |
Fields to include in CSV export |
customRoutes |
function |
null |
Callback to add custom routes |
Generated Endpoints
| Method | Endpoint | Description |
|---|---|---|
POST |
/ |
Create new record |
POST |
/bulk-import |
Bulk import from CSV |
PUT |
/:id |
Update record by ID |
DELETE |
/:id |
Delete record by ID |
POST |
/deleteByIds |
Delete multiple records |
GET |
/ |
List all records (with filters, pagination) |
GET |
/count |
Get record count |
GET |
/autocomplete |
Get autocomplete suggestions |
GET |
/:id |
Get single record by ID |
Implementation
const express = require('express');
const { wrapAsync, commonErrorHandler, isUuidV4 } = require('../helpers');
const { checkCrudPermissions } = require('../middlewares/check-permissions');
const { parse } = require('json2csv');
function createEntityRouter(entityName, Service, DBApi, options = {}) {
const router = express.Router();
// Apply CRUD permission middleware for all routes
const permissionEntity = options.permissionEntity || entityName;
router.use(checkCrudPermissions(permissionEntity));
// POST / - Create
router.post('/', wrapAsync(async (req, res) => {
const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`;
const link = new URL(referer);
const payload = await Service.create({
data: req.body.data,
currentUser: req.currentUser,
runtimeContext: req.runtimeContext,
sendInvitationEmails: true,
host: link.host,
});
res.status(200).send(payload);
}));
// POST /bulk-import - Bulk CSV import
router.post('/bulk-import', wrapAsync(async (req, res) => {
const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`;
const link = new URL(referer);
await Service.bulkImport(req, res, true, link.host);
res.status(200).send(true);
}));
// PUT /:id - Update
router.put('/:id', wrapAsync(async (req, res) => {
assertRouteIdMatchesBody(req);
await Service.update({
id: req.params.id,
data: req.body.data,
currentUser: req.currentUser,
runtimeContext: req.runtimeContext,
});
res.status(200).send(true);
}));
// DELETE /:id - Delete single
router.delete('/:id', wrapAsync(async (req, res) => {
await Service.remove({
id: req.params.id,
currentUser: req.currentUser,
runtimeContext: req.runtimeContext,
});
res.status(200).send(true);
}));
// POST /deleteByIds - Delete multiple
router.post('/deleteByIds', wrapAsync(async (req, res) => {
await Service.deleteByIds({
ids: req.body.data,
currentUser: req.currentUser,
runtimeContext: req.runtimeContext,
});
res.status(200).send(true);
}));
// GET / - List all with optional CSV export
router.get('/', wrapAsync(async (req, res) => {
const filetype = req.query.filetype;
const currentUser = req.currentUser;
const runtimeContext = req.runtimeContext;
const payload = await DBApi.findAll(normalizeQuery(req.query, DBApi, {
csv: filetype === 'csv',
}), { currentUser, runtimeContext });
if (filetype === 'csv') {
const fields = options.csvFields || DBApi.CSV_FIELDS || ['id', 'createdAt'];
const opts = { fields };
try {
const csv = parse(payload.rows, opts);
res.status(200).attachment('export.csv').send(csv);
} catch (err) {
logger.error({ err, entityName }, 'CSV export error');
res.status(500).send('CSV export error');
}
} else {
res.status(200).send(payload);
}
}));
// GET /count - Count only
router.get('/count', wrapAsync(async (req, res) => {
const currentUser = req.currentUser;
const runtimeContext = req.runtimeContext;
const payload = await DBApi.findAll(normalizeQuery(req.query, DBApi), { countOnly: true, currentUser, runtimeContext });
res.status(200).send(payload);
}));
// GET /autocomplete - Autocomplete search
router.get('/autocomplete', wrapAsync(async (req, res) => {
const payload = await DBApi.findAllAutocomplete({
query: req.query.query,
limit,
offset: req.query.offset,
});
res.status(200).send(payload);
}));
// GET /:id - Find by ID
router.get('/:id', wrapAsync(async (req, res) => {
if (!isUuidV4(req.params.id)) {
return res.status(400).send(`Invalid ${entityName} id`);
}
const runtimeContext = req.runtimeContext;
const payload = await DBApi.findBy({ id: req.params.id }, { runtimeContext });
res.status(200).send(payload);
}));
// Custom routes hook
if (options.customRoutes) {
options.customRoutes(router, Service, DBApi);
}
// Error handler
router.use('/', commonErrorHandler);
return router;
}
module.exports = { createEntityRouter, isUuidV4 };
Generic CRUD query safety:
PUT /:idusesreq.params.idas the canonical id and rejects mismatched body ids.- List and count queries default to
limit=50, maxlimit=1000, and sanitize sort direction toASCorDESC. - Sort fields are accepted only when present in the model's
rawAttributesorDBApi.SORTABLE_FIELDS. - CSV export uses the same auth path as list and is capped at
limit=1000. - Autocomplete defaults to
limit=20and is capped atlimit=50.
Exports
| Export | Type | Description |
|---|---|---|
createEntityRouter |
function |
Factory function |
isUuidV4 |
function |
UUID validation helper |
service.factory.ts
Purpose: Generates service classes with standardized CRUD operations wrapped in database transactions.
Location: backend/src/factories/service.factory.ts
Function Signature
function createEntityService(DBApi, options = {})
Parameters
| Parameter | Type | Description |
|---|---|---|
DBApi |
class |
Database API class extending GenericDBApi |
options |
object |
Configuration options |
Options
| Option | Type | Default | Description |
|---|---|---|---|
entityName |
string |
'Entity' |
Name used in error messages |
Generated Methods
| Method | Description |
|---|---|
create({ data, currentUser, transaction, runtimeContext }) |
Create record with transaction |
bulkImport(req, res) |
Bulk import from CSV with transaction |
update({ id, data, currentUser, transaction, runtimeContext }) |
Update record with transaction |
deleteByIds({ ids, currentUser, transaction, runtimeContext }) |
Delete multiple with transaction |
remove({ id, currentUser, transaction, runtimeContext }) |
Delete single with transaction |
Implementation
const db = require('../db/models');
const processFile = require('../middlewares/upload');
const ValidationError = require('../services/notifications/errors/validation');
const csv = require('csv-parser');
const stream = require('stream');
function createEntityService(DBApi, options = {}) {
const entityName = options.entityName || 'Entity';
return class GenericService {
static async create({ data, currentUser, transaction: externalTransaction, runtimeContext }) {
const transaction = externalTransaction || await db.sequelize.transaction();
const ownsTransaction = !externalTransaction;
try {
const record = await DBApi.create({ data, currentUser, transaction, runtimeContext });
if (ownsTransaction) await transaction.commit();
return record;
} catch (error) {
if (ownsTransaction) await transaction.rollback();
throw error;
}
}
static async bulkImport(req, res) {
const transaction = await db.sequelize.transaction();
try {
await processFile(req, res);
const bufferStream = new stream.PassThrough();
const results = [];
await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8'));
await new Promise((resolve, reject) => {
bufferStream
.pipe(csv())
.on('data', (data) => results.push(data))
.on('end', () => resolve())
.on('error', (error) => reject(error));
});
await DBApi.bulkImport(results, {
transaction,
ignoreDuplicates: true,
validate: true,
currentUser: req.currentUser,
});
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async update({ id, data, currentUser, transaction: externalTransaction, runtimeContext }) {
const transaction = externalTransaction || await db.sequelize.transaction();
const ownsTransaction = !externalTransaction;
try {
const record = await DBApi.findBy({ id }, { transaction, runtimeContext });
if (!record) {
throw new ValidationError(`${entityName}NotFound`);
}
const updated = await DBApi.update({ id, data, currentUser, transaction, runtimeContext });
if (ownsTransaction) await transaction.commit();
return updated;
} catch (error) {
if (ownsTransaction) await transaction.rollback();
throw error;
}
}
static async deleteByIds({ ids, currentUser, transaction: externalTransaction, runtimeContext }) {
const transaction = externalTransaction || await db.sequelize.transaction();
const ownsTransaction = !externalTransaction;
try {
await DBApi.deleteByIds({ ids, currentUser, transaction, runtimeContext });
if (ownsTransaction) await transaction.commit();
} catch (error) {
if (ownsTransaction) await transaction.rollback();
throw error;
}
}
static async remove({ id, currentUser, transaction: externalTransaction, runtimeContext }) {
const transaction = externalTransaction || await db.sequelize.transaction();
const ownsTransaction = !externalTransaction;
try {
await DBApi.remove({ id, currentUser, transaction, runtimeContext });
if (ownsTransaction) await transaction.commit();
} catch (error) {
if (ownsTransaction) await transaction.rollback();
throw error;
}
}
};
}
module.exports = { createEntityService };
Transaction Pattern
All service methods follow the same transaction pattern:
static async methodName(params) {
const transaction = await db.sequelize.transaction();
try {
// ... database operations with { transaction }
await transaction.commit();
return result;
} catch (error) {
await transaction.rollback();
throw error;
}
}
Supporting Components
helpers.js
Location: backend/src/helpers.js
Helper utilities used by the router factory:
module.exports = class Helpers {
// Wrap async route handlers to propagate errors
static wrapAsync(fn) {
return function (req, res, next) {
fn(req, res, next).catch(next);
};
}
// Centralized error response handler
static commonErrorHandler(error, req, res, _next) {
const statusCode = error.code || error.status;
if ([400, 401, 403, 404, 409, 422].includes(statusCode)) {
return res.status(statusCode).send(error.message);
}
console.error(error);
return res.status(500).send('Internal server error');
}
// JWT token signing
static jwtSign(data) {
return jwt.sign(data, config.secret_key, { expiresIn: '6h' });
}
// UUID v4 validation
static isUuidV4(value) {
return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
}
};
check-permissions.ts
Location: backend/src/middlewares/check-permissions.ts
Permission checking middleware used by router factory:
// HTTP method to permission action mapping
const METHOD_MAP = {
POST: 'CREATE',
GET: 'READ',
PUT: 'UPDATE',
PATCH: 'UPDATE',
DELETE: 'DELETE',
};
// Entities accessible publicly in runtime mode
const RUNTIME_PUBLIC_READ_ENTITIES = new Set([
'PROJECTS',
'TOUR_PAGES',
'PAGE_ELEMENTS',
'PAGE_LINKS',
'TRANSITIONS',
'PROJECT_AUDIO_TRACKS',
]);
// Generate permission name from HTTP method and entity
function checkCrudPermissions(name) {
return (req, res, next) => {
// Skip auth for public runtime read requests
const isRuntimePublicRead =
req.isRuntimePublicRequest === true &&
req.method === 'GET' &&
RUNTIME_PUBLIC_READ_ENTITIES.has(name.toUpperCase());
if (isRuntimePublicRead) {
return next();
}
// Build permission name: e.g., 'READ_ASSETS', 'CREATE_USERS'
const permissionName = `${METHOD_MAP[req.method]}_${name.toUpperCase()}`;
return checkPermissions(permissionName)(req, res, next);
};
}
GenericDBApi (Base Class)
Location: backend/src/db/api/base.api.ts
The DB API base class that all entity APIs extend. Provides declarative configuration for CRUD operations.
Static Getters (Override in Subclasses)
| Getter | Type | Description |
|---|---|---|
MODEL |
Model |
Sequelize model reference (required) |
TABLE_NAME |
string |
Database table name |
SEARCHABLE_FIELDS |
string[] |
Fields for text search (ILIKE) |
RANGE_FIELDS |
string[] |
Fields for range filtering |
ENUM_FIELDS |
string[] |
Fields for exact match filtering |
RELATION_FILTERS |
object[] |
Related entity filters |
CSV_FIELDS |
string[] |
Fields for CSV export |
AUTOCOMPLETE_FIELD |
string |
Field for autocomplete |
ASSOCIATIONS |
object[] |
Related entity setters |
FIND_BY_INCLUDES |
object[] |
Includes for findBy |
FIND_ALL_INCLUDES |
object[] |
Includes for findAll |
JSON_FIELDS |
string[] |
Fields to auto-stringify |
FIELD_TRANSFORMERS |
object |
Custom field transformers |
FIELD_DEFAULTS |
object |
Default values for fields |
Methods
| Method | Description |
|---|---|
getFieldMapping(data) |
Transform input data for database |
create(data, options) |
Create record |
bulkImport(data, options) |
Bulk create records |
update({ id, data, currentUser, transaction, runtimeContext }) |
Update record |
deleteByIds({ ids, currentUser, transaction, runtimeContext }) |
Soft delete multiple |
remove({ id, currentUser, transaction, runtimeContext }) |
Soft delete single |
findBy(where, options) |
Find single by criteria |
findAll(filter, options) |
Find all with pagination/filters |
findAllAutocomplete({ query, limit, offset }, options) |
Autocomplete search |
toCSV(rows) |
Convert to CSV string |
Usage Examples
Basic Entity (Minimal Configuration)
Route (assets.ts):
import AssetsDBApi from '../db/api/assets.ts';
import { createEntityRouter } from '../factories/router.factory.ts';
import AssetsService from '../services/assets.ts';
// 1 line: generates 9 CRUD endpoints
export default createEntityRouter('assets', AssetsService, AssetsDBApi);
Service (assets.ts):
import AssetsDBApi from '../db/api/assets.ts';
import { createEntityService } from '../factories/service.factory.ts';
// 1 line: generates service class with 5 transaction-wrapped methods
export default createEntityService(AssetsDBApi, {
entityName: 'assets',
});
DB API (assets.js):
const GenericDBApi = require('./base.api');
const db = require('../models');
class AssetsDBApi extends GenericDBApi {
static get MODEL() {
return db.assets;
}
static get SEARCHABLE_FIELDS() {
return ['name', 'cdn_url', 'storage_key', 'mime_type', 'checksum'];
}
static get RANGE_FIELDS() {
return ['size_mb', 'width_px', 'height_px', 'duration_sec'];
}
static get ENUM_FIELDS() {
return ['asset_type', 'type', 'is_public'];
}
static get ASSOCIATIONS() {
return [{ field: 'project', setter: 'setProject', isArray: false }];
}
static getFieldMapping(data) {
return {
name: data.name || null,
asset_type: data.asset_type || null,
type: data.type || 'general',
cdn_url: data.cdn_url || null,
// ... other fields
};
}
}
module.exports = AssetsDBApi;
Entity with Custom Routes
Route (project_element_defaults.ts):
import Service from '../services/project_element_defaults.ts';
import DBApi from '../db/api/project_element_defaults.ts';
import { createEntityRouter } from '../factories/router.factory.ts';
import { wrapAsync } from '../helpers.ts';
// Create base router
const baseRouter = createEntityRouter(
'project_element_defaults',
Service,
DBApi,
{ permissionEntity: 'page_elements' } // Override permission entity
);
// Add custom endpoint
baseRouter.post('/:id/reset', wrapAsync(async (req, res) => {
const payload = await Service.resetToGlobal(req.params.id, {
currentUser: req.currentUser,
});
res.status(200).json(payload);
}));
// Add another custom endpoint
baseRouter.get('/:id/diff', wrapAsync(async (req, res) => {
const payload = await Service.getDiffFromGlobal(req.params.id);
res.status(200).json(payload);
}));
export default baseRouter;
Service with Extended Methods
Service (project_element_defaults.ts):
import DBApi from '../db/api/project_element_defaults.ts';
import { createEntityService } from '../factories/service.factory.ts';
// Create base service class
const BaseService = createEntityService(DBApi, {
entityName: 'project_element_defaults',
});
// Extend with custom methods
export default class Project_element_defaultsService extends BaseService {
static resetToGlobal(id, options = {}) {
return DBApi.resetToGlobal(id, options);
}
static getDiffFromGlobal(id) {
return DBApi.getDiffFromGlobal(id);
}
static snapshotGlobalDefaults(projectId, options = {}) {
return DBApi.snapshotGlobalDefaults(projectId, options);
}
}
Using customRoutes Callback
Alternative approach using the options callback:
module.exports = createEntityRouter('entities', Service, DBApi, {
customRoutes: (router, Service, DBApi) => {
router.post('/:id/custom-action', wrapAsync(async (req, res) => {
const result = await Service.customAction(req.params.id);
res.status(200).json(result);
}));
router.get('/stats', wrapAsync(async (req, res) => {
const stats = await DBApi.getStatistics();
res.status(200).json(stats);
}));
}
});
Entity Usage Summary
Entities Using Router Factory (13)
| Entity | Permission Override | Custom Routes |
|---|---|---|
access_logs |
- | No |
asset_variants |
- | No |
assets |
- | No |
element_type_defaults |
- | No |
permissions |
- | No |
presigned_url_requests |
- | No |
project_audio_tracks |
- | No |
project_element_defaults |
page_elements |
Yes (reset, diff) |
project_memberships |
- | No |
publish_events |
- | No |
pwa_caches |
- | No |
roles |
- | No |
tour_pages |
- | No |
Entities Using Service Factory (11)
| Entity | Custom Methods |
|---|---|
access_logs |
No |
asset_variants |
No |
assets |
No |
element_type_defaults |
No |
permissions |
No |
presigned_url_requests |
No |
pwa_caches |
No |
publish_events |
No |
tour_pages |
No |
project_element_defaults |
Yes (resetToGlobal, getDiffFromGlobal, snapshotGlobalDefaults) |
project_memberships |
No |
Entities NOT Using Factories
Some entities have custom implementations due to specialized requirements:
| Entity | Reason |
|---|---|
users |
Complex auth, password hashing, token management |
projects |
Publishing workflow, complex business logic |
auth |
Authentication flows (login, OAuth, password reset) |
file |
File upload/download, S3/GCloud/Local storage |
search |
Full-text search across multiple entities |
publish |
Multi-step publishing workflow |
Generated Endpoints Flow
HTTP Request
│
▼
┌─────────────────────────────────┐
│ JWT Authentication │ (from index.js)
│ passport.authenticate('jwt') │
└─────────────────────────────────┘
│
▼
┌─────────────────────────────────┐
│ checkCrudPermissions() │ (from router.factory.ts)
│ - Maps HTTP method to action │
│ - Builds permission name │
│ - Checks user role permissions │
└─────────────────────────────────┘
│
▼
┌─────────────────────────────────┐
│ Route Handler │ (from router.factory.ts)
│ - wrapAsync() for error catch │
│ - Calls Service method │
└─────────────────────────────────┘
│
▼
┌─────────────────────────────────┐
│ Service Method │ (from service.factory.ts)
│ - Starts transaction │
│ - Calls DBApi method │
│ - Commits or rollbacks │
└─────────────────────────────────┘
│
▼
┌─────────────────────────────────┐
│ DBApi Method │ (from base.api.ts + entity)
│ - getFieldMapping() transform │
│ - Sequelize model operation │
│ - Returns result │
└─────────────────────────────────┘
│
▼
┌─────────────────────────────────┐
│ commonErrorHandler() │ (from helpers.js)
│ - Formats error response │
│ - Appropriate HTTP status │
└─────────────────────────────────┘
│
▼
HTTP Response
Design Patterns
Factory Pattern
Both createEntityRouter and createEntityService implement the Factory pattern, creating objects (router, service class) without specifying their exact classes.
Template Method Pattern
GenericDBApi.getFieldMapping() uses the Template Method pattern - the base class defines the algorithm skeleton, while subclasses can override specific steps via static getters (JSON_FIELDS, FIELD_TRANSFORMERS, FIELD_DEFAULTS).
Strategy Pattern
The permission checking system uses Strategy pattern - different entities can have different permission strategies by overriding permissionEntity option.
Decorator Pattern
The router factory decorates Express routers with permission middleware and error handling.
Best Practices
When to Use Factories
Use factories when:
- Entity requires standard CRUD operations
- No complex business logic beyond data transformation
- Permissions follow standard READ/CREATE/UPDATE/DELETE pattern
- No special authentication requirements
When NOT to Use Factories
Don't use factories when:
- Complex multi-step workflows (use custom service)
- Special authentication (OAuth flows, password reset)
- External API integration (file storage, AI)
- Cross-entity transactions
- Custom endpoint patterns
Extending Factory-Generated Code
- Add custom routes: Extend the base router after factory call
- Add custom service methods: Extend the generated class
- Override permissions: Use
permissionEntityoption - Customize data transformation: Override
getFieldMapping()in DBApi
Related Documentation
- Services Module - Business logic layer
- Routes Module - All route files
- Middleware Module - Permission checking
- Database Schema - Model definitions