Summary#
A Counter assigns a sequential number when a row is created — the order number, the invoice code, the ticket
number. The platform allocates it, so two requests creating rows at the same instant cannot receive the same value.
This is not the row's identity: every entity already has an Id. A counter is the number a human uses — the one
they read down the phone.
Signature#
Counter <Name> = new() {
Start = <n>, // the seed; the first value assigned is Start + Increment
Increment = <n>, // default 1
Format = $"…{Value:D4}…", // optional — makes the value a formatted CODE string
};
entity <E> {
[Counter(<Name>)] int <Member>; // the raw number
[Counter(<Name>)] string <Member>; // the formatted code (needs a Format)
[Counter(<Name>, scope = <Ref>)] int <Member>; // restarts per parent row
[Counter(<Name>, scope = <Ref>)] string <Member>; // a formatted code that restarts per parent
}Description#
A plain sequence — 1, 2, 3#
Declare the counter, then tag the member with the [Counter] attribute — [Counter(Name)] naming the counter to
draw from. You never assign it — creating the row does, and the compiler enforces it: writing a counter member
anywhere (an initializer, an assignment, a bulk Update body) is a compile error. Reading and filtering on it are
ordinary:
Counter OrderNumber = new() { Start = 1000 };
entity Order {
[Counter(OrderNumber)] int Number;
[Required] string CustomerName;
}
void NewOrder(string customer) {
var o = new Order { CustomerName = customer };
// Number is assigned here — 1001 for the first order, then 1002, 1003 …
}Note the first value is Start + Increment, not Start: Start = 1000 gives you 1001 first. Read Start as
"the number already used", not "the number I want first".
A formatted code — INV-0001#
Format is an interpolated string with a Value placeholder, and it turns the sequence into the code your business
actually uses. Tag a string member to get it:
Counter InvoiceNumber = new() { Start = 0, Format = $"INV-{Value:D4}" };
entity Invoice {
[Counter(InvoiceNumber)] string Code; // "INV-0001", "INV-0002", …
decimal Total;
}:D4 is the C# format specifier for "at least 4 digits, zero-padded". Pad generously — a code that jumps from
INV-9999 to INV-10000 will sort wrongly in every spreadsheet it ever lands in.
What can go in the template?
Exactly three, each with an optional :format:
| hole | fills with |
|---|---|
{Value} | the number |
{DateTime.UtcNow} · {DateTime.Now} | the current instant, UTC — the two spellings fill identically |
{TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, <IANA zone>)} | that instant read as a zone's wall-clock time |
Anything else is a compile error. It used to be copied into the value verbatim, so $"INV-{Vaule:D4}" printed
INV-{Vaule:D4} on every row until somebody noticed in the data.
⚠ A bare instant hole is UTC, whichever way it is spelled — see Current time (DateTime.UtcNow, DurableClock.Now). {DateTime.Now} once
rendered the server's local time here, which is the wrong year for anyone reading the invoice from another zone,
and wrong at a boundary nobody tests. Name the zone when the number should carry a local year:
Counter InvoiceNumber = new() { Start = 0, Format = $"INV-{TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, Europe/Stockholm):yyyy}-{Value:D4}" };The zone id is unquoted, unlike TimeZoneInfo.FindSystemTimeZoneById("Europe/Stockholm") in an expression — the template already lives inside
a string, so a nested quote would end it. An IANA id has no spaces or brackets, so nothing is ambiguous without them.
Counting by something other than 1#
Counter BatchNumber = new() { Start = 0, Increment = 10 }; // 10, 20, 30 …
entity Batch {
[Counter(BatchNumber)] int Seq;
[Required] string Name;
}A sequence that restarts per parent#
scope restarts the sequence for each distinct value of a reference — so each project's tickets are numbered from 1,
which is what people expect when they say "ticket 3 on the Apollo project":
Counter TicketSeq = new() { Start = 0 };
entity Project {
[Required] string Name;
}
entity Ticket {
[Required] string Title;
Project Project;
[Counter(TicketSeq, scope = Project)] int Seq; // 1, 2, 3 … within EACH project
}Without scope the numbers would be global — Apollo's first ticket might be 4,812, because Mercury used the first
4,811. With it, every project starts at 1.
Both at once — a formatted code that restarts per parent
scope and Format combine, and the combination is the shape most businesses actually want: each customer's
invoices numbered from one, in the customer-facing code:
Counter ReportNumber = new() { Start = 0, Format = $"SF-{Value:D4}" };
entity Organization {
[Required] string Name;
}
entity ExpenseReport {
[Required] string Title;
Organization Org;
[MaxLength(20)] [Counter(ReportNumber, scope = Org)] string Number; // SF-0001, SF-0002 … per org
}⚠ A scoped counter has to read its own numbers back, because it continues from the highest one already issued to
that parent — and on a string member the column holds the code, not the number. So the format must let the number
be found again, and one that does not is a compile error rather than a wrong number on somebody's invoice:
| format | why it is refused |
|---|---|
$"SF-{DateTime.UtcNow:yyyy}" | never prints {Value} — there is no number in the code to continue from |
$"SF-{Value:D4}{DateTime.UtcNow:yyyy}" | {Value} sits against a date hole: 00012026 says nothing about where the number ends. Put a separator between them |
$"{Value}-{Value}" | two copies of the number give two answers |
None of these applies without scope — nothing ever reads an unscoped counter's value back, so every format that
renders is legal there.
Adding a counter to an entity that already has rows#
Add the counter to the model and deploy. The deploy numbers the rows that are already there — you write no migration, no second field and no start-up hook:
Counter SupportTicketNumber = new() { Start = 100 };
entity SupportTicket {
[Required] string Subject;
[CreatedAt] DateTime Opened;
[Counter(SupportTicketNumber)] int Number; // added later: the stored tickets take 101, 102, … oldest first
}- Order: oldest first, by the entity's
[CreatedAt]member, then byId. An entity with no[CreatedAt]has no record of the order its rows were made in, so they are numbered inIdorder — deterministic, not chronological. - The counter goes on after them. Three stored rows take 101–103; the first row created after the deploy takes 104.
- Scoped counters number each parent's rows from the start, after the highest number already under that parent. A row whose scope reference is empty has no sequence to be numbered in, and is left alone.
- A recompile changes nothing. The deploy numbers only rows that hold no number (empty, or a
0the counter can never issue), so it also heals rows an earlier deploy left unnumbered — and touches nothing else. - A migration's rows draw the next numbers. The numbering runs inside the deploy, before the migration's functions, so a row a migration creates in the same deploy takes the next number after the stored ones.
Where to declare it — any file, or a kit#
A counter is declared once, in any file of the app, and [Counter(X)] in any other file finds it. A kit you use
can declare counters too, and your entities draw from them by the same bare name.
When your app declares a counter with the same name as a kit's, the bare name means yours — and the kit's own
entities keep numbering from the kit's. The two stay separate sequences. To draw from the kit's, write it qualified by
the namespace it is declared in, [Counter(Acme.Shop.OrderNumber)]. A counter of your own namespace can be written
qualified as well:
namespace Ledger;
Counter VoucherNumber = new() { Start = 0, Format = $"V-{Value:D5}" };
entity Voucher {
[Required] string Memo;
[Counter(Ledger.VoucherNumber)] string Code; // the same counter as [Counter(VoucherNumber)]
}If two kits declare a counter of one name and your app does not, the bare name is ambiguous, and the compile refuses it, naming both qualified spellings. Write the one you mean, or declare your own of that name.
Can I use it as the primary key?#
Do not use a counter as the primary key, and do not use the Id as an order number. They answer different questions:
the Id identifies the row to the system, the counter names it to a person. Both exist because both are needed.
See also#
- entity — the
Idevery entity already has - constraints —
[Unique], for when the value comes from outside rather than a sequence - entity members — the member types a counter can fill