Skip to main content

API Development Guide

This guide provides a comprehensive overview of creating REST APIs in the saasframe application. It covers the key components: CRUD factory, commands, query engine, custom fields, and OpenAPI generation.

API Architecture Overview

Core Components

1. CRUD Factory

The CRUD factory (makeCrudRoute) is the primary tool for creating REST APIs. It provides:

  • Automatic HTTP method handling (GET, POST, PUT, DELETE)
  • Multi-tenant scoping (organization and tenant filtering)
  • Query engine integration for complex listing with filtering/sorting
  • Custom fields support (EAV pattern)
  • Command bus integration for mutations
  • Event emission and search indexing
  • Caching and performance optimizations
  • OpenAPI documentation generation

Basic CRUD Factory Usage

packages/<pkg>/src/modules/example/api/todos/route.ts
import { z } from "zod";
import { makeCrudRoute } from "@saasframe/shared/lib/crud/factory";
import { TodoEntity } from "../../data/entities";
import { E } from "@saasframe/core/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(),
status: 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,
// Automatic tenant/org scoping
},
list: {
schema: querySchema,
entityId: E.example.todo,
fields: ["id", "title", "status", "created_at"],
buildFilters: async (query, ctx) => {
const filters: Record<string, any> = {};
if (query.search) filters.title = { $ilike: `%${query.search}%` };
if (query.status) filters.status = { $eq: query.status };
return filters;
},
},
actions: {
create: { commandId: "example.todos.create" },
update: { commandId: "example.todos.update" },
delete: { commandId: "example.todos.delete" },
},
});

export const { GET, POST, PUT, DELETE } = crud;

2. Commands (Business Logic)

Commands encapsulate business logic for mutations (Create, Update, Delete). They provide:

  • Transactional consistency with snapshot/undo support
  • Audit logging and operation tracking
  • Event emission for side effects
  • Search index updates
  • Custom field handling

Command Structure

packages/<pkg>/src/modules/example/commands/todos.ts
import { registerCommand } from "@saasframe/shared/lib/commands";
import type { CommandHandler } from "@saasframe/shared/lib/commands";
import {
parseWithCustomFields,
emitCrudSideEffects,
} from "@saasframe/shared/lib/commands/helpers";

type CreateTodoInput = {
title: string;
description?: string;
customFields?: Record<string, unknown>;
};

const createTodoCommand: CommandHandler<CreateTodoInput, { todoId: string }> = {
id: "example.todos.create",
async execute(rawInput, ctx) {
// Parse input with custom fields support
const { parsed, custom } = parseWithCustomFields(
todoCreateSchema,
rawInput
);

const em = ctx.container.resolve("em").fork();
const todo = em.create(TodoEntity, {
title: parsed.title,
description: parsed.description,
organizationId: parsed.organizationId,
tenantId: parsed.tenantId,
});

em.persist(todo);
await em.flush();

// Handle custom fields
if (Object.keys(custom).length > 0) {
const de = ctx.container.resolve("dataEngine");
await setCustomFieldsIfAny({
dataEngine: de,
entityId: E.example.todo,
recordId: todo.id,
organizationId: todo.organizationId,
tenantId: todo.tenantId,
values: custom,
notify: true,
});
}

// Emit events and update search index
await emitCrudSideEffects({
dataEngine: ctx.container.resolve("dataEngine"),
action: "created",
entity: todo,
identifiers: {
id: todo.id,
organizationId: todo.organizationId,
tenantId: todo.tenantId,
},
indexer: { entityType: E.example.todo },
});

return { todoId: todo.id };
},
// Snapshot for undo support
captureAfter: async (input, result, ctx) => {
// Return snapshot of created entity for undo
return await loadTodoSnapshot(ctx.container.resolve("em"), result.todoId);
},
buildLog: async ({ result, ctx }) => {
return {
actionLabel: "Create todo",
resourceKind: "example.todo",
resourceId: result.todoId,
// ... audit details
};
},
undo: async ({ logEntry, ctx }) => {
// Undo logic using snapshot
const em = ctx.container.resolve("em").fork();
const todoId = logEntry.resourceId;
const todo = await em.findOne(TodoEntity, { id: todoId });
if (todo) {
await em.remove(todo).flush();
}
},
};

