| name | data-model-design |
| description | Design entities, relationships, and manage the migration lifecycle. Use when planning a data model, designing entities, choosing relationship patterns, adding cross-module references, or managing database migrations. Triggers on "design entity", "data model", "add entity", "database schema", "migration", "relationship", "many-to-many", "junction table", "foreign key", "jsonb", "add column". |
Data Model Design
Design entities, relationships, and manage the migration lifecycle following Open Mercato conventions.
Table of Contents
- Design Workflow
- Entity Design
- Field Types
- Relationship Patterns
- Cross-Module References
- Migration Lifecycle
- Advanced Patterns
- Anti-Patterns
1. Design Workflow
When the developer describes data requirements:
- Clarify entities — What are the distinct "things" being stored?
- Clarify fields — What data does each entity hold?
- Clarify relationships — How do entities relate? (1:1, 1:N, N:M, cross-module?)
- Choose patterns — Select the right pattern for each relationship
- Generate — Create entity files, validators, and migrations
- Verify — Check migration output, test queries
2. Entity Design
Standard Entity Template
import { Entity, Property, PrimaryKey, Index, Enum } from '@mikro-orm/core'
import { v4 } from 'uuid'
@Entity({ tableName: '<entities>' })
export class <Entity> {
@PrimaryKey({ type: 'uuid' })
id: string = v4()
@Index()
@Property({ type: 'uuid' })
organization_id!: string
@Index()
@Property({ type: 'uuid' })
tenant_id!: string
@Property({ type: 'boolean', default: true })
is_active: boolean = true
@Property({ type: 'timestamptz' })
created_at: Date = new Date()
@Property({ type: 'timestamptz', onUpdate: () => new Date() })
updated_at: Date = new Date()
@Property({ type: 'timestamptz', nullable: true })
deleted_at: Date | null = null
}
Required Columns (Every Tenant-Scoped Entity)
| Column | Type | Purpose | Indexed |
|---|
id | uuid | Primary key (v4 auto-generated) | PK |
organization_id | uuid | Tenant organization scope | Yes |
tenant_id | uuid | Tenant scope | Yes |
is_active | boolean | Soft active/inactive flag | No |
created_at | timestamptz | Creation timestamp | No |
updated_at | timestamptz | Last update (auto) | No |
deleted_at | timestamptz? | Soft delete timestamp | No |
3. Field Types
Type Selection Guide
| Data | MikroORM Type | PostgreSQL Type | Decorator |
|---|
| Short text (name, title) | varchar | varchar(255) | @Property({ type: 'varchar', length: 255 }) |
| Long text (description, notes) | text | text | @Property({ type: 'text' }) |
| Integer | int | integer | @Property({ type: 'int' }) |
| Decimal (money, quantity) | decimal | numeric(precision,scale) | @Property({ type: 'decimal', precision: 10, scale: 2 }) |
| Boolean | boolean | boolean | @Property({ type: 'boolean', default: false }) |
| UUID reference | uuid | uuid | @Property({ type: 'uuid' }) |
| Date only | date | date | @Property({ type: 'date' }) |
| Date + time | timestamptz | timestamptz | @Property({ type: 'timestamptz' }) |
| Enum | varchar | varchar | @Enum({ items: () => MyEnum }) |
| Flexible JSON | jsonb | jsonb | @Property({ type: 'jsonb', nullable: true }) |
| Array of strings | jsonb | jsonb | @Property({ type: 'jsonb', default: '[]' }) |
| Email | varchar | varchar(320) | @Property({ type: 'varchar', length: 320 }) |
| URL | text | text | @Property({ type: 'text' }) |
| Phone | varchar | varchar(50) | @Property({ type: 'varchar', length: 50 }) |
When to Use JSONB
Use jsonb when:
- Schema is flexible/user-defined (custom field values, metadata, tags)
- Data is read as a whole, not queried by individual fields
- Nesting is natural (address objects, configuration maps)
Avoid jsonb when:
- You need to query, filter, or sort by individual fields — use proper columns
- Data has a fixed, well-known schema — use columns for type safety
- You need referential integrity — FKs can't point into JSONB
Enum Pattern
export enum OrderStatus {
DRAFT = 'draft',
PENDING = 'pending',
CONFIRMED = 'confirmed',
SHIPPED = 'shipped',
DELIVERED = 'delivered',
CANCELLED = 'cancelled',
}
@Enum({ items: () => OrderStatus })
status: OrderStatus = OrderStatus.DRAFT
Nullable Fields
@Property({ type: 'varchar', length: 255, nullable: true })
notes: string | null = null
@Property({ type: 'varchar', length: 255 })
name!: string
4. Relationship Patterns
One-to-Many (Same Module)
Parent entity has many children. Use @ManyToOne / @OneToMany decorators only within the same module.
@Entity({ tableName: 'categories' })
export class Category {
@PrimaryKey({ type: 'uuid' })
id: string = v4()
@Property({ type: 'varchar', length: 255 })
name!: string
@OneToMany(() => Product, product => product.category)
products = new Collection<Product>(this)
}
@Entity({ tableName: 'products' })
export class Product {
@PrimaryKey({ type: 'uuid' })
id: string = v4()
@ManyToOne(() => Category)
category!: Category
}
Many-to-Many (Same Module)
Use a junction (pivot) table.
@Entity({ tableName: 'product_tags' })
export class ProductTag {
@PrimaryKey({ type: 'uuid' })
id: string = v4()
@Index()
@Property({ type: 'uuid' })
product_id!: string
@Index()
@Property({ type: 'uuid' })
tag_id!: string
@Index()
@Property({ type: 'uuid' })
organization_id!: string
@Index()
@Property({ type: 'uuid' })
tenant_id!: string
@Property({ type: 'timestamptz' })
created_at: Date = new Date()
}
Junction table rules:
- Always include
organization_id and tenant_id
- Index both FK columns
- Include
created_at for audit trail
- Add extra columns if the relationship has attributes (e.g.,
quantity, sort_order)
One-to-One (Same Module)
@Entity({ tableName: 'user_profiles' })
export class UserProfile {
@PrimaryKey({ type: 'uuid' })
id: string = v4()
@Index({ unique: true })
@Property({ type: 'uuid' })
user_id!: string
@Property({ type: 'text', nullable: true })
bio: string | null = null
}
Self-Referencing (Tree/Hierarchy)
@Entity({ tableName: 'categories' })
export class Category {
@PrimaryKey({ type: 'uuid' })
id: string = v4()
@Property({ type: 'uuid', nullable: true })
parent_id: string | null = null
@Property({ type: 'varchar', length: 255 })
name!: string
@Property({ type: 'text', default: '' })
path: string = ''
@Property({ type: 'int', default: 0 })
depth: number = 0
}
5. Cross-Module References
Critical rule: NO ORM relationships (@ManyToOne, @OneToMany) between entities in different modules.
Pattern: FK ID Only
@Entity({ tableName: 'tickets' })
export class Ticket {
@Index()
@Property({ type: 'uuid' })
customer_id!: string
@Index()
@Property({ type: 'uuid', nullable: true })
assigned_to: string | null = null
}
Fetching Related Data
To display related data from another module, use a Response Enricher (see system-extension skill):
const enricher: ResponseEnricher = {
id: 'tickets.customer-name',
targetEntity: 'tickets.ticket',
async enrichMany(records, context) {
const customerIds = [...new Set(records.map(r => r.customer_id).filter(Boolean))]
const customers = await em.find(Person, { id: { $in: customerIds } })
const nameMap = new Map(customers.map(c => [c.id, c.name]))
return records.map(r => ({
...r,
_tickets: { customerName: nameMap.get(r.customer_id) ?? null },
}))
},
}
Why No ORM Relations Across Modules?
- Module isolation — modules must be independently deployable and ejectable
- Circular dependencies — ORM relations create tight coupling between modules
- Schema ownership — each module owns its entities; cross-module ORM relations blur ownership
- Extension system — UMES enrichers provide the same capability without coupling
6. Migration Lifecycle
Creating a Migration
yarn db:generate
yarn db:migrate
Migration Best Practices
- Review every migration — auto-generated doesn't mean correct
- Check for unintended changes — sometimes generators pick up unrelated diffs
- New columns should have defaults — prevents breaking existing rows
- Never rename columns — add new column, migrate data, remove old column (across releases)
- Never drop tables — soft delete or archive first
Adding a Column to Existing Entity
@Property({ type: 'varchar', length: 100, default: '' })
new_field: string = ''
@Property({ type: 'varchar', length: 100, nullable: true })
new_field: string | null = null
Then:
yarn db:generate
yarn db:migrate
Removing a Column
Don't remove columns in a single step. Instead:
- Stop writing to the column (remove from validators and forms)
- Make the column nullable if it isn't already
- In a later release, drop the column via migration
7. Advanced Patterns
Polymorphic References
When an entity can reference different types:
@Entity({ tableName: 'comments' })
export class Comment {
@PrimaryKey({ type: 'uuid' })
id: string = v4()
@Index()
@Property({ type: 'varchar', length: 100 })
target_type!: string
@Index()
@Property({ type: 'uuid' })
target_id!: string
@Property({ type: 'text' })
body!: string
}
Ordered Collections
When items have a user-defined order:
@Entity({ tableName: 'checklist_items' })
export class ChecklistItem {
@PrimaryKey({ type: 'uuid' })
id: string = v4()
@Index()
@Property({ type: 'uuid' })
checklist_id!: string
@Property({ type: 'int' })
sort_order!: number
@Property({ type: 'varchar', length: 255 })
title!: string
}
Soft Delete Pattern
All entities already include deleted_at. To implement soft delete:
entity.deleted_at = new Date()
entity.is_active = false
await em.flush()
const items = await em.find(Entity, {
organization_id: orgId,
deleted_at: null,
})
Audit/History Table
For tracking changes to an entity:
@Entity({ tableName: 'ticket_history' })
export class TicketHistory {
@PrimaryKey({ type: 'uuid' })
id: string = v4()
@Index()
@Property({ type: 'uuid' })
ticket_id!: string
@Property({ type: 'uuid' })
changed_by!: string
@Property({ type: 'varchar', length: 50 })
action!: string
@Property({ type: 'jsonb', nullable: true })
previous_values: Record<string, unknown> | null = null
@Property({ type: 'jsonb', nullable: true })
new_values: Record<string, unknown> | null = null
@Index()
@Property({ type: 'uuid' })
organization_id!: string
@Index()
@Property({ type: 'uuid' })
tenant_id!: string
@Property({ type: 'timestamptz' })
created_at: Date = new Date()
}
8. Anti-Patterns
| Anti-Pattern | Problem | Correct Pattern |
|---|
@ManyToOne across modules | Tight coupling, breaks module isolation | Store FK as uuid column, use enrichers |
| Storing computed values | Stale data, maintenance burden | Compute on read via enrichers or queries |
Using any for JSONB fields | No type safety | Define a Zod schema, use z.infer |
| Manual migration SQL | Fragile, version-dependent | Use yarn db:generate |
| Renaming columns | Breaks existing data/queries | Add new column, migrate data, drop old |
Missing organization_id | Cross-tenant data leaks | Always include and index |
Using varchar without length | Defaults vary by DB | Always specify length |
| Storing arrays as comma-separated strings | Can't query, no integrity | Use jsonb arrays or junction tables |
| UUID FK without index | Slow joins | Always @Index() on FK columns |
| Nullable required fields | Data integrity issues | Use ! assertion for required, null for optional |
Rules
- MUST include
organization_id and tenant_id on all tenant-scoped entities
- MUST include standard columns (
id, created_at, updated_at, deleted_at, is_active)
- MUST use UUID v4 for primary keys
- MUST index all FK columns and
organization_id / tenant_id
- MUST run
yarn db:generate after entity changes, never hand-write migrations
- MUST review generated migration before applying
- MUST use
nullable: true with = null default for optional fields
- MUST specify
length on all varchar columns
- MUST NOT use ORM relationship decorators across module boundaries
- MUST NOT rename or drop columns in a single release
- MUST NOT store sensitive data without encryption (use
findWithDecryption)
- Use
jsonb for flexible/nested data, proper columns for queryable/sortable data
- Use junction tables for many-to-many relationships
- Derive TypeScript types from Zod schemas, never duplicate type definitions