Skip to main content

Balance register: Inventory

Full worked example for the Balance family — see Creating a register for the concepts shared by every family (dimensions, the ISubmitScope write path, readers, the register transactions tab). If you have the create-register skill available, prefer it over hand-rolling these steps; this page is what its data/Dimensions.cs + data/Balance.cs skeletons produce once filled in.

Inventory is the live stock register in Kandra's own reference configuration: how much of which item sits in which warehouse. It's the canonical Balance shape — a movement log (every receipt and issue, kept forever) plus a balance table (the current running total per dimension combination, maintained by the engine as movements are posted). The example below is lightly trimmed from the real source; Acme stands in for your configuration's root name.

It's written to by two documents in the reference configuration: GoodsReceipt (the simple, one-leg case this page walks through) and Transfer (two legs plus FIFO/FEFO cost allocation — inventory-costing-specific, not something the register engine itself requires).

1. Decide the shape

Before writing code, answer three questions:

  • What is the analytical key? Here: (Warehouse, Item, Party) → three dimensions.
  • What accumulates? Here: a quantity (must never go negative) and an amount (may be zero — a zero-cost receipt is legitimate) → two resources, so a custom Resources class rather than the shipped NonNegativeQuantityResources.
  • What rides along per movement but isn't aggregated? Here: supplier, serial number, expiration date → Details.

2. Dimensions, Resources, Details

All in one file, Acme.Domain/Entities/Registers/Inventory.cs, top to bottom.

using System.Text.Json.Serialization;
using Kandra.Attributes.Entities;
using Kandra.Attributes.Naming;
using Kandra.Attributes.Printable;
using Kandra.Domain.Exceptions;
using Kandra.Domain.Registers;
using Acme.Domain.Entities.Dictionaries;
using Acme.Domain.Entities.Documents;
using Microsoft.EntityFrameworkCore;

namespace Acme.Domain.Entities.Registers;

// ---- Resources: the accumulated values, with their own algebra ----------
public sealed class InventoryResources : IRegisterResources<InventoryResources>
{
[Precision(14, 3)] [Caption("Quantity")] [Format("N3")]
public decimal Quantity { get; init; }

[Precision(14, 5)] [Caption("Amount")] [Format("N2")]
public decimal Amount { get; init; }

public static InventoryResources AdditiveIdentity => new();

public static InventoryResources operator +(InventoryResources a, InventoryResources b)
=> new() { Quantity = a.Quantity + b.Quantity, Amount = a.Amount + b.Amount };

public static InventoryResources operator -(InventoryResources a)
=> new() { Quantity = -a.Quantity, Amount = -a.Amount };

public static InventoryResources operator -(InventoryResources a, InventoryResources b)
=> a + (-b);

// Only Quantity is guarded: a zero-cost receipt makes Amount legitimately zero.
public void EnsureNonNegative()
{
if (Quantity < 0)
throw new InsufficientBalanceException($"{nameof(Quantity)} cannot be negative ({Quantity}).");
}

public bool IsZero => Quantity == 0m && Amount == 0m;
}

// ---- Details: movement-only payload, excluded from the balance key ------
public sealed class InventoryDetails : IRegisterDetails
{
[Caption("Supplier")]
[ReferenceOf(typeof(Counterparty))]
public Guid? SupplierId { get; set; }

// The creator document's own TypeId — lets the viewer resolve Dimensions.PartyId (below).
[Caption("DocumentType")]
[RegisterHidden]
public Guid? PartyCreatorDocumentTypeId { get; set; }

[Caption("SerialNumber")]
public string? SerialNumber { get; set; }

[Caption("ExpirationDate")] [Format("d")]
public DateOnly? ExpirationDate { get; set; }
}

// ---- Dimensions: the analytical key -------------------------------------
[KandraGeneratedEquality]
public sealed partial class InventoryDimensions : IRegisterDimensions<InventoryDimensions>
{
[Caption("Warehouse")]
public Guid WarehouseId { get; set; }
[JsonIgnore]
public Warehouse Warehouse { get; set; } = null!;

[Caption("Item")]
public Guid ItemId { get; set; }
[JsonIgnore]
public Item Item { get; set; } = null!;

// Deliberately bare: no navigation property, see the note below.
[Caption("Party")]
[ReferenceOf(TypeIdProperty = nameof(InventoryDetails.PartyCreatorDocumentTypeId),
PossibleTypes = new[] { typeof(GoodsReceipt) })]
public Guid PartyId { get; set; }
}