registerCommand(createTodoCommand);

3. Query Engine

The Query Engine provides advanced data retrieval capabilities:

  • Complex filtering with JSON operators ($ilike, $in, $gte, etc.)
  • Multi-table joins and relations
  • Custom field hydration (EAV pattern)
  • Pagination and sorting
  • Performance optimization

Query Engine Integration

const crud = makeCrudRoute({
// ...
list: {
entityId: E.customers.customer_person_profile,
fields: [
"id",
"display_name",
"primary_email",
"cf:priority", // Custom field
"cf:tags", // Another custom field
],
buildFilters: async (query, ctx) => {
const filters: Record<string, any> = { kind: { $eq: "person" } };

// Text search
if (query.search) {
filters.display_name = { $ilike: `%${query.search}%` };
}

// Custom field filters
const cfFilters = await buildCustomFieldFiltersFromQuery({
entityIds: [E.customers.customer_entity],
query,
em: ctx.container.resolve("em"),
tenantId: ctx.auth.tenantId,
});
Object.assign(filters, cfFilters);

return filters;
},
customFieldSources: [
{
entityId: E.customers.customer_person_profile,
table: "customer_people",
alias: "person_profile",
recordIdColumn: "id",
join: { fromField: "id", toField: "entity_id" },
},
],
joins: [
{
alias: "tags",
table: "customer_tag_assignments",
from: { field: "id" },
to: { field: "entity_id" },
type: "left",
},
],
},
// ...
});

4. Custom Fields (EAV Pattern)

Custom fields use Entity-Attribute-Value pattern for dynamic field support:

  • Runtime field definition without schema changes
  • Multi-tenant field scoping
  • Type safety with validation
  • Search and filtering support

Custom Field Integration

// In command - handle custom fields
const { parsed, custom } = parseWithCustomFields(schema, rawInput);

if (Object.keys(custom).length > 0) {
await setCustomFieldsIfAny({
dataEngine: de,
entityId: E.example.todo,
recordId: record.id,
organizationId,
tenantId,
values: custom,
notify: true,
});
}

// In query - include custom fields
const crud = makeCrudRoute({
list: {
fields: ["id", "title", "cf:priority", "cf:tags"],
customFieldSources: [
{
entityId: E.example.todo,
table: "todos",
recordIdColumn: "id",
},
],
},
});

5. OpenAPI Generation

Automatic OpenAPI specification generation from route definitions:

openapi.ts
export const createTodosCrudOpenApi = createCrudOpenApiFactory({
defaultTag: "Todos",
});

export const openApi = createTodosCrudOpenApi({
resourceName: "Todo",
pluralName: "Todos",
querySchema: listSchema,
listResponseSchema: createPagedListResponseSchema(todoItemSchema),
create: {
schema: todoCreateSchema,
responseSchema: createResponseSchema,
description: "Creates a new todo item",
},
update: {
schema: todoUpdateSchema,
responseSchema: okResponseSchema,
description: "Updates an existing todo item",
},
});

Step-by-Step API Creation Workflow

Step 1: Define Data Model

Create MikroORM entities and validators:

packages/<pkg>/src/modules/example/data/entities.ts
import { Entity, Property } from "@mikro-orm/postgresql";

@Entity({ tableName: "todos" })
export class TodoEntity {
@Property({ primary: true })
id: string;

@Property()
title: string;

@Property({ nullable: true })
description?: string;

@Property()
status: string;

@Property()
organizationId: string;

@Property()
tenantId: string;

@Property()
createdAt: Date;

@Property({ nullable: true })
updatedAt?: Date;
}
packages/<pkg>/src/modules/example/data/validators.ts
import { z } from "zod";

