Skip to main content

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/PathDepth/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.