Step 4: Create the data API
API handlers live under api/<method>/<path>.ts. Instead of hand-coding every method, lean on the CRUD factory from @open-mercato/core—it wraps validation, RBAC metadata, DI wiring, and event emission so each module stays consistent.
1. Implement a domain service
mkdir -p packages/inventory/src/modules/inventory/services
touch packages/inventory/src/modules/inventory/services/inventory-service.ts
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) {
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.persistAndFlush(item);
return item;
}
async update(id: string, input: UpsertInventoryItemInput) {
const item = await this.em.findOneOrFail(InventoryItemEntity, {
id,
tenant_id: input.tenantId,
deleted_at: null,
});
item.organization_id = input.organizationId;
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();
}
}
Wire the service in di.ts:
di.ts
import { asClass } from 'awilix';
import type { AppContainer } from '@open-mercato/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 router
mkdir -p packages/inventory/src/modules/inventory/api/items
touch packages/inventory/src/modules/inventory/api/items/route.ts
api/items/route.ts
import { createCrudRouter } from '@open-mercato/core/modules/api/crud-router';
import type { AppRouteHandlerContext } from '@open-mercato/shared/modules/api/types';
import { upsertInventoryItemSchema } from '../../data/validators';
export const { GET, POST, PATCH, DELETE, metadata } = createCrudRouter({
entityName: 'inventory_item',
requireAuth: true,
requireFeatures: {
list: ['inventory.view'],
create: ['inventory.create'],
update: ['inventory.edit'],
delete: ['inventory.delete'],
},
validator: upsertInventoryItemSchema,
resolveServices: ({ container }: AppRouteHandlerContext) => ({
inventoryService: container.resolve('inventoryService'),
}),
list: async ({ inventoryService, auth }) =>
inventoryService.list({ tenantId: auth.tenantId, organizationId: auth.organizationId }),
create: async ({ inventoryService, auth }, input) =>
inventoryService.create({ ...input, tenantId: auth.tenantId }),
retrieve: async ({ inventoryService, auth }, id) =>
inventoryService.findOne(id, auth.tenantId),
update: async ({ inventoryService, auth }, id, input) =>
inventoryService.update(id, { ...input, tenantId: auth.tenantId }),
remove: async ({ inventoryService, auth }, id) =>
inventoryService.remove(id, auth.tenantId),
});
The factory:
- Generates
GET /,GET /:id,POST,PATCH, andDELETEhandlers with consistent naming. - Derives per-method metadata so RBAC and auth guards run automatically.
- Validates request bodies against your zod schema and translates errors into HTTP responses.
- Injects the Awilix container, making services accessible without direct imports.
- Emits CRUD lifecycle events that indexing and workflow subscribers rely on.
Once you restart the dev server, the new API is available at /api/items.
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 emitted events.
- Extensibility – override individual handlers (for example, add advanced filters in
list) while keeping the rest untouched.