Перейти до основного вмісту

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: ISimpleNumberingSystem and IGaplessNumberingSystem.

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 sees GR.2600001 in the "new" form before they've typed anything.
  • Gaps are tolerated. Nothing gives a number back if the user abandons the form.
  • GetNextAsync never returns null; an unknown bucket is simply created at 1.
  • requestorId/requestorTag are 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, then NumberingRetryLimitExceededException (with bucket, last raw value and requestor).
  • Do not draw the number in OnNewAsync. Show a placeholder and call GetNextAsync from OnSaveAsync/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.
  • RegisterManualAsync claims a hand-typed number so later automatic numbers can't collide with it; it throws InvalidStateException if that number is already issued.
Read this before promising "gapless" to an auditor

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?

SimpleGapless
Number shown in the "new" formYes — drawn in OnNewAsyncNo — placeholder, drawn in OnSave/OnSubmit
GapsToleratedNot intended (see caution above)
PoolingYesNo
Collision tracking against hand-typed numbersNoYes (RegisterManualAsync)
Typical useDictionaries, internal documentsFiscal / 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 Code the 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 Code is typed by a person don't. Currency (USD/EUR) and PriceType (RETAIL/WHOLESALE) leave OnNewAsync as a no-op and skip the bucket file entirely.

Gotchas worth knowing before you start

  • OnNewAsync runs 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.Date when OnNewAsync ran. If the user then edits the date into the next year, the GR.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 as AcmeTypeIds.
  • 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's Initial migration 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