Перейти до основного вмісту

Creating an enum

Use the shipped skill

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:

  1. The generated form — an enum-typed property gets a select listing every member with a localized label.
  2. Print and CSV export — the same localized label appears, not the raw member name.
  3. 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.

WhatWhereNeeded
The enumAcme.Enums/{Name}.csalways
Localization keysAcme.Localization/Acme.resx + .ru.resx + .uk.resxalways
Type identitynew sibling file Acme.Enums/AcmeTypeIds/AcmeTypeIds.{Name}.csonly for the subconto role
A migrationnew migration per provideronly 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 enumSubconto-backing enum
Use whena 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 identitynot neededrequired

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.Category to 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 with isLiteral: 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 a T? 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:

AttributeOnIdentifies
[TypeId]the enumthe type — what the client type registry and EnumSubconto<T> read
[SubcontoId]each memberthat 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 EnumSelect editor: a MudSelect over Enum.GetValues<TEnum>() — every member, ascending numeric order — labelled with GetCaption(...).
  • Print and CSV. PrintValueFormatter renders an enum through GetCaption, 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 for Category. The query layer has enum support, but this has not been verified for the grid's filter UI, so treat Filterable = false as 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 .resx but absent from .uk.resx shows 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; KANDRATYPEREG004 is 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] throws InvalidOperationException immediately, 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] breaks EnumSubconto<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