Overall architecture
This page is the map you keep coming back to. It doesn't go deep on any one subsystem — each "Creating a..." guide and the Reference section do that — but it should let you place any file you're looking at into the right layer.
Your configuration's own layout
This is what dotnet new kandra-config -n Acme puts in your repo — 11 projects, all
prefixed with your configuration's name, nothing else:
Acme.slnx
src/
Acme.Domain # concrete *Base entities (Entities/Dictionaries, /Documents, /Registers, ...)
Acme.Forms # concrete *Dtos + *Validators (client+server shared)
Acme.Application # concrete *Behaviors + AutoMapper profiles, generated DI wiring
Acme.Persistence # generated EntityConfigurations, repositories
Acme.Persistence.Databases # DbContext(s) + per-provider Migrations (only for providers you selected)
Acme.WebApi # runnable host — generated controllers land here
Acme.Client.MudBlazor # the runnable Blazor WebAssembly client — 100% generated pages
Acme.ClientLib.Common # hand-written Refit interfaces + client services
Acme.Enums
Acme.Localization
Acme.Tests
It starts empty (no Dictionaries/Documents/Reports/Registers/Data Processors) and grows one
entity at a time via the shipped .claude/skills/ — see
How to start.
The one rule that matters most inside this shape: Acme.Forms must never reference
Acme.Application/Acme.Domain/Acme.Persistence. The Blazor WebAssembly client only ever
sees Forms (plus Enums/Localization); the server references Forms too, because mappers
and behaviors need both the *Base entity and the *Dto. Cross that boundary and the client
build breaks trying to pull in EF Core.
What you're referencing, not browsing
Kandra.* is consumed purely via NuGet PackageReference — there is no platform source
anywhere in your repo, unless you've separately checked out the platform team's own monorepo
for reference (see How to start).
The package names below tell you where a given concept's logic actually lives, even though
you can't cd into it:
| Package | What it's responsible for |
|---|---|
Kandra.Domain | Entity base classes, register engine, Identity contracts — EF-free |
Kandra.Application(.Abstractions) | CRUD services, behavior/validator/mapper contracts, numbering |
Kandra.Persistence(.Abstractions) | EF Core integration, register writers/readers, accounting subsystem |
Kandra.Api.Lib | ASP.NET Core wiring (AddKandra/UseKandra), controller base classes |
Kandra.Attributes | The metadata vocabulary the generators read |
Kandra.ClientLib.Common/.MudBlazor | Client-side service/DI plumbing |
Kandra.Generators.* | Roslyn source generators (run at your build time, from the NuGet package) |
Kandra.Printing | PDF/CSV export, timezone-aware value formatting |
$(KandraCoreVersion) in your Directory.Build.props pins which published build of these
packages you're on — a manually-maintained version string, bumped by hand after you've
validated a newer engine release actually works with your configuration.
The generation pipeline, end to end
You write: *Base entity + *Dto (attributed) + *Behavior + *Validator
│
▼
Kandra.Generators.* read the Dto/entity attributes at compile time
│
┌───────────┼──────────────────┬────────────────────┬──────────────┐
▼ ▼ ▼ ▼ ▼
EF Core CRUD controller Client DI (Refit DI registration Generated Blazor
config (Kandra.Generators. client + service blocks UI pages
(Generated Api) → WebApi wrapper) → (ConfigureServices (Document/Report
.Net/) ClientLib .cs) emitters)
Nothing here is a runtime reflection pass — it all happens at dotnet build time, and the
output is ordinary checked-in-shape C#/Razor under each project's Generated.Net/ folder.
Read it when you want to know exactly what an attribute produces; never hand-edit it.
Request-time flow (a document submit, as the representative case)
- Blazor client posts to a generated controller endpoint.
- The generated controller calls into the application-layer CRUD service, which resolves the
entity's
*Behavior. OnBeforeSaveAsync/OnSaveAsyncrun around the EF Core save.- On submit specifically,
OnSubmitAsyncruns with anISubmitScopeparameter — the only legal path toISubmitScope.GetWriter<TWriter>()(register writers) andIPostingService(chart-of-accounts postings), both keyed DI services unreachable by ordinary constructor injection. This is a deliberate chokepoint: nothing outside a submit can write a register or post a transaction. - Time zone conversion happens on the way out: the server stores/emits UTC everywhere; a
registered
JsonConverter<DateTime>converts every DTO field crossing the wire for display, andKandra.Printing'sPrintValueFormatterconverts server-side for PDF/CSV export. The client detects its IANA time zone natively and sends it as anX-Time-Zoneheader on every request.
Two accounting-adjacent subsystems that are deliberately not unified
- Register engine (
Kandra.Domain/Kandra.Persistenceregisters) — general analytical storage: balances, turnovers, info records. Any document can write any register it needs. - Chart-of-accounts / posting subsystem (
Kandra.Persistence/Accounting,Acc_tables) — a purpose-built double-entry-style accounting engine with its own account codes, subconto dimension slots, and posting batches.
They look similar (both are "documents write structured records on submit") but are built independently on purpose — see Register Engine and Chart of Accounts / Posting for why, and don't try to implement one in terms of the other.
Identity and localization, briefly
- Identity is generic over
<TUser, TRole>at the platform layer, closed to concrete types per configuration. Two JWT paths: human users, and API keys for non-human callers. - Localization is a stacked-module chain (
LocalizationChain) — the most-recently-registered module wins per key, falling through to earlier modules.services.AddLocalization()must be called beforeAddLocalizationModule().
See Identity & Auth and Localization for the full picture of each.
Where to go next
Pick the entity kind you're about to build: Dictionary, Register, Document, Report, Data processor, or Constant — each guide assumes you've read this page.