Numbering (simple and gapless)
Almost every Document and most Dictionaries have a human-readable Code (GR.2600001,
BR-000042). The engine does not decide what that looks like — it gives you an atomic counter
and you turn its raw number into a string. This page covers the two counter services, the bucket
that names a counter, and when to pick which.
The design is in the platform's docs/numbering-subsystem-spec.md; everything below was checked
against the implementation (Kandra.Application/Services/Numbering), not the spec alone.
The model in four sentences
- A bucket is a plain string that names one independent counter —
"Branch","GoodsReceipt:26". The engine never parses, trims or interprets it. - A counter starts at 1 on first use and only ever goes up, atomically.
- You pass a formatter (
Func<ulong, string>) and get back the formatted string. The formatter is always the caller's job — the service knows nothing about prefixes, padding or dates. - Two services share that idea:
ISimpleNumberingSystemandIGaplessNumberingSystem.
Both are ordinary scoped services (AddKandraApplication() registers them), so unlike register
writers and the posting service, a behavior can take them straight from its constructor.
Simple numbering — the default
public interface ISimpleNumberingSystem
{
Task<string> GetNextAsync(string bucket, Func<ulong, string> formatter,
Guid? requestorId = null, string? requestorTag = null, CancellationToken cancellationToken = default);
Task<string?> GetNextFromPoolAsync(string bucket, string poolName, Func<ulong, string> formatter, /* ... */);
Task LeaseToPoolAsync(string bucket, string poolName, int count, /* ... */);
}
Use it when gaps are acceptable — internal documents and dictionaries. Its contract:
- The number is visible immediately: you draw it in
OnNewAsync, so the user seesGR.2600001in the "new" form before they've typed anything. - Gaps are tolerated. Nothing gives a number back if the user abandons the form.
GetNextAsyncnever returnsnull; an unknown bucket is simply created at 1.requestorId/requestorTagare audit labels only and play no part in numbering. The reference configuration passes a tag such as"Branch:OnNew".
Pooling
LeaseToPoolAsync(bucket, pool, count) reserves count raw values from the bucket's counter into
a named pool; GetNextFromPoolAsync draws them back oldest-first, applying the formatter at
draw time (the pool stores raw values, so a formatter that depends on something that changes
between lease and draw isn't baked in). It returns null when the pool is empty — an expected
outcome, not an error. Pooling exists to hand out pre-reserved blocks (for example to an offline
client); it is implemented and unit-tested, but the reference configuration has no caller, so
treat it as available machinery rather than a pattern with a worked precedent.
Gapless numbering — for regulated documents
public interface IGaplessNumberingSystem
{
Task<string> GetNextAsync(string bucket, Func<ulong, string> formatter, /* ... */);
Task RegisterManualAsync(string bucket, string number, /* ... */);
}
Use it when a document series must not have holes (fiscal invoices). What differs from Simple:
- Every issued string is recorded in an issued set, unique on
(bucket, formattedNumber). If the formatted string is already taken — for example a user typed it by hand and you registered it — the counter advances and the formatter runs again, up to 50 attempts, thenNumberingRetryLimitExceededException(with bucket, last raw value and requestor). - Do not draw the number in
OnNewAsync. Show a placeholder and callGetNextAsyncfromOnSaveAsync/OnSubmitAsync, once the document's final business state (especially its date, if the date is in the bucket) is settled. Drawing early would reintroduce exactly the gaps you chose this family to avoid. - No pooling. Reserving numbers ahead of time is in direct tension with gaplessness.
RegisterManualAsyncclaims a hand-typed number so later automatic numbers can't collide with it; it throwsInvalidStateExceptionif that number is already issued.
The platform's own spec (§10) records that IGaplessNumberingSystem today means monotonic and
never reused — it does not detect or record a raw value that was skipped (a collision retry,
or a crash between the counter commit and your own save). The admin surface designed for that
(RegisterExceptionAsync, GetUndocumentedGapsAsync) is not built. No entity in the reference
configuration calls the gapless service yet, so it has less production mileage than Simple. Prove
it against your own compliance rules before wiring it to a fiscal document.
Which one?
| Simple | Gapless | |
|---|---|---|
| Number shown in the "new" form | Yes — drawn in OnNewAsync | No — placeholder, drawn in OnSave/OnSubmit |
| Gaps | Tolerated | Not intended (see caution above) |
| Pooling | Yes | No |
| Collision tracking against hand-typed numbers | No | Yes (RegisterManualAsync) |
| Typical use | Dictionaries, internal documents | Fiscal / regulated documents |
Start with Simple. Pick Gapless only because a rule requires it, and then follow the "draw late" rule above.
Worked example: a bucket, a Dictionary and a Document
The bucket constant — a new sibling file
A bucket is just a string, so nothing discovers it. By convention the string lives in a
constant, and — same reason as AcmeTypeIds — the shared class is partial
with an empty main file, so each bucket is its own new file:
// Acme.Domain/Numbering/NumberingBuckets.cs — never edited
namespace Acme.Domain.Numbering;
public static partial class NumberingBuckets
{
}
// Acme.Domain/Numbering/NumberingBuckets.Branch.cs
namespace Acme.Domain.Numbering;
public static partial class NumberingBuckets
{
public const string Branch = "Branch";
}
// Acme.Domain/Numbering/NumberingBuckets.GoodsReceipt.cs
namespace Acme.Domain.Numbering;
public static partial class NumberingBuckets
{
public const string GoodsReceipt = "GoodsReceipt";
}
A dictionary: one flat counter
// Acme.Application/Dictionaries/Branch.cs
[KandraDictionaryBehavior(typeof(BranchDto))]
public class BranchBehavior(ISimpleNumberingSystem numbering) : IFlatDictionaryBehavior<Branch>
{
public async ValueTask OnNewAsync(Branch entity, CancellationToken cancellationToken) =>
entity.Code = await numbering.GetNextAsync(NumberingBuckets.Branch, raw => $"BR-{raw:D6}",
requestorTag: "Branch:OnNew", cancellationToken: cancellationToken);
}
The first branch gets BR-000001, the second BR-000002. A hierarchical dictionary is usually
given two buckets — folders and leaves want different prefixes and widths (CPF-{raw:D3} vs
CP-{raw:D6}); see Hierarchical dictionary.
A document: a year-suffixed bucket
// Acme.Application/Documents/GoodsReceipt.cs
public async ValueTask OnNewAsync(GoodsReceipt entity, DateTime? date, Guid? sourceDocumentId,
CancellationToken cancellationToken)
{
entity.Date = date ?? DateTime.UtcNow;
var yy = entity.Date.ToString("yy");
entity.Code = await numbering.GetNextAsync($"{NumberingBuckets.GoodsReceipt}:{yy}", raw => $"GR.{yy}{raw:D5}",
requestorTag: "GoodsReceipt:OnNew", cancellationToken: cancellationToken);
}
In 2026 the bucket is "GoodsReceipt:26" and the codes run GR.2600001, GR.2600002…; on
1 January 2027 the bucket becomes "GoodsReceipt:27", which has never been used, so the counter
restarts at 1 with no reset job. The :{yy} suffix is a convention of the reference
configuration, not an engine requirement — a plain "GoodsReceipt" bucket works and simply
never restarts. The engine sees two unrelated strings either way.
Which entities need a bucket
- Nearly every Document and most Dictionaries — anything whose
Codethe system should assign. In the reference configuration that is all eight documents and the Branch, Counterparty (folder + leaf), Item (folder + leaf), UnitOfMeasure and Warehouse dictionaries. - Entities whose
Codeis typed by a person don't.Currency(USD/EUR) andPriceType(RETAIL/WHOLESALE) leaveOnNewAsyncas a no-op and skip the bucket file entirely.
Gotchas worth knowing before you start
OnNewAsyncruns when the form opens, not when the row saves. The engine calls it against a scratch entity that is never persisted. Every "Add" click therefore consumes a number, and cancelling leaves a gap. That is the Simple contract, not a bug — and the reason a fiscal series must not draw there.- A year-suffixed bucket is fixed at form-open time. The year comes from
entity.DatewhenOnNewAsyncran. If the user then edits the date into the next year, theGR.26…code stays. Decide whether that matters for your series; if it does, that is another reason to draw at save time with Gapless. - Counters are keyed by the exact string.
"GoodsReceipt"and"Goodsreceipt"are different counters, and the service will not warn you. Always reference the constant instead of retyping. - Two entities sharing one bucket share one counter. Sometimes intended; usually a copy-paste mistake. Give each type its own constant.
{raw:D5}pads to at least five digits; it does not cap. Once a bucket passes 99,999 the code is simply one character longer — pick a width with that in mind.- Add a new bucket file; never edit
NumberingBuckets.cs. Same rule and reason asAcmeTypeIds. - Counters live in
Sys_tables (Sys_SimpleCounters,Sys_SimplePools,Sys_GaplessCounters,Sys_GaplessIssued), part of the platform's own model. They arrive in your configuration's migrations with the rest of the platform tables (the reference configuration'sInitialmigration creates them); you never declare or map them yourself. - Concurrent draws are safe but not lock-free. Counters use a manually-incremented version column with up to five retries on a lost update, portable across SQLite, SQL Server and PostgreSQL rather than a provider-specific atomic increment.
See also
- Creating a dictionary and Creating a document —
where
OnNewAsyncfirst assigns aCode. - Type identity (TypeId) — the same "one constant per sibling file" convention.
- Platform doc:
docs/numbering-subsystem-spec.md(design, storage model, the deferred admin interfaces).