Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 115 additions & 49 deletions docs/04-reading-the-diagrams.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,14 @@ The ER diagram shows **two things**: the entities (the named concepts) and the
entity — its attributes, actions, and the invariants that govern it — lives in
the Markdown sections *below* the diagram, not inside it.

So the boxes are intentionally **empty** — each entity is just a plain labeled
box with no rows inside it:
So the boxes are usually **empty** — each entity is just a plain labeled box
with no rows inside it:

```mermaid
erDiagram
Policy {}
Project {}
User {}
Policy {}
```

Three entities, no connections drawn yet. In a generic ER diagram a box would
Expand All @@ -41,6 +41,9 @@ look at the raw Mermaid *source* behind the diagram, each entity is written
won't see the braces in the rendered picture — just the empty box.) **The
diagram is the structure; the text is the detail.**

The one exception is an entity related to *itself*, which appears as a row
inside its own box — see [Self-relationships](#self-relationships-live-inside-the-box).

## The lines: relationships and cardinality

A line between two entities is a relationship. The **symbols at each end** tell
Expand All @@ -56,13 +59,15 @@ modelith uses just two endpoint symbols:
| `>○` (crow's foot + circle) | **zero or many** |

Combine the two ends and you get the four cardinalities a model can declare.
Each example below is exactly what modelith emits for that cardinality.
Each example below is exactly what modelith emits for that cardinality when the
relationship declares no role and no ownership — which is why the lines are
dashed and the labels empty. Both are explained in the next two sections.

### `1:1` — one to one

```mermaid
erDiagram
User ||--|| Profile : "has"
User ||..|| Profile : ""
```

A bar (`|`) at both ends: **one** `User` relates to **one** `Profile`, and vice
Expand All @@ -72,7 +77,7 @@ versa.

```mermaid
erDiagram
Project ||--o{ Policy : "owned"
Project ||..o{ Policy : ""
```

A bar at `Project`, a crow's foot at `Policy`: **one** `Project` relates to
Expand All @@ -83,7 +88,7 @@ A bar at `Project`, a crow's foot at `Policy`: **one** `Project` relates to

```mermaid
erDiagram
Policy }o--|| Project : "referenced"
Policy }o..|| Project : ""
```

The mirror of the above, declared from the *many* side. **Many** `Policies` to
Expand All @@ -94,34 +99,97 @@ which one you see just reflects which entity declared it.

```mermaid
erDiagram
User }o--o{ Project : "Owner or Member"
User }o..o{ Project : ""
```

A crow's foot at both ends: **many** `Users` relate to **many** `Projects`. A
`User` can be in several `Projects`; a `Project` can have several `Users`.

## The line style: owned vs referenced

The line itself is either **solid** or **dashed**, and that is where a
relationship's `ownership` shows up:

| Line | Means |
| --- | --- |
| **solid** (`--`) | **`owned`** (composition, an *identifying* relationship): the related entity is a *part of* this one and can't exist without it — delete the parent and it goes too. |
| **dashed** (`..`) | **`referenced`** (a *non-identifying* relationship): the related entity is independent; this one merely points at it. This is the default when a relationship doesn't say. |

```mermaid
erDiagram
Project ||--o{ Policy : ""
Project }o..o{ User : ""
```

A `Policy` is `owned` by its `Project` — solid. A `Project` merely *references*
the `Users` on it — dashed; deleting the `Project` doesn't delete the `Users`.

Ownership belongs to the relationship, not to the end that declared it. If a
`Project` says it owns its `Policies` and the `Policy` says it references its
`Project`, that's one identifying relationship seen from both ends, so it draws
as a single solid line — provided the two cardinalities are inverses. The two
ends may name *different* roles, and usually do: the `Project` end calls it
`Policies`, the `Policy` end calls it `Project`. The line is labelled with the
owning end's role (with neither end owning, the role from the entity that sorts
first). The other role isn't lost — it's in that entity's relationship list in
the Markdown below the diagram.

Anything the renderer can't reduce to one relationship stays two lines, so
nothing you wrote vanishes from the picture:

- If both ends claim `owned`, that's a contradiction — a relationship is owned
by at most one end. Both lines draw, and `modelith lint` reports it as an
error.
- If one entity declares two relationships to the same entity that differ in
`ownership` or in role, those are two relationships, and both draw.
- If one end declares the same relationship *twice* and the other declares it
back once, there's no way to tell which two of the three are the pair. Every
declaration draws its own line, and `modelith lint` warns that the pairing is
ambiguous. Declaring each relationship from one end only clears it up.

## The labels on the lines

Every line carries a quoted label. It comes from the first of these that the
model provides, so a label means one of three things:

1. **A role** describing the relationship — e.g. `"Owner or Member"`. The most
descriptive label; written when the relationship plays a named part in the
domain.
2. **Ownership** — `"owned"` or `"referenced"`:
- **`owned`** (composition): the related entity is a *part of* this one and
can't exist without it — delete the parent and it goes too. A `Policy` is
`owned` by its `Project`.
- **`referenced`**: the related entity is independent; this one merely points
at it. A `Project` *references* the `Users` on it — deleting the `Project`
doesn't delete the `Users`.
3. **The raw cardinality** (e.g. `"1:n"`) — a fallback when neither a role nor
ownership was specified.

Crow's-foot notation has no glyph for ownership, so **the label is the only
place owned-vs-referenced appears in the diagram.** Worth internalizing: two
lines can look identical and mean very different things depending on whether the
label says `owned` or `referenced`.
A label on a line is the relationship's **`role`** — the part the related entity
plays, e.g. `"Owner or Member"`. Nothing else is ever written there: a
relationship with no role gets an empty label.

```mermaid
erDiagram
Project }o..o{ User : "Owner or Member"
```

That's a deliberate diet. Ownership is in the line style, and the exact
cardinality (`1:0..1`, `1:2`) is in the per-entity table below the diagram —
spending label space on either would crowd out the roles and, for long text,
collide with neighbouring lines. Keep a `role` to a short role name, ideally a
glossary term; `modelith lint` warns when a role reads as prose and points you
at the relationship's `note` field instead.

## Self-relationships live inside the box

When an entity relates to *itself* — a `Project` that replaced an earlier one, a
`Task` that blocks another `Task` — the relationship is drawn as a **row inside
that entity's box** rather than as a line looping back on it:

```mermaid
erDiagram
Policy {}
Project {
Project self "1:0..1 — Predecessor"
}
Project ||--o{ Policy : ""
```

Read the row as: one `Project` relates to `0..1` other `Project`, which plays
the role `Predecessor`. Because there's no line to carry it, the row spells out
the declared cardinality in full — both sides, since the two ends of a line are
what the row replaces — the word `owned` when the relationship is owned, and the
role. An entity with several self-relationships gets one row each (`self`,
`self2`, …).

This is a layout necessity, not a modeling statement: Mermaid's ER layout has no
self-loop handling and draws an arc that swamps the rest of the diagram. See
[ADR-0008](https://github.com/stacklok/modelith/blob/main/project-docs/adr/0008-er-diagram-conventions.md).

## What the diagram can't tell you

Expand All @@ -144,30 +212,28 @@ Here is the diagram modelith renders for the [worked example](https://github.com
```mermaid
erDiagram
Policy {}
Project {}
Project {
Project self "1:0..1 — Predecessor"
}
User {}
Policy }o--|| Project : "referenced"
Project }o--o{ User : "Owner or Member"
Project ||--o{ Policy : "owned"
Policy }o--|| Project : ""
Project }o..o{ User : "Owner or Member"
```

Reading each line:

- **`Policy }o--|| Project : "referenced"`** — zero-or-many `Policies` point at
exactly one `Project`; from the `Policy` side, this is a reference to its
owning project.
- **`Project }o--o{ User : "Owner or Member"`** — many-to-many between
`Projects` and `Users`, where a `User`'s role is `Owner` or `Member`.
- **`Project ||--o{ Policy : "owned"`** — one `Project` owns zero-or-many
`Policies`; the `owned` label says the `Policies` are part of the `Project` and
die with it.

Notice the `Project`–`Policy` pair has **two lines** (`referenced` and `owned`):
the example declares that relationship from *both* entities, each with its own
label. modelith keeps both because their labels differ — it's showing you both
points of view. Usually you'll declare a relationship from one side and see a
single line. (The two declarations must agree on cardinality, or `modelith lint`
flags a contradiction.)
Reading it:

- **`Policy }o--|| Project : ""`** — zero-or-many `Policies` to exactly one
`Project`, on a **solid** line: the `Policies` are part of the `Project` and
die with it. The example declares this relationship from *both* entities
(`Project` owns `Policy`; `Policy` references its `Project`) — one
relationship seen from two ends, so it draws once. The two declarations must
have inverse cardinalities and at most one end claiming `owned`, or
`modelith lint` flags the contradiction and both lines draw.
- **`Project }o..o{ User : "Owner or Member"`** — many-to-many between
`Projects` and `Users` on a **dashed** line: a `User`'s role is `Owner` or
`Member`, and neither entity is part of the other.
- **the `Project self` row** — a `Project` optionally points at the archived
`Project` it replaced, its `Predecessor`.

To go deeper on the underlying fields, see the [Schema
Reference](./06-schema-reference.md).
20 changes: 10 additions & 10 deletions docs/05-parking-garage/garage.modelith.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,16 +231,16 @@ erDiagram
Spot {}
Ticket {}
Visit {}
Account ||--o{ Keycard : "owned"
Account ||--o{ Car : "owned"
Garage ||--o{ Spot : "owned"
Garage ||--o{ Kiosk : "owned"
Spot }o--|| Account : "referenced"
Ticket }o--|| Kiosk : "referenced"
Visit }o--|| Spot : "referenced"
Visit }o--|| Car : "referenced"
Visit }o--|| Keycard : "referenced"
Visit ||--|| Ticket : "owned"
Account ||--o{ Keycard : ""
Account ||--o{ Car : ""
Garage ||--o{ Spot : ""
Garage ||--o{ Kiosk : ""
Spot }o..|| Account : ""
Ticket }o..|| Kiosk : ""
Visit }o..|| Spot : ""
Visit }o..|| Car : ""
Visit }o..|| Keycard : ""
Visit ||--|| Ticket : ""
```

## Scenarios
Expand Down
22 changes: 11 additions & 11 deletions docs/05-parking-garage/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,16 +80,16 @@ erDiagram
Spot {}
Ticket {}
Visit {}
Account ||--o{ Keycard : "owned"
Account ||--o{ Car : "owned"
Garage ||--o{ Spot : "owned"
Garage ||--o{ Kiosk : "owned"
Spot ||--|| Account : "referenced"
Ticket }o--|| Kiosk : "referenced"
Visit }o--|| Spot : "referenced"
Visit }o--|| Car : "referenced"
Visit }o--|| Keycard : "referenced"
Visit ||--|| Ticket : "owned"
Account ||--o{ Keycard : ""
Account ||--o{ Car : ""
Garage ||--o{ Spot : ""
Garage ||--o{ Kiosk : ""
Spot ||..|| Account : ""
Ticket }o..|| Kiosk : ""
Visit }o..|| Spot : ""
Visit }o..|| Car : ""
Visit }o..|| Keycard : ""
Visit ||--|| Ticket : ""
```

> **Agent:** This is the minimum useful model — honest to stop here and
Expand All @@ -105,7 +105,7 @@ erDiagram
> **You:** Let's go `n:1` for spot→account. Any of the cars on an account can
> park in any of its spots.

A one-line change to the cardinality; the diagram updates to `Spot }o--|| Account`.
A one-line change to the cardinality; the diagram updates to `Spot }o..|| Account`.

## Pass 2 — the behavior

Expand Down
31 changes: 30 additions & 1 deletion docs/06-schema-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ as derived entities.
| `entity` | string | yes | Target entity name. Must reference a defined entity. |
| `cardinality` | string | yes | Written `left:right` (see below). `1:1`, `1:n`, `n:1`, `n:n` are the common shorthands. |
| `symmetric` | boolean | no | The relationship carries no inherent order: `(a, b)` is the same as `(b, a)`. Only valid on a self-referential relationship or one whose target side is more than one. |
| `role` | string | no | The role the related entity plays. Backtick entity names. |
| `role` | string | no | The **short** role the related entity plays (`Owner`, `Predecessor`) — ideally a glossary term. Backtick entity and glossary names. It is the only label the diagram draws, so prose belongs in `note`; the linter warns on a role that reads as a sentence. |
| `ownership` | enum | no | Is the related entity *part of* this one? `owned` = it can't exist independently (composition: created within, and deleted with, this entity); `referenced` = an independent entity this one points at. Omitted ⇒ `referenced`. |
| `note` | string | no | Freeform note. |

Expand All @@ -154,6 +154,27 @@ relationship there (e.g. on `Project`, not `Policy`). It keeps each link in one
obvious place and reads the way the domain does. Declare from both ends only
when both views genuinely add clarity.

**How a relationship draws.** Three conventions, recorded in
[ADR-0008](https://github.com/stacklok/modelith/blob/main/project-docs/adr/0008-er-diagram-conventions.md)
and covered in full in [Reading the Diagrams](./04-reading-the-diagrams.md):

- **`ownership` is the line style** — solid (identifying) for `owned`, dashed
(non-identifying) for `referenced` and for an omitted `ownership`. It costs no
label space. Ownership belongs to the relationship rather than the end that
declared it, so a parent's `owned` and the child's `referenced` fold into one
solid line when their cardinalities are inverses and each end declares it
once — even when the two ends name different roles, in which case the owning
end's role labels the line and the other stays in the Markdown. Declarations
the renderer can't reduce to one relationship draw as separate lines: mutual
`owned` is a lint error, and a pairing it can't resolve is a lint warning.
- **`role` is the only label** — `ownership` and `cardinality` are never written
on a line. Keep the role short; put the explanation in `note`.
- **A self-referential relationship becomes a row inside the entity's box**
(`Project self "1:0..1 — Predecessor"`), not a line looping back on it.
Mermaid's ER layout has no self-loop handling, and the arc it draws swamps the
diagram. The row carries the declared cardinality in full (both sides — the
two ends of the line it replaces), `owned` when owned, and the role.

## Attribute

| Field | Type | Required | Notes |
Expand Down Expand Up @@ -273,6 +294,8 @@ The JSON Schema covers structure. [`modelith lint`](./07-cli.md) adds:
- a relationship target that doesn't reference a defined entity;
- a relationship declared from both sides with cardinalities that aren't
inverses (e.g. `Project`→`Policy` `1:n` but `Policy`→`Project` `1:1`);
- a relationship declared from both sides where both ends claim
`ownership: owned` — a relationship is owned by at most one end;
- a duplicate invariant `id` (across entity-level *and* model-level
invariants — they share one namespace);
- a scenario `invariants_touched` or an action `preserves` that references an
Expand All @@ -282,6 +305,12 @@ The JSON Schema covers structure. [`modelith lint`](./07-cli.md) adds:
term, role, or actor;
- a relationship `role` that resolves to neither an entity nor a glossary
term — define it in the glossary;
- a relationship `role` that reads as prose (too long for a label, more
than four words, or ending a sentence) — the role is the only label on
the rendered diagram line, so the explanation belongs in `note`;
- a pair where one end declares the same relationship more than once and
the other declares it back, so which is the reciprocal of which can't be
determined — the diagram draws every declaration as its own line;
- an attribute `type` that looks like an enum reference (PascalCase) but
names no defined enum;
- an action `actor` that is neither a defined entity nor a glossary term.
Expand Down
11 changes: 7 additions & 4 deletions examples/example.modelith.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ A small, illustrative model used in the docs and as a golden fixture for the too

- **`Member`** — A `User` granted access to a `Project` without ownership rights.
- **`Owner`** — A `User` with full control of a `Project` — can transfer ownership, archive it, and manage its `Policies`.
- **`Predecessor`** — An archived `Project` that a newer one replaced, kept so the history of the work isn't lost.

## Enums

Expand Down Expand Up @@ -50,6 +51,7 @@ A container for a set of related `Policies`, owned by at least one `User`. `Proj

- `User` — n:n — referenced — `Owner` or `Member` — Must always have at least one `Owner`
- `Policy` — 1:n — owned
- `Project` — 1:0..1 — referenced — `Predecessor` — The archived `Project` this one replaced, if any

**Attributes**

Expand Down Expand Up @@ -88,11 +90,12 @@ A human principal who can own or belong to `Projects`. Identity is managed exter
```mermaid
erDiagram
Policy {}
Project {}
Project {
Project self "1:0..1 — Predecessor"
}
User {}
Policy }o--|| Project : "referenced"
Project }o--o{ User : "Owner or Member"
Project ||--o{ Policy : "owned"
Policy }o--|| Project : ""
Project }o..o{ User : "Owner or Member"
```

## Invariants
Expand Down
Loading
Loading