export const todoCreateSchema = z.object({
title: z.string().min(1).max(255),
description: z.string().optional(),
status: z.enum(["pending", "in_progress", "completed"]),
organizationId: z.string().uuid(),
tenantId: z.string().uuid(),
});

export const todoUpdateSchema = todoCreateSchema.partial().extend({
id: z.string().uuid(),
});

Step 2: Create Commands

Implement business logic with commands:

packages/<pkg>/src/modules/example/commands/todos.ts
// Create, Update, Delete commands with snapshots and undo support

Step 3: Create API Route

Use CRUD factory to wire everything together:

packages/<pkg>/src/modules/example/api/todos/route.ts
// Full CRUD route with query engine, custom fields, commands

Step 4: Add Custom Fields Support

Define custom entities in ce.ts:

packages/<pkg>/src/modules/example/ce.ts
export const entities = [
{
id: E.example.todo,
name: "Todo",
fields: [
{
key: "priority",
label: "Priority",
kind: "select",
options: ["low", "medium", "high"],
},
],
},
];

Step 5: Configure ACL Features

Define module features in acl.ts:

packages/<pkg>/src/modules/example/acl.ts
export const features = [
"example.todos.view",
"example.todos.create",
"example.todos.edit",
"example.todos.delete",
];

Step 6: Generate and Test

# Generate module registry
yarn generate

# Run tests
yarn test

# Generate database migrations
yarn db:generate

Advanced Patterns

Custom Endpoints

For non-CRUD operations:

packages/<pkg>/src/modules/example/api/todos/bulk-update/route.ts
export async function POST(request: Request, { auth }: RequestContext) {
const container = await createRequestContainer();
const commandBus = container.resolve("commandBus");

const body = await request.json();
const results = await Promise.all(
body.todos.map((todo) =>
commandBus.execute("example.todos.update", { ...todo, auth })
)
);

return NextResponse.json({ results });
}

export const metadata = {
POST: {
requireAuth: true,
requireFeatures: ["example.todos.edit"],
},
};

Complex Queries with Joins

const crud = makeCrudRoute({
list: {
entityId: E.example.todo,
fields: ["id", "title", "assignee.name"],
joins: [
{
alias: "assignee",
table: "users",
from: { field: "assignee_user_id" },
to: { field: "id" },
type: "left",
},
],
buildFilters: async (query) => {
// Filters can reference joined tables
return {
"assignee.name": { $ilike: `%${query.assigneeSearch}%` },
};
},
},
});

Event-Driven Architecture

Commands automatically emit events for side effects:

// Events are emitted automatically by commands
// Listen in subscribers
export default {
event: "example.todo.created",
persistent: true,
handler: async (event, ctx) => {
// Send notification, update cache, etc.
},
};

Best Practices

  1. Always use commands for mutations - never direct ORM calls in routes
  2. Validate all inputs with Zod schemas colocated with entities
  3. Respect tenant boundaries - let the framework handle scoping
  4. Use custom fields for user-extensible data
  5. Implement undo support in commands for data safety
  6. Document APIs with OpenAPI schemas for frontend integration
  7. Test thoroughly - include command tests and API integration tests

Troubleshooting

Common Issues

  • "Entity not found" - Check entity ID references in E.generated.ts
  • Custom fields not saving - Ensure ce.ts exports custom entities
  • OpenAPI not generating - Check route exports openApi constant
  • Commands not found - Run yarn generate and verify command-loaders.generated.ts includes the command file and expected ID
  • Tenant scoping issues - Check requireAuth and organization context

Performance Considerations

  • Use query engine for complex lists with joins
  • Cache responses with built-in caching (enabled by default)
  • Index custom fields for searchable fields
  • Batch operations for bulk updates
  • Profile slow queries with SF_PROFILE=*