Creating an enum
Prefer the create-enum skill (in your scaffolded repo's .claude/skills/) over hand-rolling
this. Its data/Enum.Plain.cs, data/Enum.Subconto.cs, data/TypeIdConstant.cs and
data/Localization.resx.xml templates are the files this page walks through, and its
reference.md was verified against a running app.
An enum is a small, closed, compile-time set of named values — an item category, a costing
method, a status. It is the lightest of the seven entity kinds: a plain C# enum, not a
persisted entity. There is no *Base, *Dto, *Behavior or *Validator, no table, no
controller, no Refit client, no nav entry, no Blazor page.
It reaches the database and the UI only as the type of a property on some other entity or Dto, and the engine integrates it into three places worth getting right:
- The generated form — an enum-typed property gets a select listing every member with a localized label.
- Print and CSV export — the same localized label appears, not the raw member name.
- Accounting (opt in) — the enum becomes a closed set of subconto values.
The worked examples are the two real enums in Kandra's own reference configuration:
ItemCategoryType (used as the property Item.Category) and CostingMethod (a domain-logic
enum whose members drive behavior).
The pieces
An enum needs one small file and its resx keys. The engine-side wiring is automatic.
| What | Where | Needed |
|---|---|---|
| The enum | Acme.Enums/{Name}.cs | always |
| Localization keys | Acme.Localization/Acme.resx + .ru.resx + .uk.resx | always |
| Type identity | new sibling file Acme.Enums/AcmeTypeIds/AcmeTypeIds.{Name}.cs | only for the subconto role |
| A migration | new migration per provider | only for the property that uses the enum, not the enum itself |
Put the enum in Acme.Enums, not Acme.Domain. That project references only
Kandra.Attributes, and Domain, Forms and the Blazor WASM client all reference it — which is
the point: the same type is used unchanged on the entity, on the Dto and in the browser, so
there is no Domain-enum/Dto-enum pair and no mapping code. An enum in Acme.Domain would be
invisible to the client, and Acme.Forms must never reference server-only projects.
1. Decide which role it plays
| Plain enum | Subconto-backing enum | |
|---|---|---|
| Use when | a property just needs a closed value set (Item.Category) | the values must be accounting dimension values posted against accounts |
| Attributes | [EnumCaption] per member | [EnumCaption] plus [TypeId] on the enum and [SubcontoId] per member |
| Type identity | not needed | required |
Start plain. Adding [TypeId]/[SubcontoId] later is additive — but a [SubcontoId] GUID,
once shipped and posted against, is permanent. Note that both real enums carry the full
subconto decoration even though no chart-of-accounts slot in that configuration uses either as
a subconto today: that is an option they hold open, not something plain use requires.
2. Write the enum
// Acme.Enums/ItemCategoryType.cs
using Kandra.Attributes.Accounting;
using Kandra.Attributes.Entities;
using Kandra.Attributes.Naming;
namespace Acme.Enums;
[TypeId(AcmeTypeIds.ItemCategoryType)]
public enum ItemCategoryType
{
[EnumCaption("Enum_ItemCategoryType_Goods")]
[SubcontoId("4ef39b2a-f5aa-4469-aa34-e719a3ddd4b5")]
Goods = 0,
[EnumCaption("Enum_ItemCategoryType_Production")]
[SubcontoId("e330dcbd-1c6b-4b73-bb40-efc4cd23e7b8")]
Production = 1,
[EnumCaption("Enum_ItemCategoryType_Material")]
[SubcontoId("fdeb670f-5b4a-4075-b9b5-0b124ca7c711")]
Material = 2,
[EnumCaption("Enum_ItemCategoryType_Service")]
[SubcontoId("e31271b6-e0c4-4fcc-962d-a916b46c4fbb")]
Service = 3,
[EnumCaption("Enum_ItemCategoryType_Kit")]
[SubcontoId("cc53d570-2b65-4b12-8038-a8a77fb09146")]
Kit = 4,
}
That is the fully decorated form; AcmeTypeIds.ItemCategoryType is the identity constant minted
in step 3. A plain enum drops the usings for
Kandra.Attributes.Accounting/Entities, the [TypeId] and every [SubcontoId], and keeps
only [EnumCaption]:
public enum PaymentKind
{
[EnumCaption("Enum_PaymentKind_Cash")]
Cash = 0,
[EnumCaption("Enum_PaymentKind_Card")]
Card = 1,
}
The rules that matter:
- Always assign explicit numeric values, and never renumber or reuse one. EF stores the
enum as its underlying integer — no value conversion is configured anywhere in the
engine, and the reference configuration's SQLite snapshot maps
Item.Categoryto a plain integer column. Reordering members, or inserting one in the middle, silently re-labels every existing row. Append new members with the next unused number; to retire a member, leave it in place. [EnumCaption]on every member. Its argument is a localization key, or literal text withisLiteral: true. A member without it still works but renders its raw C# name, untranslated, in the form, the grid and exports.- The key shape
Enum_{Type}_{Member}is a convention, not enforced. Keep it: every existing key uses it, and it makes a missing translation greppable. - Model "not chosen" as an explicit member (
None = 0) rather than making the Dto property nullable. The generated select has no nullable branch, so aT?enum property is not a supported shape. - Order is display order. The select lists members by ascending numeric value, so number them in the order you want them shown.
3. Type identity and subconto ids (subconto role only)
Skip this step for a plain enum.
The enum's [TypeId] — a fresh GUID, in its own new file, never an edit to
AcmeTypeIds.cs itself (the same partial-class convention Documents, Dictionaries and
Constants use):
// Acme.Enums/AcmeTypeIds/AcmeTypeIds.CostingMethod.cs
namespace Acme.Enums;
public static partial class AcmeTypeIds
{
// Enum type-level id only - per-value [SubcontoId] attributes on individual members are
// unrelated and stay where they are.
public const string CostingMethod = "37198221-fa6d-4fb0-bb7c-319511db4c84";
}
Then put [TypeId(AcmeTypeIds.CostingMethod)] on the enum. The two identifiers are different
things and unrelated:
| Attribute | On | Identifies |
|---|---|---|
[TypeId] | the enum | the type — what the client type registry and EnumSubconto<T> read |
[SubcontoId] | each member | that member as a subconto value, permanently, in posted rows |
// Acme.Enums/CostingMethod.cs
[TypeId(AcmeTypeIds.CostingMethod)]
public enum CostingMethod
{
[EnumCaption("Enum_CostingMethod_MovingAverage")]
[SubcontoId("b6b4078b-75bd-4629-b137-c3c1d0b7c047")]
MovingAverage = 0,
[EnumCaption("Enum_CostingMethod_Fifo")]
[SubcontoId("42ad9fb2-cf08-4135-929f-bc01cfcae067")]
Fifo = 1,
[EnumCaption("Enum_CostingMethod_SpecificIdentification")]
[SubcontoId("7ca6d75f-1e99-42ac-a47a-c8e51a2d1a39")]
SpecificIdentification = 2,
[EnumCaption("Enum_CostingMethod_Fefo")]
[SubcontoId("7c9d5bae-b78e-4750-96a8-ad5a5558c1b5")]
Fefo = 3,
}
Use a different fresh GUID per member, and never reuse a GUID anywhere. (The GUIDs shown are the real ones from the reference configuration, reproduced only because they are verified working code — mint your own.)
You never register an enum by hand: Kandra.Generators.TypeRegistry walks the compilation and
every referenced Kandra*/Acme* assembly, collects every type carrying [TypeId], and emits
a registry entry of kind Enum on the next build. The client uses it to turn a runtime-picked
subconto value back into the right selector control.
4. Localization keys
Add every [EnumCaption] key to all three of Acme.Localization/Acme.resx, .ru.resx and
.uk.resx, one entry per member per file:
<!-- Acme.Localization/Acme.resx -->
<data name="Enum_ItemCategoryType_Goods" xml:space="preserve">
<value>Goods</value>
</data>
<data name="Enum_CostingMethod_Fifo" xml:space="preserve">
<value>FIFO</value>
</data>
<!-- Acme.Localization/Acme.uk.resx -->
<data name="Enum_ItemCategoryType_Goods" xml:space="preserve">
<value>Товар</value>
</data>
<data name="Enum_CostingMethod_Fifo" xml:space="preserve">
<value>ФІФО</value>
</data>
Resolution is done by one method, EnumLocalizationExtensions.GetCaption(this Enum, IStringLocalizer)
(Kandra.Localization): no [EnumCaption] → the raw member name; isLiteral: true → the
literal text; otherwise localizer[key], which goes through the normal
localization chain. Everything that shows an enum value — the select, the
grid, print and CSV — uses it. Never pass isLiteral: true just to skip translating.
5. Use it as a property
Both sides use the same enum type — no mapping, no second enum:
// Acme.Domain/Entities/Dictionaries/Item.cs
public ItemCategoryType Category { get; set; }
// Acme.Forms/Dictionaries/Item.cs
[Required]
[Caption("Category")]
[ListFormColumn(Filterable = false)]
public ItemCategoryType Category { get; set; }
What you get without writing anything else:
- Form editor. Any enum-typed property is discovered as an enum field and gets the
EnumSelecteditor: aMudSelectoverEnum.GetValues<TEnum>()— every member, ascending numeric order — labelled withGetCaption(...). - Print and CSV.
PrintValueFormatterrenders an enum throughGetCaption, falling back to the raw member name only when the member has no[EnumCaption]. This is shared engine code, not something each print form does — a report or document column typed as your enum prints the translated label with no[Format]needed. - List column.
[ListFormColumn(Filterable = false)]is what the reference configuration ships forCategory. The query layer has enum support, but this has not been verified for the grid's filter UI, so treatFilterable = falseas a working default, not a rule the engine states.
This is the step that needs an EF migration: a new integer column on the entity's table.
Regenerate all three providers (see Database migrations). Add a [Caption] resx
key for the property's own name (Category) as usual.
As a subconto dimension
Bind a Guid Dto property with [EnumGuidDropdown(typeof(CostingMethod))] (for several values,
[EnumGuidMultiSelect] on an IReadOnlyList<Guid>?), and reference EnumSubconto<CostingMethod>
in the chart of accounts. EnumSubconto<TEnum> adapts the decorated enum into an ISubconto,
reading the enum's [TypeId] and each member's [SubcontoId] by reflection the first time the
closed generic type is used. The chart-of-accounts side belongs to
Chart of accounts and the post-to-accounting /
add-account-subconto-field skills. Neither real enum is used through EnumSubconto<> in the
reference configuration today, so the Guid + [EnumGuidDropdown] pattern follows the
attribute's own contract rather than a copy-from-real-code recipe.
6. Verify
dotnet build Acme.slnx — for the subconto role, no KANDRATYPEREG* diagnostic. Then, live:
open a form with the property and confirm the select lists every member with a translated
label; switch the UI language and confirm the labels follow (the only way to catch a key
missing from .ru/.uk); save, reopen, and export or print a row and confirm the label, not
the member name, appears.
Gotchas worth knowing before you start
- Explicit values, never renumbered. The database stores integers (see step 2).
- A missing resx key compiles and runs — the label renders as the raw key
(
Enum_ItemCategoryType_Kit, literally). A key present in.resxbut absent from.uk.resxshows the fallback for Ukrainian users only, which is why the verify step switches language. - A
[TypeId]or[SubcontoId]GUID is permanent and must be unique. A duplicate[TypeId]across two types fails the build (KANDRATYPEREG003;KANDRATYPEREG004is a[TypeId]that isn't a valid GUID). A duplicate[SubcontoId]on two members is not caught at build. [SubcontoId]failures are runtime, not build-time.new EnumSubconto<T>(value)for a member without[SubcontoId]throwsInvalidOperationExceptionimmediately, so adding a postable member later needs its own fresh[SubcontoId]— forgetting it fails at that value's first posting.- A missing enum-level
[TypeId]breaksEnumSubconto<T>for the whole process. It throws from the closed generic type's static initializer, and .NET caches a failed type initializer, so every later use fails too. Fix the attribute and restart; don't retry. - The select lists every member, in numeric order. You can't hide a retired member from the dropdown, so leave it in place by convention or split the enum.
[TypeId](enum) and[SubcontoId](member) are unrelated — a common mix-up. One names the type, the other names a value.- Keep the enum in
Acme.Enums, or the Blazor client can't see it.
See also
- Creating a dictionary — the entity a property like
Item.Categorylives on, and the same type-identity convention. - Creating a constant — the other lightweight kind, using the same
AcmeTypeIdssibling-file mechanism. - Chart of accounts — enum-backed subconto.
- Localization and Database migrations.
- Reference: Attributes