Why PartyId has no navigation. It identifies a batch: Guid.Empty means "no batch — moving-average item", and for a real batch it's the Id of the document that created it. There is no honest table row a foreign key could always point at (the Guid.Empty sentinel has none, and the target document type varies), and documents are soft-deletable while a register must stay queryable forever. So it's a bare Guid, and the register transactions viewer resolves it via a dynamic [ReferenceOf(TypeIdProperty = ...)] that reads the sibling PartyCreatorDocumentTypeId field — itself [RegisterHidden], so it's a real stored column but never shown as a raw Guid.

Amount and the non-negative guard. The register only guards Quantity. It can't guard Amount independently: as long as movements are always posted with proportional quantity and amount, Amount can't go negative when Quantity doesn't.

Operators must be total. +, unary -, and binary - never throw and never clamp — the engine relies on x + (-x) == AdditiveIdentity. Only EnsureNonNegative() throws, and only the engine calls it (see Gotchas in Creating a register).

3. Row entities and the register handle

Still the same file, below the components:

[KandraRegisterEntity(TableName = "Reg_Inventory_Balance")]
public sealed class InventoryBalance : Balance<InventoryDimensions, InventoryResources>
{
}

[KandraRegisterEntity(TableName = "Reg_Inventory")]
[RegisterCaption("Inventory")]
public sealed class InventoryMovement : Movement<InventoryDimensions, InventoryResources, InventoryDetails>
{
}

public sealed partial class InventoryRegister : BalanceRegister<InventoryMovement, InventoryBalance>
{
}
  • [KandraRegisterEntity(TableName = ...)] is mandatory on both Balance and Movement and the table name is always set explicitly, never inferred. Convention: Reg_{Name} for the movement log and Reg_{Name}_Balance for the balance table.
  • [RegisterCaption] goes on the Movement only, not the Balance. Its value is a resx key by default, like [Caption].
  • The InventoryBalance row inherits an engine-managed Version (the optimistic-concurrency counter) and a surrogate Id — EF can't key on properties of an owned type such as Dimensions, so the balance table has its own primary key.
  • The register handle class is sealed partial and empty — it exists purely as the register's identity.

Swap InventoryDetails for NoDetails on the Movement if you have no movement-scoped fields.

4. DI and EF configuration — nothing to write

Build the solution. The DI generator walks every [KandraRegisterEntity] class, pairs each Balance with the Movement that has the same Dimensions and Resources types, and emits — into Acme.Persistence/Generated.Net/.../GeneratedAcmePersistenceDiExtensions.g.cs:

services.AddBalanceRegister<InventoryMovement, InventoryDimensions, InventoryResources, InventoryDetails, InventoryBalance>();

That one call registers the reader, the keyed writer, and IRegisterMaintenance<,> (rebuild/verify tooling for the balance table). The EF-config generator separately emits an InventoryBalanceConfiguration and InventoryMovementConfiguration into Acme.Persistence/Generated.Net/ covering table names, owned-type mapping of Dimensions/Resources/Details, [Precision] columns, and FK relationships for every navigation-paired dimension. Confirm both exist after building; don't write either by hand.

5. Localization

Every [Caption]/[RegisterCaption] key above needs an entry in all three of Acme.Localization/Acme.resx, .ru.resx and .uk.resx: Inventory, Warehouse, Item, Party, Quantity, Amount, Supplier, DocumentType, SerialNumber, ExpirationDate. Keys that other entities already define (Warehouse, Item, Quantity) need no new entry.

6. Write it from a document Behavior

The register-write code lives in the document's own Behavior file — no new file for the register. This is the simple, single-leg case from GoodsReceiptBehavior, trimmed to the register write:

