Skip to main content

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 readWhat 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.DocumentTypeIdStamp 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 registryTurn 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.

PlaneProjectHowRead by
EntityAcme.Domainpublic static Guid TypeId { get; } (the IEntityWithTypeId contract)server code, via T.TypeId or TypeIdResolver
DtoAcme.Forms[TypeId("...")] attributethe 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, not static 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.TypeRegistry walks the compilation and every referenced Kandra*/Acme* assembly, collects every [TypeId]-attributed Dto and enum, and emits a GeneratedTypeRegistry plus its DI registration (called from your Configure{Name}ClientServices via AddGenerated{Name}TypeRegistry()). It classifies each entry as Dictionary, Document or Enum.
  • On the server, TypeIdResolver.Resolve(Type) reads the static TypeId off any IEntityWithTypeId type 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 TypeId is 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, never Acme.Domain. Both sides normally read the same AcmeTypeIds.* 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 TypeId on an entity fails at runtime, not build time. TypeIdResolver throws InvalidOperationException ("missing a public static Guid TypeId property") the first time the CRUD service records a change for that type. Add IEntityWithTypeId and the property before your first save.
  • Only the concrete hierarchical types carry a TypeId. The abstract *Node root doesn't: TypeIdResolver runs 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 empty NumberingBuckets.cs — see Numbering.
  • Keep it a const string. A static readonly field compiles for the entity side and then breaks [TypeId(...)] on the Dto.

See also