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:
| Piece | Project | Base type |
|---|---|---|
Domain entity (*Base) | Acme.Domain | DictionaryBase / HierarchicalDictionaryBase |
Dto (*Dto) | Acme.Forms | DictionaryDto / HierarchicalDictionaryItemDto |
Behavior (*Behavior) | Acme.Application | IFlatDictionaryBehavior<T> / IHierarchicalDictionaryBehavior<T> |
Validator (*Validator) | Acme.Forms | DictionaryValidator<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*Basedomain entity itself (auto-numberedCode, no custom fields), the*Dto/*Validatorpair, the*Behavior, and the hand-written Refit client. - Hierarchical dictionary:
Counterparty— two type identities, the three-class TPH*Basetree (abstract root + folder + leaf), the*Dto/*Validatortrio, 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 int1/0. The client's generatedJsonInheritanceConverterreads it viaJsonElement.GetString(), which throws on a JSON number token. This is a wire/JSON-layer detail only — the unrelated EF CoreIsFolderdiscriminator column is a plain int mapping and untouched by this choice. AcmeTypeIds/NumberingBucketsarepartial, 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 intoAcmeTypeIds.cs/NumberingBuckets.csitself.- 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
*Behavioris 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
*Mapperneeds the same.ForMember(..., o => o.Ignore())list forDepth/Path/the fiveChangesInfoaudit 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
- Creating a document — the same TypeId-minting and Refit-client mechanics apply there too, plus the register/posting pieces dictionaries don't have.
- File storage & attachments — the
[FilePicker]field trimmed out of theBranchexample (realBranchin Kandra's own reference configuration has one). - Localization — the
LocalizationChainand resx-key conventions referenced in both worked examples. - Database migrations — the
kandra-migratetool used in both worked examples' migration step. - Chart of accounts —
ISubcontoand howCounterpartyparticipates in posting as a subconto dimension. - Reference: Entities, Reference: DTOs & Validators, Reference: Services, and Reference: Source generators.