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

Flat dictionary: Branch

Full worked example for the flat shape — see Creating a dictionary for the conceptual background (the four generator-discovered pieces, when to reach for hierarchical instead) and Hierarchical dictionary: Counterparty for the other shape. If you have the create-dictionary skill available, prefer it over hand-rolling these steps — this page is what its data/Domain.Flat.cs/Forms.Flat.cs/Application.Flat.cs/ClientLib.Flat.cs templates produce once filled in.

Branch is about as simple as a dictionary gets: no custom fields beyond Code/Name (inherited from DictionaryBase unmodified), Code auto-numbered BR-000001, BR-000002, ... The real Branch in Kandra's own reference configuration also carries a [FilePicker]-backed RegistrationDocumentId field with blob reconciliation wired into its Behavior — that's a separate concern, not core to being a dictionary; see File storage & attachments if you need it. Left out here to keep this example focused on the four-piece pattern plus the two hand-written extras.

1. Mint a type identity

A fresh file, never an edit to AcmeTypeIds.cs itself (that file is partial and stays permanently empty of members — see Creating a dictionary's Gotchas section for why):

// Acme.Enums/AcmeTypeIds/AcmeTypeIds.Branch.cs
namespace Acme.Enums;

public static partial class AcmeTypeIds
{
public const string Branch = "18d2d31e-c312-411b-b9eb-291e10d89fd6";
}

Generate your own fresh GUID for a real dictionary — the value above is Branch's actual, already-registered identity in Kandra's own reference configuration, reused here only because it's real, verified-working code; never copy a literal type-identity value into a different dictionary.

2. Domain entity (*Base)

// Acme.Domain/Entities/Dictionaries/Branch.cs
using Kandra.Attributes.Entities;
using Kandra.Domain.Commons;
using Kandra.Domain.Entities.Dictionaries;
using Acme.Enums;

namespace Acme.Domain.Entities.Dictionaries;

/// <summary>
/// Not ISubconto - not used as a posting analytical dimension in the current chart.
/// Still implements IEntityWithTypeId so its TypeId is discoverable the same way as
/// every other document/dictionary in the domain.
/// </summary>
[KandraDictionaryEntity(TableName = "Dict_Branches")]
[KandraGeneratedEquality]
public partial class Branch : DictionaryBase, IEntityWithTypeId
{
public static Guid TypeId { get; } = new(AcmeTypeIds.Branch);
}

[KandraGeneratedEquality] fills in Equals/GetHashCode/ToString over this class's own declared properties — requires partial. Zero UI/auth attributes on this class, by design: a background job or importer can reference Acme.Domain without dragging in the web/UI layer.

3. Dto + Validator (*Dto, *Validator)

// Acme.Forms/Dictionaries/Branch.cs
using Kandra.Attributes.Application;
using Kandra.Attributes.Entities;
using Kandra.Attributes.Naming;
using Kandra.Forms.Dictionaries;
using Kandra.Forms.Querying;
using Kandra.Validators.Dictionaries;
using Acme.Enums;
using Microsoft.Extensions.Localization;

namespace Acme.Forms.Dictionaries;

[TypeId(AcmeTypeIds.Branch)]
[KandraDictionaryForm(Name = "Branches", ValidatorType = typeof(BranchValidator), QueryDtoType = typeof(BranchQueryDto))]
[EditFormTitle("EditBranch")]
[AddFormTitle("CreateBranch")]
[ViewFormTitle("ViewBranch")]
[ListFormTitle("Branches")]
[NavigationName("Nav_Branches")]
public class BranchDto : DictionaryDto
{
// Code/Name are inherited unmodified from DictionaryDto — most dictionaries don't need to
// override either. Add fields here as [Required]/[Caption]/[ListFormColumn]-attributed
// properties once this dictionary needs more than Code/Name.
}

public class BranchQueryDto : DictionaryQueryDto
{
}

public class BranchValidator : DictionaryValidator<BranchDto>
{
public BranchValidator(IStringLocalizer localizer) : base(localizer)
{
// base(localizer) already covers Code/Name NotEmpty — add extra RuleFor(...) calls here.
}
}

[KandraDictionaryForm]'s Name ("Branches") drives the generated controller's route segment and the Refit client's expected [PathPrefix] — it's not derived from the class name, always set it explicitly and keep step 5 in sync with it.

Every title/nav key referenced above (EditBranch, CreateBranch, ViewBranch, Branches, Nav_Branches) needs a matching <data name="..."> entry in all three of your configuration's engine-facing locale files (Acme.Localization/Acme.resx / .ru.resx / .uk.resx) — a missing key silently renders as the raw key name at runtime, not an error. This is the application's own runtime localization (see Localization), a separate concern from this documentation site's own EN/UK translation tracking. Example shape:

<!-- Acme.Localization/Acme.resx -->
<data name="Nav_Branches" xml:space="preserve">
<value>Branches</value>
</data>
<data name="CreateBranch" xml:space="preserve">
<value>Create Branch</value>
</data>

4. Numbering bucket + Behavior + Mapper (*Behavior)

// Acme.Domain/Numbering/NumberingBuckets.Branch.cs
namespace Acme.Domain.Numbering;

public static partial class NumberingBuckets
{
public const string Branch = "Branch";
}
// Acme.Application/Dictionaries/Branch.cs
using AutoMapper;
using Kandra.Application.Abstractions.Behaviors;
using Kandra.Application.Abstractions.Services;
using Kandra.Application.Validation;
using Kandra.Attributes.Application;
using Acme.Domain.Entities.Dictionaries;
using Acme.Domain.Numbering;
using Acme.Forms.Dictionaries;

namespace Acme.Application.Dictionaries;

[KandraDictionaryBehavior(typeof(BranchDto))]
public class BranchBehavior(ISimpleNumberingSystem numbering) : IFlatDictionaryBehavior<Branch>
{
public async ValueTask OnNewAsync(Branch entity, CancellationToken cancellationToken) =>
entity.Code = await numbering.GetNextAsync(NumberingBuckets.Branch, raw => $"BR-{raw:D6}",
requestorTag: "Branch:OnNew", cancellationToken: cancellationToken);
}

public class BranchMapper : Profile
{
public BranchMapper() => CreateMap<Branch, BranchDto>().ReverseMap();
}

public class BranchQueryDtoValidator : PagedQueryDtoValidator<BranchQueryDto>;

A no-op Behavior (nothing overridden beyond the mandatory numbering) is completely valid for most dictionaries — but the class and its [KandraDictionaryBehavior] attribute must still exist. The engine fails DI validation at startup without one per dictionary. If Code is hand-typed instead of auto-numbered (a rare case — Kandra's own Currency dictionary does this), skip the numbering bucket file entirely and make OnNewAsync a plain no-op.

5. Refit client interface (hand-written)

// Acme.ClientLib.Common/ApiClients/IBranchesApiClient.cs
using Kandra.ClientLib.Common.ApiClients;
using Kandra.Forms.Paging;
using Acme.Forms.Dictionaries;
using Refit;

namespace Acme.ClientLib.Common.ApiClients;

[PathPrefix("/api/v1/dictionaries/Branches")]
public interface IBranchesApiClient : IDictionaryApiClient<BranchDto, PagedResult<BranchDto>, BranchQueryDto>
{
// Nothing to add for the common case — IDictionaryApiClient<> already supplies the full CRUD
// set (AllAsync/QueryAsync/ReadAsync/InsertAsync/UpdateAsync/DeleteAsync/GetChangeStateAsync)
// plus NewAsync/LookupAsync. Refit's own source generator implements the body at compile time
// from these attributes — you only ever write the declaration.
}

[PathPrefix] must match [KandraDictionaryForm(Name = "...")] from step 3 exactly — here, "Branches" both times.

6. EF migration

cd src/Acme.Persistence.Databases
dotnet kandra-migrate add AddBranch

(or the per-provider dotnet ef migrations add fallback — see Database migrations if kandra-migrate isn't installed yet in your setup.)

7. Verify

dotnet build Acme.slnx — confirm BranchesController.g.cs now exists under Acme.WebApi/Generated.Net/. dotnet test Acme.slnx, then run the app and exercise create/edit/list/delete for Branches in the browser. There's still one more hand-written piece this walkthrough doesn't cover — the Blazor UI pages themselves (no generator exists for this yet); copy the closest existing dictionary's Pages/Dictionary/*/*.razor set as a starting point and wire it to IBranchesApiClient from step 5.

See also