Summary#
An entity is a table of rows your app stores. But the declaration is not a list of columns — it is everything that is true about those rows:
entity Order {
[Required, Unique, MaxLength(20)] string Code; // the shape …
[Required] decimal Total;
bool Cancelled;
invariant Total >= 0; // … what must hold for a row to exist …
security { allow create, read, update when IsAuthenticated || IsAnonymous; } // … and who may touch it
}Three different questions, answered in one place: what shape is a row (entity members), what makes a row valid (constraints · invariant), and who may see or change it (The security model).
They are enforced for every path that ever touches the table — this function, the next one, the UI, a workflow, an import you write in six months. There is nowhere to write a row that goes around them, so there is nowhere to forget.
Description#
What the platform gives every row#
You never declare an identifier, and you never declare an audit trail:
| Member | What it is |
|---|---|
Id | the row's identity, a Guid, assigned for you |
CreatedAt · ModifiedAt | when the row was written, and last changed |
CreatedBy · ModifiedBy | who — the signed-in principal, not a value your code passes |
CreatedBy is worth pausing on, because it is not just an audit column: it is stamped from the security principal,
so a rule may authorise on it. That is how ownership works with no owner field and nothing in your code that assigns
one:
[Role] enum AppRole { Authenticator, Member }
[Principal]
entity User {
[Required, MaxLength(255)] string Email;
security { allow read where Id == user.Id; }
}
entity Note {
[Required, MaxLength(200)] string Title;
security {
allow create when IsAuthenticated;
allow read, update, delete where CreatedBy == user.Id; // ← ownership, in one line
}
}Every reader of Note now sees only their own — including Note.Count(), which honestly means "how many are mine".
What an unset member reads back#
The declaration decides, and it reads exactly as C# does:
decimal Amount; // has an honest zero → unset reads 0.
decimal? Amount; // `?` → may be unset. Unset reads null.
string Title; // NO honest zero → REQUIRED. You must supply it.Whether a member is required is carried by its type spelling — full model in Optional and required members; the shapes are:
| Type | Bare (no ?, no default) |
|---|---|
int long double decimal bool TimeSpan — an honest zero | reads its zero (0, false, 0m); never null |
string DateTime DateOnly TimeOnly Guid Json RichText Markdown Vector byte[], any enum — no honest zero | REQUIRED — you must supply it |
an entity reference (Customer Owner;) | optional — reads null; [Required] demands it |
any T? (int? string? DateTime? …) | optional — reads null |
The reason those types have no zero is worth a sentence: the year 0001 is not a date anybody meant (it sorts first
and matches Due < today), all-zero bits is not a Guid anyone assigned, and default(string) is null, not "" —
an empty string is a value someone typed, not the absence of one. Rather than invent a value it would then have to
answer questions about, the platform requires you to supply one. Say ? (DateTime? Due;) when "maybe unset" is
genuinely what you mean, or give a default (string Tag = "";) when the empty value really is the right starting point.
A required member is checked when the value becomes real — for an entity, at commit: new Ticket {} compiles
(an empty draft is a normal state — a create-form binds inputs to fill it), and the row is refused at save if a
required member is still unset, naming it. [Required] on a reference opts that one exception into the same rule.
It is also the only constraint that rejects an unset value. [MaxLength], [Unique], [Pattern] and the rest all
let one through, because "no value" is not a violation of "at most 20 characters" (constraints).
The consequence to know about. An invariant reads a member, and a member that can be unset
reads back null — so an invariant over an optional member is asking a question about a value that may not be there:
decimal? Total; // may be unset …
invariant Total >= 0; // … and the invariant reads it — so a row with no Total is REFUSEDThat has quietly made Total mandatory. If a member takes part in an invariant, either give it a type that always has
a value (decimal Total; — the invariant then holds trivially on 0), mark it [Required] (say what you mean), or
guard the invariant with when so it only applies to the rows that have the value.
Where do I write a rule — on the member, or on the row?#
Two kinds, and the distinction is not academic — it decides where you write the rule:
- A constraint speaks about one member:
[Required],[Unique],[MaxLength(n)],[Min]/[Max],[Pattern]. (constraints) - An invariant speaks about the whole row — a relationship between members, optionally guarded so it applies only to the rows it should. (invariant)
entity Product {
[Required, MaxLength(80)] string Name; // a constraint: about Name alone
bool IsPerishable;
int ShelfDays;
invariant ShelfDays <= 30 when IsPerishable // an invariant: about the ROW — two members, conditionally
message "Perishable items can have at most 30 shelf days.";
security { allow create, read, update when IsAuthenticated || IsAnonymous; }
}Both are enforced when the row is written, by the database — not by the function that happens to be writing it. So
a violation arrives as an ordinary ValidationException that a caller can catch (try / catch / finally), and the
faulted write leaves nothing behind:
[Test]
void A_row_that_breaks_its_invariant_is_never_written() {
Assert.Throws<ValidationException>(() => new Product { Name = "Milk", IsPerishable = true, ShelfDays = 90 });
Assert.Empty(Product.ToList());
}
[Test]
void A_member_left_unset_reads_back_its_zero() {
var product = new Product { Name = "Salt" }; // IsPerishable and ShelfDays never set
var stored = Product.Single(p => p.Name == "Salt");
Assert.Equal(0, stored.ShelfDays); // int → 0, exactly as a C# field
Assert.False(stored.IsPerishable); // bool → false
}The ? is how you get the other behaviour — and it is the difference between "the shelf life is zero days" and
"nobody has recorded a shelf life". A type with no meaningful zero is optional whether you write the ? or not:
```osy title="? opts out of the zero; a date has no zero to opt out of" run app=entity-index
entity Delivery {
[Required] string Reference;
int Attempts; // has a zero → unset is 0
int? WeightGrams; // opted out → unset is null
DateTime? ArrivedAt; // no zero → optional (?), so unset reads null (a bare DateTime would be required)
security { allow create, read, update when IsAuthenticated || IsAnonymous; } }
[Test] void The_question_mark_is_what_distinguishes_no_value_from_zero() { var delivery = new Delivery { Reference = "D-1" }; // nothing else set
var stored = Delivery.Single(d => d.Reference == "D-1");
Assert.Equal(0, stored.Attempts); // zero attempts — a real, countable fact Assert.Null(stored.WeightGrams); // nobody weighed it — a different fact Assert.Null(stored.ArrivedAt); // and a date never has a zero to fall back to }
### Relations: the child points up, the parent reads down {#relations}
A child holds a reference to its parent — that member **is** the foreign key. The parent reads its children back
through a **collection** member:
```osy title="both directions, declared once" test app=entity-index-rel
entity Invoice {
[Required, Unique, MaxLength(20)] string Number;
[ForeignKey(Invoice)] Line[] Lines; // the parent reads DOWN
security { allow create, read, update when IsAuthenticated || IsAnonymous; }
}
entity Line {
[Required] Invoice Invoice; // the child points UP — this is the FK
[Required, MaxLength(80)] string Description;
decimal Amount;
security { allow create, read, update when IsAuthenticated || IsAnonymous; }
}Read children through the collection — never with a query filtered by the foreign key. invoice.Lines is the
navigation; Line.Where(l => l.Invoice == invoice) is you re-implementing it by hand, and worse:
[Test]
void A_parent_reads_its_children_through_its_collection() {
var invoice = new Invoice { Number = "INV-1" };
var a = new Line { Invoice = invoice, Description = "Design", Amount = 100m };
var b = new Line { Invoice = invoice, Description = "Build", Amount = 250m };
var found = Invoice.Single(i => i.Number == "INV-1");
Assert.Equal(2, found.Lines.Count);
Assert.Equal(350m, found.Lines[0].Amount + found.Lines[1].Amount);
}A collection has no order. It is a set of rows, exactly as a table is — invoice.Lines[0] is a line, not the
first one you added, and it may differ between runs. When the order matters, ask for it: query the children with an
OrderBy, and you get the order you asked for.
[Required] on the reference is how you say a child cannot exist without its parent — an orphan then becomes a
violation rather than a stray row. See relations, and Include (pre-loading relations) for pre-loading a graph you are
about to walk.
An entity is stored; a class is not#
Declare a class when the value only ever lives in memory — a computed result, a payload you assemble, a shape you
return. It has no table, no Id, no audit columns, and no security { }, because there is nothing stored to secure.
The test is simply: does a row of this outlive the request? If yes, it is an entity. See class methods.
Do I write migrations when the model changes?#
You do not write migrations. osy compile reconciles the database with your source, and the two directions are
deliberately not symmetric:
- Adding is applied. A new entity, a new member, a new index — the schema moves, and it moves quietly.
- Removing is proposed, not executed. Delete a member from your source and the column is not dropped. The compile reports it as a proposed drop and keeps the data; dropping it takes an explicit acknowledgement, and in production an unacknowledged drop is a hard error rather than something that happens while you are not looking.
That asymmetry is the platform refusing to destroy data on the strength of a deletion you may not have meant. It is not a gap in the tooling — it is the tooling declining to be clever with the one thing it cannot undo.
Why so much hangs off the entity design#
Most of designing an Osy# app is designing its entities, because so much hangs off them:
- your queries are over them, and the security rules are compiled into those queries (Querying data);
- your functions contain no authorization code, because the entity's rules already do that job (Functions (the unit of work));
- your UI names an entity and gets its data.
Time spent getting the model and its rules right is not preparation for the work. It is most of the work.
See also#
- entity — declaring one, and what you get for free
- entity members — the members, the types, and optional-by-default
- relations — references, collections, and the FK query you should not write
- constraints —
[Required]·[Unique]·[MaxLength]·[Min]/[Max]·[Pattern] - invariant — a rule about the whole row
- entity Sub : Base —
entity Sub : Base, and what a subtype does and does not carry - sealed — how a type says no one may derive from it
- enum — a closed set of values
- Counter — a sequence the platform hands out
- The security model — the rules that live on the entity, and why they are in the query
- Querying data — reading the model back