Hierarchical dictionary: Counterparty
Full worked example for the hierarchical shape — see Creating a dictionary for the
conceptual background and Flat dictionary: Branch for the simpler shape (read that
one first if you haven't; this page assumes you've seen the basic four-piece pattern and
focuses on what's different here). If you have the create-dictionary skill available, prefer
it over hand-rolling these steps — this page is what its
data/Domain.Hierarchical.cs/Forms.Hierarchical.cs/Application.Hierarchical.cs/
ClientLib.Hierarchical.cs templates produce once filled in.
Hierarchical is meaningfully more machinery than flat: a three-class TPH tree (abstract root +
folder + leaf), Depth/Path materialized-path columns the engine manages for you, and two
separate identities/numbering buckets (one per folder and leaf kind) instead of one. Only reach
for it when folder-style grouping is a real requirement.
1. Mint two type identities — one per kind
Hierarchical mints an identity for the leaf and (usually) the folder — never for the abstract root itself, which has no identity of its own:
// Acme.Enums/AcmeTypeIds/AcmeTypeIds.Counterparty.cs
namespace Acme.Enums;
public static partial class AcmeTypeIds
{
public const string Counterparty = "2bf88ca8-a003-429d-bb08-a64cd33959b4";
}
// Acme.Enums/AcmeTypeIds/AcmeTypeIds.CounterpartyFolder.cs
namespace Acme.Enums;
public static partial class AcmeTypeIds
{
public const string CounterpartyFolder = "2da6261a-2eda-4da5-ab10-c6967ca99ebf";
}
Same caveat as Branch in the flat example: generate your own fresh GUIDs for a real
dictionary, these are Counterparty's real values reused for illustration only.
2. Domain entities — abstract root, folder, leaf (*Base)
// Acme.Domain/Entities/Dictionaries/Counterparty.cs
using Kandra.Attributes.Entities;
using Kandra.Domain.Commons;
using Kandra.Domain.Entities.Dictionaries;
using Acme.Enums;
namespace Acme.Domain.Entities.Dictionaries;
// [KandraDictionaryEntity] goes on the abstract root ONLY — never repeat it on Folder/leaf.
[KandraDictionaryEntity(TableName = "Dict_Counterparties")]
public abstract class CounterpartyNode : HierarchicalDictionaryBase
{
}
/// <summary>
/// Not ISubconto - a folder is a structural grouping node, not a postable value. Still
/// implements IEntityWithTypeId so its TypeId is discoverable the same way as every
/// other document/dictionary in the domain.
/// </summary>
[KandraDictionaryFolder]
[KandraGeneratedEquality]
public sealed partial class CounterpartyFolder : CounterpartyNode, IEntityWithTypeId
{
public static Guid TypeId { get; } = new(AcmeTypeIds.CounterpartyFolder);
}
/// <summary>
/// Implements ISubconto so a counterparty can be used directly as a posting analytical
/// dimension (see Chart of accounts) - no separate wrapper type.
/// </summary>
[KandraGeneratedEquality]
public sealed partial class Counterparty : CounterpartyNode, ISubconto
{
public static Guid TypeId { get; } = new(AcmeTypeIds.Counterparty);
public string? ContactName { get; set; }
public string? Address { get; set; }
public string? City { get; set; }
public string? Phone { get; set; }
// The real Counterparty also carries ContactTitle/Region/PostalCode/Country/Fax — same
// shape as the four shown here, trimmed for length.
}
HierarchicalDictionaryBase supplies ParentId/Depth/Path — Depth/Path are
engine-managed (the hierarchical CRUD service maintains them), never set by hand and never
exposed on a Dto.
3. Dto trio + Validator trio (*Dto, *Validator)
// Acme.Forms/Dictionaries/Counterparty.cs
using System.Text.Json.Serialization;
using FluentValidation;
using Kandra.Attributes.Application;
using Kandra.Attributes.Editors;
using Kandra.Attributes.Entities;
using Kandra.Attributes.Layout;
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;
// Discriminator values are strings ("1"/"0"), not the int 1/0 — see Creating a dictionary's
// Gotchas section for why.
[JsonPolymorphic(TypeDiscriminatorPropertyName = "isFolder")]
[JsonDerivedType(typeof(CounterpartyFolderDto), "1")]
[JsonDerivedType(typeof(CounterpartyDto), "0")]
// The full [KandraDictionaryForm]/title/nav set goes on the ABSTRACT ROOT, not the leaf.
[KandraDictionaryForm(Name = "Counterparties", ValidatorType = typeof(CounterpartyNodeValidator), QueryDtoType = typeof(CounterpartyQueryDto))]
[EditFormTitle("EditCounterparty")]
[AddFormTitle("CreateCounterparty")]
[ViewFormTitle("ViewCounterparty")]
[ListFormTitle("Counterparties")]
[NavigationName("Nav_Counterparties")]
public abstract class CounterpartyNodeDto : HierarchicalDictionaryItemDto
{
}
[TypeId(AcmeTypeIds.Counterparty)]
[FormTab("Contact")]
public class CounterpartyDto : CounterpartyNodeDto
{
[TabRef("Contact")] [Caption("ContactName")]
public string? ContactName { get; set; }
[TabRef("Contact")] [Caption("Address")] [Multiline]
public string? Address { get; set; }
[TabRef("Contact")] [Caption("City")]
public string? City { get; set; }
[TabRef("Contact")] [Caption("Phone")] [ListFormColumn(Filterable = false)]
public string? Phone { get; set; }
}
[TypeId(AcmeTypeIds.CounterpartyFolder)]
public class CounterpartyFolderDto : CounterpartyNodeDto
{
// Folder-only fields (rare) go here — most hierarchical dictionaries leave this empty.
}
public class CounterpartyQueryDto : HierarchicalDictionaryQueryDto
{
}
/// Leaf-only rules. Not registered as a top-level Dto validator — InsertAsync/UpdateAsync
/// always validate against CounterpartyNodeDto, so this is wired in conditionally below.
public class CounterpartyValidator : DictionaryValidator<CounterpartyDto>
{
public CounterpartyValidator(IStringLocalizer localizer) : base(localizer)
{
}
}
/// Folder-only rules — kept separate so a future folder-specific rule has somewhere to go
/// without touching CounterpartyValidator.
public class CounterpartyFolderValidator : DictionaryValidator<CounterpartyFolderDto>
{
public CounterpartyFolderValidator(IStringLocalizer localizer) : base(localizer)
{
}
}
/// The validator actually registered for the hierarchical CRUD service's Dto
/// (CounterpartyNodeDto). Covers shared Code/Name rules directly (via the base ctor), then
/// dispatches to the folder/leaf validator by runtime type.
public class CounterpartyNodeValidator : DictionaryValidator<CounterpartyNodeDto>
{
public CounterpartyNodeValidator(IStringLocalizer localizer) : base(localizer)
{
RuleFor(x => x as CounterpartyDto)
.SetValidator(new CounterpartyValidator(localizer))
.When(x => x is CounterpartyDto);
RuleFor(x => x as CounterpartyFolderDto)
.SetValidator(new CounterpartyFolderValidator(localizer))
.When(x => x is CounterpartyFolderDto);
}
}
Same resx-per-key rule as Branch in the flat example, just a longer key list:
EditCounterparty, CreateCounterparty, ViewCounterparty, Counterparties,
Nav_Counterparties, the tab key Contact, and every field [Caption] (ContactName,
Address, City, Phone, ...) — all three locale resx files, or each renders as its raw key
name.
4. Numbering buckets + Behavior + Mapper (*Behavior)
Two buckets — folder and leaf usually want different prefixes/widths:
// Acme.Domain/Numbering/NumberingBuckets.Counterparty.cs
namespace Acme.Domain.Numbering;
public static partial class NumberingBuckets
{
public const string Counterparty = "Counterparty";
}
// Acme.Domain/Numbering/NumberingBuckets.CounterpartyFolder.cs
namespace Acme.Domain.Numbering;
public static partial class NumberingBuckets
{
public const string CounterpartyFolder = "CounterpartyFolder";
}
// Acme.Application/Dictionaries/Counterparty.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(CounterpartyNodeDto))]
public class CounterpartyBehavior(ISimpleNumberingSystem numbering) : IHierarchicalDictionaryBehavior<CounterpartyNode>
{
public async ValueTask OnFolderNewAsync(CounterpartyNode entity, CancellationToken cancellationToken) =>
entity.Code = await numbering.GetNextAsync(NumberingBuckets.CounterpartyFolder, raw => $"CPF-{raw:D3}",
requestorTag: "CounterpartyFolder:OnNew", cancellationToken: cancellationToken);
public async ValueTask OnLeafNewAsync(CounterpartyNode entity, CancellationToken cancellationToken) =>
entity.Code = await numbering.GetNextAsync(NumberingBuckets.Counterparty, raw => $"CP-{raw:D6}",
requestorTag: "Counterparty:OnNew", cancellationToken: cancellationToken);
}
public class CounterpartyMapper : Profile
{
public CounterpartyMapper()
{
// .Include<>() on BOTH the root map and its reverse is what makes AutoMapper
// polymorphic-resolve through the abstract CounterpartyNode/CounterpartyNodeDto type —
// each concrete pair still needs its own explicit CreateMap<>() too.
CreateMap<CounterpartyNode, CounterpartyNodeDto>()
.Include<CounterpartyFolder, CounterpartyFolderDto>()
.Include<Counterparty, CounterpartyDto>();
CreateMap<CounterpartyFolder, CounterpartyFolderDto>();
CreateMap<Counterparty, CounterpartyDto>()
.ReverseMap()
.ForMember(d => d.Depth, o => o.Ignore())
.ForMember(d => d.Path, o => o.Ignore())
.ForMember(d => d.CreatorUserId, o => o.Ignore())
.ForMember(d => d.ModifierUserId, o => o.Ignore())
.ForMember(d => d.Created, o => o.Ignore())
.ForMember(d => d.Modified, o => o.Ignore())
.ForMember(d => d.IsDeleted, o => o.Ignore());
// Depth/Path/audit fields are server-managed and never come from the Dto — repeat the
// same .Ignore() list on every entity-bound map direction, or a client payload could
// silently overwrite a server-managed field.
CreateMap<CounterpartyNodeDto, CounterpartyNode>()
.Include<CounterpartyFolderDto, CounterpartyFolder>()
.Include<CounterpartyDto, Counterparty>()
.ForMember(d => d.Depth, o => o.Ignore())
.ForMember(d => d.Path, o => o.Ignore())
.ForMember(d => d.CreatorUserId, o => o.Ignore())
.ForMember(d => d.ModifierUserId, o => o.Ignore())
.ForMember(d => d.Created, o => o.Ignore())
.ForMember(d => d.Modified, o => o.Ignore())
.ForMember(d => d.IsDeleted, o => o.Ignore());
CreateMap<CounterpartyFolderDto, CounterpartyFolder>()
.ForMember(d => d.Depth, o => o.Ignore())
.ForMember(d => d.Path, o => o.Ignore())
.ForMember(d => d.CreatorUserId, o => o.Ignore())
.ForMember(d => d.ModifierUserId, o => o.Ignore())
.ForMember(d => d.Created, o => o.Ignore())
.ForMember(d => d.Modified, o => o.Ignore())
.ForMember(d => d.IsDeleted, o => o.Ignore());
}
}
public class CounterpartyQueryDtoValidator : PagedQueryDtoValidator<CounterpartyQueryDto>;
5. Refit client interface — binds to the root Dto
// Acme.ClientLib.Common/ApiClients/ICounterpartiesApiClient.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/Counterparties")]
public interface ICounterpartiesApiClient : IHierarchicalDictionaryApiClient<CounterpartyNodeDto, PagedResult<CounterpartyNodeDto>, CounterpartyQueryDto>
{
// IHierarchicalDictionaryApiClient<> supplies the full CRUD set plus
// NewAsync(parentId, isFolder)/LookupAsync/MoveToAsync.
}
Binds to the abstract root Dto (CounterpartyNodeDto), never the leaf — same placement
rule as [KandraDictionaryForm] in step 3.
6. EF migration and verify
Same command shape as Branch in the flat example, just a different migration name:
cd src/Acme.Persistence.Databases
dotnet kandra-migrate add AddCounterparty
Then dotnet build Acme.slnx (confirm CounterpartiesController.g.cs exists),
dotnet test Acme.slnx, and exercise folder creation, moving a leaf between folders, and
create/edit/list/delete in the browser.
See also
- Flat dictionary: Branch — the simpler shape; read it first if you haven't.
- Creating a dictionary — the conceptual overview and the Gotchas that apply to both
shapes, including the JSON discriminator and AutoMapper
.Ignore()details referenced above.