Chapter 01

The data model

The erDiagram section is the foundation: it decides your tables, your API, your forms and your grids. Everything in later chapters attaches to something you name here, so it is worth getting the names right before you write a single rule.

The shape of a model file

One file, several diagrams, read top to bottom. A section starts at a Mermaid keyword and runs until the next one. Directives above a section configure it; directives inside it annotate what it declares.

emlthe skeleton of every model
%% ---- Section 1: ERD --------------------------------------------------
%%meta name: Enterprise CRM
%%meta kind: erd
%%meta version: 1.0.0

%%category name: Sales Pipeline; icon: TrendingUp; entities: Opportunity, Product
%%enum LeadStatus: new, working, nurturing, qualified, converted, disqualified

erDiagram
    Lead {
        string  id PK
        ...
    }

    Campaign ||--o{ Lead : "generates"

    %%field Lead.status enum: LeadStatus
    %%index Lead(owner_id, status)
    %%entity Lead audited: true

%% ---- Section 2: a business rule --------------------------------------
%%meta kind: rules
%%rule leadScoring on Lead event: beforeCreate priority: 10
flowchart TD
    ...
Read it as Mermaid

Open the file in any Mermaid preview. The ERD draws, the flowcharts draw, the state diagrams draw. The %% lines are invisible there — which is the point: the picture stays honest and the generator still gets its instructions.

Entities and attributes

An entity block is a table. Each line inside is type name [modifiers]. The type comes first, the way Mermaid's ER grammar wants it.

emllanguage/examples/crm.eml.mmd · the Lead entity
    Lead {
        string  id PK
        string  first_name
        string  last_name
        string  company_name
        email   email
        phone   phone OPTIONAL
        string  job_title OPTIONAL
        string  industry OPTIONAL
        integer employee_count OPTIONAL
        decimal annual_revenue OPTIONAL
        string  lead_source
        string  rating OPTIONAL
        integer score OPTIONAL
        string  campaign_id FK OPTIONAL
        string  status
        string  owner_id FK
        datetime converted_at OPTIONAL
        string  disqualification_reason OPTIONAL
    }

Types

Eight canonical types — string, text, integer, decimal, boolean, date, datetime, json — plus aliases that normalize to one of them while carrying intent. email is a string that renders as an email input; phone, url, password and money behave the same way. Use the alias when it says something true; nothing breaks if you use the base type instead.

Modifiers

ModifierMeaningWatch out for
PKPrimary key. Every entity gets string id PK if you omit one.
FKForeign key. The column name decides what it points at.See below — this is the one that catches people.
UKUnique. Emits a unique index.Unique and required means the caller must always supply it.
OPTIONALNullable. Everything is required by default.Mermaid's ER grammar has no equivalent, so ERDs render through a normalizer that strips it.

Foreign keys resolve by name

There is no syntax for "this column points at that table". The generator derives it from the column name, so the name has to carry the reference:

  • account_id → the Account entity. sla_policy_idSlaPolicy.
  • owner_id, manager_id, user_id, and anything ending _by or _by_id → the user entity. A person named by the role they played is still a user; there is no bus_approved_by table.
  • Anything that resolves to nothing is stored as a plain string: no lookup, no display name, a raw UUID in your grids.
The most common modelling mistake

string assigned_to FK looks fine and quietly does the wrong thing — it does not end in _id, so the checker flags EML114 and the column renders as raw text. Either use a name the resolver knows (owner_id, escalated_by_id) or accept that you are storing an opaque string.

Relationships

Standard Mermaid cardinality operators. The label becomes the relationship's name; the glyphs decide which side carries the foreign key.

emla few of the CRM's 39 relationships
    Account     ||--o{ Contact             : "employs"
    Account     ||--o{ Opportunity         : "has pipeline"
    Opportunity ||--o{ OpportunityLineItem : "contains"
    Opportunity ||--o{ Quote               : "is priced by"
    Quote       ||--o{ Contract            : "becomes"
    SlaPolicy   ||--o{ SupportCase         : "applies to"
    User        ||--o{ Opportunity         : "owns"

Declare a relationship for every foreign key you write, including the ownership ones back to User. The checker reports the gap as EML502, and the generated application uses the relationship to build lookups, child panels and the sentence in each window's help text.

The ERD, rendered by the app itself. Seventeen entity boxes and the relationships between them, drawn from the same file the generator compiles. The entity count in the header is the parser's, not a guess.
The generator's design step showing the CRM model's Mermaid source on the left and a rendered ERD of all 17 entities on the right.

Enums: closed vocabularies

Declare the values once, then bind columns to them. This is what turns a status column from a text box into a dropdown, so it matters more than it looks.

emldeclare, then bind
%%enum LeadStatus: new, working, nurturing, qualified, converted, disqualified
%%enum LeadRating: hot, warm, cold
%%enum CasePriority: low, medium, high, critical

    %% inside the erDiagram, after the entity blocks
    %%field Lead.status          enum: LeadStatus
    %%field Lead.rating          enum: LeadRating
    %%field SupportCase.priority enum: CasePriority
What the binding buys you. Lead Source, Rating and Status carry a Dropdown badge and a select holding exactly the modelled values. Score stays an integer; Campaign Id and Owner Id stay lookups. Each enum becomes a list reference in the dictionary, so an administrator can rename a label later without touching the model.
The Lead form in the generated CRM: Lead Source, Rating and Status each carry a Dropdown badge and render as select controls.
Keep enum values machine-shaped

Use closed_won, not "Closed Won". The raw value is what every rule, workflow and state machine compares; the application derives the display label from it (closed_won → "Closed Won"). Change the label in the dictionary, keep the value stable.

Match the state machine exactly

If an entity has a lifecycle, its status enum and its state machine must carry the same set of values. The checker compares them and reports a state missing from the enum (EML426) or an enum value no state uses (EML427). Two lists that drift apart are the classic way a status ends up unreachable.

Indexes

UK gets you a single-column unique index for free. Anything composite — the pairs your lists actually filter and sort by — has to be asked for.

emlthe CRM's composite indexes, chosen from its own screens
    %%index Account(territory_id, status)
    %%index Lead(owner_id, status)
    %%index Opportunity(account_id, stage)
    %%index Opportunity(owner_id, expected_close_date)
    %%index SupportCase(owner_id, priority)
    %%index Activity(owner_id, due_at)

Pick them from the questions the application asks: "my open opportunities by close date" is Opportunity(owner_id, expected_close_date). The column order matters — leading column first.

Grouping the dashboard

%%category decides how the home screen is organised. Without it every entity lands in one undifferentiated "General" block, which is survivable at five entities and unusable at seventeen.

emlthree of the CRM's seven categories
%%category name: Sales Pipeline; description: Opportunities, their line items and the products sold; icon: TrendingUp; color: #0ea5e9; entities: Opportunity, OpportunityLineItem, Product
%%category name: Customer Service; description: Support cases and the service levels that govern them; icon: LifeBuoy; color: #f59e0b; entities: SupportCase, SlaPolicy
%%category name: People and Teams; description: Users, selling teams and organisational structure; icon: Users; color: #64748b; default: true; entities: User, Team

Keys are ;-separated and only name is required. default: true marks the category that catches anything you forgot to assign — at most one document may declare it. The description is rendered under the group heading, so write it for the person using the application, not for yourself.

Entity-level metadata

eml
    %%entity Account     audited: true      -- every change recorded in the audit log
    %%entity Activity    softDelete: true   -- deletes stamp deleted_at instead of removing
    %%entity Product     label: Product Catalogue

Check before you go further

The checker reads the whole document against the language definition and reports errors, warnings and informational notes with line numbers and fixes. Run it while you write, not at the end.

shell
$ bun language/checker.ts language/examples/crm.eml.mmd

Checking crm.eml.mmd
  ✓ No issues found.
  crm.eml.mmd  0 errors · 0 warnings
Aim for a clean run

Errors stop generation. Warnings are things that will bite later — an FK that will not resolve, a state nothing can reach, a decision node with one branch. The infos are usually a missing relationship behind a foreign key you did declare. A model worth generating from reports nothing at all.