CRUD API Factory
The CRUD factory (makeCrudRoute) is a powerful abstraction for building consistent, multi-tenant safe REST APIs. It integrates deeply with the saasframe architecture to provide validation, security, performance, and extensibility.
Architecture Overview
Core Configuration
ORM Configuration
const crud = makeCrudRoute({
orm: {
entity: CustomerEntity, // MikroORM entity class
idField: "id", // Primary key field (default: 'id')
orgField: "organizationId", // Organization scope field (default: 'organizationId')
tenantField: "tenantId", // Tenant scope field (default: 'tenantId')
softDeleteField: "deletedAt", // Soft delete field (default: 'deletedAt')
},
// ... rest of config
});
Field Behavior:
orgField: nulldisables automatic organization scopingtenantField: nulldisables automatic tenant scopingsoftDeleteField: nulldisables implicit soft delete filtering
Usage
Define a route.ts under packages/<pkg>/src/modules/<module>/api/<path>/route.ts (or apps/saasframe/src/modules/<module>/api/<path>/route.ts for app overrides) and use the factory.
Modern Pattern (with Commands)
The recommended approach is to delegate mutations to Commands and use the factory primarily for the GET (list) handler and wiring.
import { z } from "zod";
import { makeCrudRoute } from "@saasframe/shared/lib/crud/factory";
import { TodoEntity } from "../../data/entities";
import { E } from "@/generated/entities.ids.generated";
const querySchema = z.object({
page: z.coerce.number().min(1).default(1),
pageSize: z.coerce.number().min(1).max(100).default(50),
search: z.string().optional(),
});
const routeMetadata = {
GET: { requireAuth: true, requireFeatures: ["example.todos.view"] },
POST: { requireAuth: true, requireFeatures: ["example.todos.create"] },
PUT: { requireAuth: true, requireFeatures: ["example.todos.edit"] },
DELETE: { requireAuth: true, requireFeatures: ["example.todos.delete"] },
};
export const metadata = routeMetadata;
const crud = makeCrudRoute({
metadata: routeMetadata,
orm: {
entity: TodoEntity,
// defaults: idField: 'id', orgField: 'organizationId', tenantField: 'tenantId'
},
list: {
schema: querySchema,
entityId: E.example.todo,
fields: ["id", "title", "status", "cf:priority"],
sortFieldMap: { priority: "cf:priority" },
buildFilters: async (query, ctx) => {
// Return typed filters for QueryEngine
const filters: any = {};
if (query.search) filters.title = { $ilike: `%${query.search}%` };
return filters;
},
},
actions: {
create: {
commandId: "example.todos.create",
schema: z.object({ title: z.string() }).passthrough(), // validation handled by command
},
update: {
commandId: "example.todos.update",
},
delete: {
commandId: "example.todos.delete",
},
},
});
export const { GET, POST, PUT, DELETE } = crud;
Configuration Options
orm
Configures the MikroORM entity binding.
entity: The entity class.idField: Primary key field (default:'id').orgField: Field for organization scope (default:'organizationId'). Passnullto disable.tenantField: Field for tenant scope (default:'tenantId'). Passnullto disable.softDeleteField: Field for soft deletes (default:'deletedAt').
List Configuration Deep Dive
The list configuration powers the GET endpoint using the Query Engine for advanced querying capabilities.
Basic List Configuration
list: {
schema: z.object({
page: z.coerce.number().min(1).default(1),
pageSize: z.coerce.number().min(1).max(100).default(50),
search: z.string().optional(),
status: z.enum(['active', 'inactive']).optional(),
createdFrom: z.string().optional(),
createdTo: z.string().optional(),
sortField: z.string().optional(),
sortDir: z.enum(['asc', 'desc']).optional(),
}),
entityId: E.customers.customer_entity,
fields: [
'id',
'display_name',
'primary_email',
'status',
'created_at',
'cf:priority', // Custom field
'cf:tags', // Another custom field
],
}
Per-request projection (function-form fields)
fields accepts either a static array or a function (query, ctx) => string[] that resolves the projection per request. The function form lets a route narrow the columns the Query Engine selects based on the validated query — most usefully to drop large detail-only columns (encrypted JSONB snapshots, payload blobs) from grid listings while still selecting them for single-record fetches.
This matters because those columns are fetched over the wire and decrypted per row for every list page even when no grid column renders them; the cost scales with row width × page size.
const detailOnlyColumns = new Set([
'billing_address_snapshot',
'shipping_address_snapshot',
'totals_snapshot',
'metadata',
]);
const allFields = ['id', 'number', 'status', 'customer_snapshot', ...detailOnlyColumns];
const gridFields = allFields.filter((field) => !detailOnlyColumns.has(field));
list: {
schema: listSchema,
entityId: E.sales.sales_order,
// The detail page fetches a single record through this same list route with an
// `?id=` filter (there is no separate detail endpoint), so it needs the full
// projection. Grid listings (no `id`) use the trimmed projection.
fields: (query) =>
typeof query.id === 'string' && query.id.length ? allFields : gridFields,
}
Notes:
- The function is resolved on the Query Engine path only (the route must set both
entityIdandfields). The array form is fully backward compatible — pass an array whenever the projection is static. - Keep response keys stable. Dropped columns still serialize (e.g. as
nullviatransformItem), so the wire contract / OpenAPI schema stays unchanged as long as those response fields are alreadynullable().optional(). - Only narrow columns the list view never renders. Keep any column the grid derives a displayed value from (for sales documents,
customer_snapshotis kept because the grid renders the customer name/email from it).
Advanced Filtering with buildFilters
The buildFilters function transforms query parameters into Query Engine compatible filters:
buildFilters: async (query, ctx) => {
const filters: Record<string, any> = {};
// Basic field filters
if (query.status) {
filters.status = { $eq: query.status };
}
// Text search with ILIKE
if (query.search) {
filters.display_name = { $ilike: `%${query.search}%` };
}
// Date range filters
if (query.createdFrom || query.createdTo) {
const range: any = {};
if (query.createdFrom) range.$gte = new Date(query.createdFrom);
if (query.createdTo) range.$lte = new Date(query.createdTo);
filters.created_at = range;
}
// Custom field filters (requires EM access)
if (ctx) {
const cfFilters = await buildCustomFieldFiltersFromQuery({
entityIds: [E.customers.customer_entity],
query,
em: ctx.container.resolve("em"),
tenantId: ctx.auth?.tenantId ?? null,
});
Object.assign(filters, cfFilters);
}
return filters;
};
Supported Filter Operators:
$eq- Equal$ne- Not equal$gt,$gte- Greater than$lt,$lte- Less than$in- In array$nin- Not in array$ilike- Case-insensitive LIKE$exists- Field exists/null check
Custom Field Sources
For entities with custom fields in related tables:
customFieldSources: [
{
entityId: E.customers.customer_person_profile,
table: "customer_people", // Table name
alias: "person_profile", // Join alias
recordIdColumn: "id", // Column in main table
join: {
fromField: "id", // Field in main table
toField: "entity_id", // Field in joined table
},
},
];
Complex Joins
Define multi-table relationships:
joins: [
{
alias: "tag_assignments",
table: "customer_tag_assignments",
from: { field: "id" },
to: { field: "entity_id" },
type: "left", // 'left', 'inner', 'right'
},
{
alias: "tags",
table: "customer_tags",
from: { field: "tag_assignments.tag_id" },
to: { field: "id" },
type: "left",
},
];
Item Transformation
Post-process query results:
transformItem: (item) => {
// Remove sensitive fields
const { password, ...safe } = item;
// Add computed fields
return {
...safe,
displayName: item.first_name + " " + item.last_name,
isOverdue: item.due_date && new Date(item.due_date) < new Date(),
};
};
Export Configuration
Enable CSV/JSON/XML export:
export: {
enabled: true,
formats: ['csv', 'json', 'xml'],
filename: 'customers_export',
columns: [
{ field: 'id', header: 'ID' },
{ field: 'display_name', header: 'Name' },
{ field: 'cf:priority', header: 'Priority' },
],
batchSize: 1000, // Process in batches for large exports
}
Actions Configuration (Command Integration)
The actions configuration integrates with the Command Bus for business logic execution:
actions: {
create: {
commandId: 'customers.people.create',
schema: z.object({}).passthrough(), // Validation before command
mapInput: async ({ parsed, raw, ctx }) => {
// Transform input before command execution
const { translate } = await resolveTranslations()
const scoped = withScopedPayload(raw, ctx, translate)
const { base, custom } = splitCustomFieldPayload(scoped)
return Object.keys(custom).length ? { ...base, customFields: custom } : base
},
response: ({ result }) => ({
id: result?.entityId ?? result?.id ?? null,
personId: result?.personId ?? null,
}),
status: 201,
},
update: {
commandId: 'customers.people.update',
schema: z.object({ id: z.string().uuid() }).passthrough(),
mapInput: async ({ parsed, raw, ctx }) => {
const { translate } = await resolveTranslations()
const scoped = withScopedPayload(raw, ctx, translate)
const { base, custom } = splitCustomFieldPayload(scoped)
return Object.keys(custom).length ? { ...base, customFields: custom } : base
},
response: () => ({ ok: true }),
},
delete: {
commandId: 'customers.people.delete',
mapInput: ({ parsed }) => ({ id: parsed.id }),
response: () => ({ ok: true }),
},
}
Command Integration Benefits
- Transactional Consistency - Commands handle all business logic in transactions
- Audit Logging - Automatic operation logging with undo support
- Event Emission - Commands emit events for side effects
- Search Indexing - Automatic index updates
- Undo Support - Full snapshot-based undo capabilities
events & indexer
Controls side effects when not using Commands (Commands handle this internally).
events: Emit standard<module>.<entity>.<action>events.indexer: Emitquery_indexevents to keep the search index in sync.
Hooks
Lifecycle hooks allow injecting logic before/after operations.
hooks: {
beforeList: async (query, ctx) => { /* ... */ },
afterList: async (response, ctx) => { /* ... */ },
beforeCreate: async (input, ctx) => { /* ... */ },
afterCreate: async (entity, ctx) => { /* ... */ },
// ... update/delete hooks
}
Response Caching
The CRUD factory includes sophisticated caching to improve performance:
Cache Configuration
// Environment variables
ENABLE_CRUD_API_CACHE = true; // Enable caching globally
SF_CRUD_CACHE_DEBUG = true; // Debug cache hits/misses
Cache Key Generation
Cache keys include:
- Resource path (
/api/customers/people) - Query parameters (sorted and serialized)
- Tenant ID
- Selected organization ID
- Organization scope (array of allowed org IDs)
- Active-enricher signature (only when the route has enrichers active for the caller)
// Example cache key structure
crud|customers.customer_entity|GET|/api/customers/people|tenant:123|selectedOrg:456|scope:456,789|query:page=1&pageSize=50&search=test
Response Enrichers and the List Cache
When a route opts into response enrichers, the cache stores the enriched payload and appends an enrichers:<signature> segment to the key, derived from the enrichers active for the caller after ACL + tenant filtering. This partitions entries by feature cohort, so a caller never receives ACL-gated fields enriched for a different cohort.
On a cache hit the factory re-runs the active enrichers unless every one of them sets cacheableOnListHit: true — in which case the stored enriched fields are served directly (the fast path). Non-cacheable enrichers (cross-module reads, time-relative values, cross-table aggregates) keep re-running on every hit so the response stays fresh, and for those routes the cache stores the base pre-enrichment payload. See the cacheableOnListHit guidance for when to opt in.
Automatic Invalidation
Cache invalidation happens automatically when:
- Mutations occur via commands (CREATE, UPDATE, DELETE)
- Events are emitted with entity identifiers
- Cache tags match the mutated resource
Cache Headers
Responses include cache status headers:
x-om-cache: hit|miss- Cache statusx-om-partial-index- Partial index warnings (JSON)
Performance Features
Profiling
Enable profiling for performance analysis:
# Enable profiling for all operations
SF_PROFILE=*
# Profile specific modules
SF_PROFILE=customers.*
# Profile CRUD operations only
SF_CRUD_PROFILE=true
Query Optimization
The factory automatically:
- Uses Query Engine for complex queries (avoids N+1 problems)
- Implements cursor-based pagination
- Supports batch export operations
- Caches custom field definitions
- Parallelizes custom field loading
Routes can further trim per-request work by passing a function-form fields projection that drops large detail-only columns from grid listings, avoiding fetching and decrypting blobs the list never renders.
Export Performance
For large datasets, exports use:
- Batch processing (default: 1000 records)
- Configurable batch sizes
- Memory-efficient streaming
- Background processing for very large exports
Event System Integration
Automatic Events
When using commands, events are emitted automatically:
customers.person.createdcustomers.person.updatedcustomers.person.deleted
Indexer Integration
Search index updates are handled automatically:
- Creates
query_indexevents - Supports partial index detection
- Updates related entities (e.g., when deleting a person, updates deal indexes)
Security & Multi-tenancy
Authentication & Authorization
metadata: {
GET: {
requireAuth: true,
requireFeatures: ['customers.people.view'],
requireRoles: ['admin'], // Optional role requirements
},
POST: {
requireAuth: true,
requireFeatures: ['customers.people.create'],
},
// ...
}
Organization Scoping
The factory automatically:
- Resolves organization context from authentication
- Applies organization filters to queries
- Validates organization access for mutations
- Supports cross-organization queries (admin only)
Tenant Isolation
- Hard requirement: All queries must specify
tenantId - Automatic scoping: Tenant filters applied to all operations
- Validation: Commands validate tenant access
Error Handling
Built-in Error Types
CrudHttpError- Structured API errors- Zod validation errors - Automatic 400 responses
- Database constraint errors - Converted to user-friendly messages
- Authentication errors - 401 responses
- Authorization errors - 403 responses
Error Response Format
{
"error": "Validation failed",
"details": [
{
"field": "email",
"message": "Invalid email format"
}
]
}
Hooks System
Lifecycle hooks allow injecting custom logic:
hooks: {
beforeList: async (query, ctx) => {
// Pre-processing query
console.log('Listing with filters:', query)
},
afterList: async (response, ctx) => {
// Post-processing response
response.items.forEach(item => {
item.processed = true
})
},
beforeCreate: async (input, ctx) => {
// Validate business rules
if (input.priority === 'high' && !ctx.auth.roles.includes('manager')) {
throw new CrudHttpError(403, { error: 'Insufficient permissions for high priority' })
}
return { ...input, createdBy: ctx.auth.sub }
},
afterCreate: async (entity, ctx) => {
// Side effects
await sendNotification(ctx.container, 'Task created', entity)
},
// Similar for update/delete
}
Testing
Unit Testing Commands
describe("customers.people.create", () => {
it("creates person with custom fields", async () => {
const { result } = await executeCommand("customers.people.create", {
displayName: "John Doe",
"cf:department": "Engineering",
});
expect(result.entityId).toBeDefined();
});
});
API Integration Testing
describe("GET /api/customers/people", () => {
it("returns paginated results", async () => {
const response = await app.request(
"/api/customers/people?page=1&pageSize=10"
);
expect(response.status).toBe(200);
const data = await response.json();
expect(data.items).toHaveLength(10);
expect(data.total).toBeGreaterThan(10);
});
});
Custom Fields
The factory seamlessly integrates with the EAV system:
- Reads:
list.fieldscan includecf:<key>to fetch values.customFieldSourcesallow fetching fields from related profiles. - Writes: Use
parseWithCustomFieldsandsetCustomFieldsIfAnyhelpers inside your command.