Type identity (TypeId)
Every Dictionary, Document, Constant and subconto-backing enum in a configuration carries one
permanent Guid that names its type — not a row, the kind of thing. Every
entity-kind page (dictionary, document,
constant, enum) has a step that mints one; this page
explains the shared mechanism once so those steps can stay short.
Why it exists
Most of the engine talks about entities through their compile-time type. A few places can't, because all they hold at runtime is data:
Where a TypeId is stored or read | What it lets the engine do |
|---|---|
Change log (Sys_ChangeEvents) | Record which type an Insert/Update/Delete belonged to |
Blob references (Sys_BlobReferences) | Say which entity type owns an attachment |
Document links (Sys_DocumentLinks) | Store "from" and "to" types without a fixed CLR type |
DocumentBase.DocumentTypeId | Stamp every document row with its own concrete type |
Posted ledger rows (SubcontoSlot(TypeId, EntityId)) | Remember what a subconto value was, so it can be shown and re-resolved later |
Register rows ([ReferenceOf(TypeIdProperty = ...)]) | Resolve a Guid column whose target type varies per row |
| The client's generated type registry | Turn a runtime Guid back into a Dto/enum type and pick the right selector control |
Because it is stored in data, a TypeId is forever. Changing one orphans every row that
recorded the old value; two types sharing one makes them indistinguishable.
The two sides of one identity
A form-bearing entity declares its TypeId twice — once on the server-side entity, once on the
client-visible Dto — and both must be the same value.
| Plane | Project | How | Read by |
|---|---|---|---|
| Entity | Acme.Domain | public static Guid TypeId { get; } (the IEntityWithTypeId contract) | server code, via T.TypeId or TypeIdResolver |
| Dto | Acme.Forms | [TypeId("...")] attribute | the client type registry generator |
The Dto has to carry it too because the Blazor client can never reference Acme.Domain (see
Overall architecture); the registry generator can only see what
Acme.Forms and Acme.Enums expose.
IEntityWithTypeId is a C# static-abstract interface member — the identity belongs to the
type, not to any instance:
public interface IEntityWithTypeId
{
static abstract Guid TypeId { get; }
}
ISubconto (used for accounting dimensions) is simply
IRootEntity + IEntityWithTypeId; it adds nothing to identity itself.
One constant, declared once
To keep both sides in sync without copying a GUID literal, a configuration keeps every identity
in one static class in Acme.Enums as const string fields. Acme.Enums is referenced by
Domain, Forms and the client, so both planes can see it.
The class is partial, and its main file is empty:
// Acme.Enums/AcmeTypeIds/AcmeTypeIds.cs — never edited
namespace Acme.Enums;
public static partial class AcmeTypeIds
{
}
Each identity is its own new sibling file:
// Acme.Enums/AcmeTypeIds/AcmeTypeIds.Branch.cs
namespace Acme.Enums;
public static partial class AcmeTypeIds
{
public const string Branch = "18d2d31e-c312-411b-b9eb-291e10d89fd6";
}
Two rules follow from how the constant is used:
- Add a file, never edit
AcmeTypeIds.cs. Two people adding two entities in parallel then never touch the same line, so there is nothing to merge. The convention is identical for Dictionaries, Documents, Constants and enums. const string, notstatic readonly Guid. The value is also an argument to[TypeId(...)], and attribute arguments must be compile-time constants.
Mint a fresh GUID for each (any GUID generator works). Nothing generates or tracks them for you, and nothing checks that you didn't paste the same one twice on the Domain side — see the gotchas.
Declaring it, per entity kind
Flat dictionary
// Acme.Domain/Entities/Dictionaries/Branch.cs
public partial class Branch : DictionaryBase, IEntityWithTypeId
{
public static Guid TypeId { get; } = new(AcmeTypeIds.Branch);
// ...
}
// Acme.Forms/Dictionaries/Branch.cs
[TypeId(AcmeTypeIds.Branch)]
[KandraDictionaryForm(Name = "Branches", ValidatorType = typeof(BranchValidator), QueryDtoType = typeof(BranchQueryDto))]
public class BranchDto : DictionaryDto { /* ... */ }
Hierarchical dictionary
A hierarchical dictionary has an abstract root (CounterpartyNode) and two concrete kinds,
folder and leaf. Only the concrete types carry identity, one each — the abstract root has
none:
public sealed partial class CounterpartyFolder : CounterpartyNode, IEntityWithTypeId
{
public static Guid TypeId { get; } = new(AcmeTypeIds.CounterpartyFolder);
}
public sealed partial class Counterparty : CounterpartyNode, ISubconto
{
public static Guid TypeId { get; } = new(AcmeTypeIds.Counterparty);
}
On the Dto side [TypeId(AcmeTypeIds.Counterparty)] sits on the leaf Dto and
[TypeId(AcmeTypeIds.CounterpartyFolder)] on the folder Dto. That is why a hierarchical dictionary
needs two AcmeTypeIds.* files. See
Hierarchical dictionary.
Document
A document stores its identity on every row, so the entity's constructor passes it up to
DocumentBase:
public sealed partial class GoodsReceipt : DocumentBase, IEntityWithRows<GoodsReceipt>, ISubconto
{
public static Guid TypeId { get; } = new(AcmeTypeIds.GoodsReceipt);
public GoodsReceipt() : base(TypeId) { }
}
[TypeId(AcmeTypeIds.GoodsReceipt)]
[KandraDocumentForm(Name = "GoodsReceipts", ValidatorType = typeof(GoodsReceiptValidator), QueryDtoType = typeof(GoodsReceiptQueryDto))]
public class GoodsReceiptDto : DocumentDto { /* ... */ }
DocumentBase.DocumentTypeId is fixed by that constructor call and never reassigned. Forget
: base(TypeId) and the constructor won't compile — DocumentBase has no parameterless
constructor.
Constant
A constant has no Dto, so it declares only the entity side — as a static property on the constant
class itself, next to its Name:
[KandraConstant]
public sealed class HomeCurrencyConstant() : Constant<string>("UAH"), IConstant
{
public static string Name => "HomeCurrency";
public static Guid TypeId { get; } = new(AcmeTypeIds.HomeCurrency);
}
Constants have no per-value entity Id, so this TypeId is what identifies the constant in the
change log. See Creating a constant.
Enum
An enum needs a TypeId only when its members are used as
subconto values. It goes on the enum type as [TypeId(...)] — there is no
entity side. See Creating an enum;
that page also covers the per-member [SubcontoId], which is a different identifier.
What you get for free
You do not register a TypeId anywhere. On the next build:
Kandra.Generators.TypeRegistrywalks the compilation and every referencedKandra*/Acme*assembly, collects every[TypeId]-attributed Dto and enum, and emits aGeneratedTypeRegistryplus its DI registration (called from yourConfigure{Name}ClientServicesviaAddGenerated{Name}TypeRegistry()). It classifies each entry as Dictionary, Document or Enum.- On the server,
TypeIdResolver.Resolve(Type)reads the staticTypeIdoff anyIEntityWithTypeIdtype by reflection (cached per type). The CRUD services use it to stamp change-log entries with the concrete type of the row.
Verify
dotnet build Acme.slnx with no KANDRATYPEREG* diagnostic — then exercise the entity in the
running app: create and save a row (a missing Domain-side TypeId throws on that first save, see the
gotchas below), then open its change history.
Gotchas worth knowing before you start
- A
TypeIdis permanent. It is written into change logs, ledger rows, attachments and document links. Never reuse one for a different type, and never edit one after data exists. - Every new GUID must be unique. The generator catches a duplicate across two
[TypeId]types at build time (KANDRATYPEREG003), and a value that isn't a valid GUID (KANDRATYPEREG004). It also errors (KANDRATYPEREG001/002) if a[TypeId]class carries neither or both of[KandraDictionaryForm]/[KandraDocumentForm]. - The Domain side is not cross-checked. The registry generator runs in the client plane and
only sees
Acme.Forms/Acme.Enums, neverAcme.Domain. Both sides normally read the sameAcmeTypeIds.*constant, so they can't drift — but if you paste a literal on one side instead of the constant, nothing warns you. - A missing
static Guid TypeIdon an entity fails at runtime, not build time.TypeIdResolverthrowsInvalidOperationException("missing a public static Guid TypeId property") the first time the CRUD service records a change for that type. AddIEntityWithTypeIdand the property before your first save. - Only the concrete hierarchical types carry a
TypeId. The abstract*Noderoot doesn't:TypeIdResolverruns against the row's real runtime type (folder or leaf), so each of those needs its own. - Don't hand-edit
AcmeTypeIds.cs. Add a sibling file. The same applies to the emptyNumberingBuckets.cs— see Numbering. - Keep it a
const string. Astatic readonlyfield compiles for the entity side and then breaks[TypeId(...)]on the Dto.
See also
- Creating a dictionary and Creating a document —
where each kind's
TypeIdis first used. - Creating a constant and Creating an enum.
- Chart of accounts —
ISubcontobuilds onIEntityWithTypeId. - Numbering — the other "constant per sibling file" convention.
- Reference: Attributes