[KandraDocumentBehavior(typeof(GoodsReceiptDto))]
[RegisterTransactions(typeof(InventoryMovement))]
public class GoodsReceiptBehavior(/* numbering, costing, etc. */) : IDocumentBehavior<GoodsReceipt>
{
public async ValueTask OnSubmitAsync(GoodsReceipt entity, bool isInsert,
ISubmitScope registers, CancellationToken cancellationToken)
{
var movements = entity.Lines.Select(line => new InventoryMovement
{
LineId = line.Id,
IsExpense = false, // a receipt adds stock
Dimensions = new InventoryDimensions
{
WarehouseId = entity.ToWarehouseId,
ItemId = line.ItemId,
// Real code picks Guid.Empty (moving-average items) or this document's own Id
// (every other costing method) — see the note under section 2.
PartyId = entity.Id,
},
Resources = new InventoryResources { Quantity = line.Quantity, Amount = line.Quantity * line.Price },
Details = new InventoryDetails
{
SupplierId = entity.CounterpartyId,
PartyCreatorDocumentTypeId = GoodsReceipt.TypeId,
ExpirationDate = line.ExpirationDate,
SerialNumber = line.SerialNumber,
},
}).ToList();

var writer = registers.GetWriter<IBalanceRegisterWriter<InventoryMovement, InventoryBalance>>();
await writer.PostAsync(entity.Id, entity.Date, movements, cancellationToken);
}

public async ValueTask OnUnsubmitAsync(GoodsReceipt entity, ISubmitScope registers,
CancellationToken cancellationToken) =>
await registers.GetWriter<IBalanceRegisterWriter<InventoryMovement, InventoryBalance>>()
.UnpostAsync(entity.Id, cancellationToken);

public ValueTask OnDeleteAsync(Guid id, ISubmitScope registers, CancellationToken cancellationToken) =>
new(registers.GetWriter<IBalanceRegisterWriter<InventoryMovement, InventoryBalance>>()
.DeleteAsync(id, cancellationToken));
}

What the engine does with that:

  • OnSubmitAsyncPostAsync. Loads the document's previous movements, stamps every new movement with a fresh sequential-v7 Id, the document's Id and Date, and Active = true; runs each movement's Resources.EnsureNonNegative(); then in one save replaces the old movements with the new ones and applies the net delta per dimension combination to the balance table (IsExpense movements subtract, receipts add). A repost therefore doesn't double-count — only the difference between the old and new sets lands on the balance.
  • OnUnsubmitAsyncUnpostAsync. Marks the document's movements Active = false and reverses their effect on the balance. The rows stay in the log; readers stop seeing them.
  • OnDeleteAsyncDeleteAsync. Unposts, then physically removes the movements.
  • If applying a delta would drive the touched balance negative, the guard throws InsufficientBalanceException, with a message naming the dimension combination that lacked stock — surfaced to the user as a failed submit.

The real GoodsReceiptBehavior also posts to the chart-of-accounts subsystem in the same OnSubmitAsync (via registers.GetPostingService()); that's an independent engine and out of scope here — see Chart of accounts.

7. Read it back

Constructor-inject the reader anywhere — no ISubmitScope needed. This is modeled on the live current-stock query in the reference configuration's reports repository (simplified to a single warehouse filter):

public class ReportsRepository(IBalanceReader<InventoryMovement, InventoryBalance> balanceReader /* , ... */)
{
public Task<List<RestReportLine>> GetCurrentRestsAsync(Guid warehouseId, CancellationToken ct) =>
balanceReader.Balances(
q => q
.Where(b => b.Dimensions.WarehouseId == warehouseId)
.Where(b => b.Resources.Quantity > 0)
.Select(b => new RestReportLine
{
WarehouseName = b.Dimensions.Warehouse.Name, // navigation pulled into the SQL
ItemCode = b.Dimensions.Item.Code,
ItemName = b.Dimensions.Item.Name,
Quantity = b.Resources.Quantity,
Amount = b.Resources.Amount,
}),
ct);
}

Filter and project inside the Func, in one expression — one SQL query, no Include(), no client-side filtering. Movement history uses Movements(from, to, ...) on the same reader, and Balances(at, ...) computes balances as of a past date ("current balance minus the effective sum of movements after at"), which — unlike the others — needs real Include() calls because it works on full TBalance objects, not a projection.

8. Migration

Add the two new tables with your normal per-provider migration flow — see Database migrations. Nothing here is register-specific.

9. Verify

  • dotnet build and check the two generated files from section 4.
  • dotnet test — Kandra's reference configuration has a test shape for each layer worth copying: a predicate test over the new dimensions type (InventoryDimensionsPredicateTests), a SQLite round-trip proving the predicate actually translates to SQL rather than silently falling back to client-side evaluation (InventoryBalancePredicateSqliteTests), a rebuild/verify test for the balance table (RegisterMaintenanceRebuildTests), and a test of the generated register-transactions reader through real DI (InventoryMovementTransactionsReaderSqliteTests).
  • Live: submit a GoodsReceipt, confirm the stock report shows it, open the document's View page and check the Register Transactions tab, unsubmit and confirm the stock returned to what it was.

See also