Skip to main content

Creating a dictionary

:::tip Use the shipped skill If you're working in a scaffolded configuration with Claude Code, prefer invoking the create-dictionary skill (ships in your repo's .claude/skills/) over hand-rolling the four pieces below — it does the mechanical work for you, flat or hierarchical. Turning an existing flat dictionary into a hierarchical one later, without losing its data, is a separate skill: convert-dictionary-to-hierarchical. This page is the conceptual background the skill assumes, worked through end to end so you can see what the skill's own copy-paste templates actually produce. :::

A Dictionary is reference/master data with no posting behavior: items, warehouses, counterparties, price types. There are two shapes:

  • Flat — a plain list. Full worked example: Branch.
  • Hierarchical — supports a folder/parent structure. Full worked example: Counterparty. Hierarchy is opt-in per dictionary, not a separate base type family — only reach for it if folder-style grouping is a real requirement for this entity, not "might be nice later."

Both worked examples use the same illustrative names the create-dictionary skill's own reference.md uses, so what you see there lines up with what that skill's data/*.cs templates produce once you fill in your own names. Acme stands in for your configuration's own root name throughout (the one you passed to dotnet new kandra-config -n).

The four generator-discovered pieces, plus two hand-written ones

A dictionary spans three class-library projects, not one file:

PieceProjectBase type
Domain entity (*Base)Acme.DomainDictionaryBase / HierarchicalDictionaryBase
Dto (*Dto)Acme.FormsDictionaryDto / HierarchicalDictionaryItemDto
Behavior (*Behavior)Acme.ApplicationIFlatDictionaryBehavior<T> / IHierarchicalDictionaryBehavior<T>
Validator (*Validator)Acme.FormsDictionaryValidator<TDto>

Those four are entirely generator-discovered from attributes — nothing about them is ever manually registered. See Source generators overview for which generator reads which attribute. But a dictionary the UI can actually use needs two more pieces that no generator produces yet, and both are demonstrated in context in each worked example rather than explained in the abstract:

  • A type identity constant — a fresh GUID, minted as its own new file.
  • A hand-written Refit client interface — Refit's own source generator implements the method bodies at compile time, but nothing generates the interface declaration itself.

Worked examples

  • Flat dictionary: Branch — one type identity, the *Base domain entity itself (auto-numbered Code, no custom fields), the *Dto/*Validator pair, the *Behavior, and the hand-written Refit client.
  • Hierarchical dictionary: Counterparty — two type identities, the three-class TPH *Base tree (abstract root + folder + leaf), the *Dto/*Validator trio, the *Behavior, and the AutoMapper .Include<>()/.Ignore() pattern that makes the mapper work across all three classes.

Gotchas worth knowing before you start

  • [Search]/[Dropdown] on a hierarchical dictionary field must target the abstract root Dto, not the leaf subclass — pointing at a leaf silently drops the field from the generated UI, and referencing the root doesn't loosen the node-selection default either (still leaf-only unless you say so explicitly).
  • Computed get-only properties are excluded from field discovery — if a property needs to show up in the generated UI, it needs a real backing setter, not just a getter.
  • The JSON discriminator (isFolder) on a hierarchical Dto is the string "1"/"0", not the int 1/0. The client's generated JsonInheritanceConverter reads it via JsonElement.GetString(), which throws on a JSON number token. This is a wire/JSON-layer detail only — the unrelated EF Core IsFolder discriminator column is a plain int mapping and untouched by this choice.
  • AcmeTypeIds/NumberingBuckets are partial, and their main file declares zero members — every constant lives in its own sibling file (AcmeTypeIds.Branch.cs, AcmeTypeIds.Counterparty.cs, ...). This is deliberate: adding a new dictionary never means editing a file another in-flight dictionary might also be touching, only adding a new one. Never insert a line into AcmeTypeIds.cs/NumberingBuckets.cs itself.
  • A missing resx key doesn't fail the build or throw — it just renders the raw key name (e.g. literally Nav_Branches) in the UI. Easy to miss one of the three locale files.
  • A no-op *Behavior is fine, but the class and its [KandraDictionaryBehavior]/ [KandraDictionaryFolder] attributes must still exist — the engine fails DI validation at startup without a behavior for every dictionary, even one that overrides nothing.
  • Every entity-bound AutoMapper direction in a hierarchical *Mapper needs the same .ForMember(..., o => o.Ignore()) list for Depth/Path/the five ChangesInfo audit fields — repeated per direction (Dto→entity and entity→Dto, root and each concrete pair), not inherited automatically. Miss one direction and a client payload can silently overwrite a server-managed field.

See also