Skip to main content

Step 4: Create the data API

API handlers live under api/<path>/route.ts. Instead of hand-coding every method, lean on the CRUD factory (makeCrudRoute) from @saasframe/shared -- it wraps validation, RBAC metadata, multi-tenant scoping, and event emission so each module stays consistent.

1. Implement a domain service

mkdir -p apps/saasframe/src/modules/inventory/services
touch apps/saasframe/src/modules/inventory/services/inventory-service.ts
apps/saasframe/src/modules/inventory/services/inventory-service.ts
import type { EntityManager } from '@mikro-orm/core';
import { InventoryItemEntity } from '../data/entities';
import type { UpsertInventoryItemInput } from '../data/validators';

export class InventoryService {
constructor(private readonly em: EntityManager) {}

async list(params: { tenantId: string; organizationId?: string }) {
return this.em.find(
InventoryItemEntity,
{
tenant_id: params.tenantId,
...(params.organizationId ? { organization_id: params.organizationId } : {}),
deleted_at: null,
},
{ orderBy: { name: 'asc' } },
);
}

async findOne(id: string, tenantId: string) {
return this.em.findOneOrFail(InventoryItemEntity, {
id,
tenant_id: tenantId,
deleted_at: null,
});
}

async create(input: UpsertInventoryItemInput & { tenantId: string; organizationId: string }) {
const item = this.em.create(InventoryItemEntity, {
tenant_id: input.tenantId,
organization_id: input.organizationId,
sku: input.sku,
name: input.name,
quantity: input.quantity,
location: input.location ?? null,
});
await this.em.persist(item).flush();
return item;
}

async update(id: string, input: UpsertInventoryItemInput & { tenantId: string }) {
const item = await this.em.findOneOrFail(InventoryItemEntity, {
id,
tenant_id: input.tenantId,
deleted_at: null,
});
item.sku = input.sku;
item.name = input.name;
item.quantity = input.quantity;
item.location = input.location ?? null;
await this.em.flush();
return item;
}

async remove(id: string, tenantId: string) {
const item = await this.em.findOneOrFail(InventoryItemEntity, { id, tenant_id: tenantId });
item.deleted_at = new Date();
await this.em.flush();
}
}

Update di.ts to wire the service into the Awilix container:

apps/saasframe/src/modules/inventory/di.ts
import { asClass } from 'awilix';
import type { AppContainer } from '@saasframe/shared/lib/di/container';
import { InventoryService } from './services/inventory-service';

export function register(container: AppContainer) {
container.register({
inventoryService: asClass(InventoryService).scoped(),
});
}

Use .scoped() so each request receives a fresh instance bound to the request-scoped EntityManager.

2. Create a CRUD route

The platform uses makeCrudRoute from @saasframe/shared/lib/crud/factory to generate consistent REST endpoints. Each route file must also export per-method metadata for RBAC guards and an openApi object for API documentation.

mkdir -p apps/saasframe/src/modules/inventory/api/items
touch apps/saasframe/src/modules/inventory/api/items/route.ts
apps/saasframe/src/modules/inventory/api/items/route.ts
import { z } from 'zod';
import { makeCrudRoute } from '@saasframe/shared/lib/crud/factory';
import { InventoryItemEntity } from '../../data/entities';
import { upsertInventoryItemSchema } from '../../data/validators';

const listSchema = z.object({
page: z.coerce.number().min(1).default(1),
pageSize: z.coerce.number().min(1).max(100).default(50),
search: z.string().optional(),
}).passthrough();

const routeMetadata = {
GET: { requireAuth: true, requireFeatures: ['inventory.view'] },
POST: { requireAuth: true, requireFeatures: ['inventory.create'] },
PUT: { requireAuth: true, requireFeatures: ['inventory.edit'] },
DELETE: { requireAuth: true, requireFeatures: ['inventory.delete'] },
};

export const metadata = routeMetadata;

const crud = makeCrudRoute({
metadata: routeMetadata,
orm: {
entity: InventoryItemEntity,
idField: 'id',
orgField: 'organization_id',
tenantField: 'tenant_id',
softDeleteField: 'deleted_at',
},
list: {
schema: listSchema,
},
create: {
schema: upsertInventoryItemSchema,
},
update: {
schema: upsertInventoryItemSchema,
},
});

export const GET = crud.GET;
export const POST = crud.POST;
export const PUT = crud.PUT;
export const DELETE = crud.DELETE;

export const openApi = {};

makeCrudRoute handles the following automatically:

  • Multi-tenant scoping -- filters every query by tenant_id and organization_id from the authenticated session.
  • Soft deletes -- excludes rows where deleted_at is set.
  • Validation -- validates request bodies against your zod schema and translates errors into structured HTTP responses.
  • RBAC -- per-method metadata ensures auth guards run before the handler.
  • Pagination -- the list handler supports page and pageSize query parameters out of the box.

Once you restart the dev server, the new API is available at /api/items.

API route conventions

Every API route file must export an openApi object (even if empty initially) for automatic API documentation generation. The metadata export controls per-method auth and RBAC guards. See the CRUD Factory reference for advanced options like custom filters, search integration, event emission, and response caching.

Why the CRUD factory matters

  • Less boilerplate -- the factory handles transport concerns so you focus on domain logic.
  • Consistency -- every module shares response shapes, error handling, and pagination.
  • Extensibility -- override individual handlers (for example, add advanced filters in list.buildFilters) while keeping the rest untouched.