Skip to main content

Step 3: Create the data structures

Inventory data lives in a module-scoped MikroORM entity. Keep multi-tenancy in mind by including both tenant_id and organization_id.

1. Define the entity

mkdir -p apps/saasframe/src/modules/inventory/data
touch apps/saasframe/src/modules/inventory/data/entities.ts
touch apps/saasframe/src/modules/inventory/data/validators.ts
apps/saasframe/src/modules/inventory/data/entities.ts
import { Entity, Property, PrimaryKey } from '@mikro-orm/core';
import { v4 as uuid } from 'uuid';

@Entity({ tableName: 'inventory_items' })
export class InventoryItemEntity {
@PrimaryKey({ columnType: 'uuid' })
id = uuid();

@Property({ columnType: 'uuid' })
tenant_id!: string;

@Property({ columnType: 'uuid' })
organization_id!: string;

@Property()
sku!: string;

@Property()
name!: string;

@Property({ columnType: 'integer' })
quantity!: number;

@Property({ columnType: 'text', nullable: true })
location?: string;

@Property({ columnType: 'timestamptz', defaultRaw: 'now()' })
created_at: Date = new Date();

@Property({ columnType: 'timestamptz', defaultRaw: 'now()', onUpdate: () => new Date() })
updated_at: Date = new Date();

@Property({ columnType: 'timestamptz', nullable: true })
deleted_at?: Date | null;
}

Each column follows the multi-tenant conventions: tenant_id, organization_id, timestamps, and deleted_at for soft deletes.

Entity field naming

Database columns use snake_case (tenant_id, organization_id, created_at). The makeCrudRoute factory expects the corresponding ORM field names in its configuration -- MikroORM handles the mapping automatically when the entity property name differs from the column name. If you use camelCase property names in your entity class, set columnType explicitly and MikroORM will derive the snake_case column name.

2. Add validators

apps/saasframe/src/modules/inventory/data/validators.ts
import { z } from 'zod';

export const upsertInventoryItemSchema = z.object({
sku: z.string().min(1).max(64),
name: z.string().min(1).max(128),
quantity: z.number().int().min(0),
location: z.string().max(128).optional(),
});

export type UpsertInventoryItemInput = z.infer<typeof upsertInventoryItemSchema>;

Use the same schema for create and update flows -- call it from API handlers, CLI commands, and forms to guarantee consistent validation.

Validator best practices
  • Do not include tenantId or organizationId in your request validator. These are derived from the authenticated session by the CRUD factory and injected automatically -- accepting them from user input would be a security risk.
  • Derive TypeScript types with z.infer<typeof schema> to keep types and runtime validation in sync.

3. Generate migrations

Run the generators and create a migration for the new entity:

yarn generate
yarn db:generate

Migrations appear under apps/saasframe/src/modules/inventory/migrations/ (for app-level modules with from: '@app'). Commit them alongside your entity. When you run yarn db:migrate, the new table will be created across environments.

yarn db:migrate