Document-bound info register: ExchangeRates
Full worked example for the document-bound Info family — see Creating a register for the
concepts shared by every family, and Balance register: Inventory for the
accumulating counterpart. If you have the create-register skill available, prefer it; this
page is what its data/Dimensions.cs + data/Details.cs + data/DocumentInfo.cs skeletons
produce once filled in.
An Info register stores facts that a document sets, not totals it accumulates. There is no
balance table and no Resources class: the stored value is the register's Details. This is
the most common shape in Kandra's reference configuration — three of its four live registers
are document-bound Info registers:
| Register | Dimensions (the key) | Details (the stored value) | Posted by |
|---|---|---|---|
ExchangeRates (this page) | from-currency, to-currency | Rate | RateImport |
Prices | price type, item | Price | PriceList |
CostingMethodSettings | warehouse?, item? (both nullable) | costing method, rank | CostingMethodSetting |
ExchangeRates is the simplest of the three, so it's the one walked through here. It answers
"what was the rate of currency X against the home currency on date D?" Acme stands in for
your configuration's root name.
How it differs from Balance
- No Resources class, no balance table, no
EnsureNonNegative(). Nothing accumulates. - One row entity, not two. A
DocumentInfoRecord— not aBalance+Movementpair — and a single table. - "Current value" is a date-sliced lookup, not a stored total. To get a rate you ask the
reader for the last record at or before a date (
SliceOfLast), per dimension combination. - Nullable dimensions are safe. With no balance table there is no unique upsert-target
index over the dimensions for a NULL to break, so
CostingMethodSettingsuses nullableWarehouseId/ItemIdas a deliberate "applies to all" wildcard. (Don't copy that into a Balance register.)
1. Details, Dimensions
Acme.Domain/Entities/Registers/ExchangeRates.cs, top to bottom:
using System.Text.Json.Serialization;
using Kandra.Attributes.Entities;
using Kandra.Attributes.Naming;
using Kandra.Attributes.Printable;
using Kandra.Domain.Registers;
using Acme.Domain.Entities.Dictionaries;
using Microsoft.EntityFrameworkCore;
namespace Acme.Domain.Entities.Registers;
/// <summary>The stored value: the exchange rate of FromCurrency against ToCurrency.</summary>
public sealed class ExchangeRateDetails : IRegisterDetails
{
[Precision(18, 6)]
[Caption("Rate")]
[Format("N6")]
public decimal Rate { get; set; }
}
/// <summary>
/// The analytical key: (from currency, to currency). Date and document are structural fields
/// on the record itself (Period / DocumentId), not part of this class.
/// </summary>
[KandraGeneratedEquality]
public sealed partial class ExchangeRateDimensions : IRegisterDimensions<ExchangeRateDimensions>
{
[Caption("FromCurrency")]
public Guid FromCurrencyId { get; set; }
[JsonIgnore]
public Currency FromCurrency { get; set; } = null!;
[Caption("ToCurrency")]
public Guid ToCurrencyId { get; set; }
[JsonIgnore]
public Currency ToCurrency { get; set; } = null!;
}
Both dimensions are ordinary navigation-paired foreign keys (Guid Id + [JsonIgnore]
navigation) to the Currency dictionary. [Precision(18, 6)] on Rate sets the column's
decimal precision; [Format("N6")] is the display format the register transactions viewer uses.
2. Record and register handle
Same file:
[KandraRegisterEntity(TableName = "Reg_ExchangeRates")]
[RegisterCaption("ExchangeRates")]
public sealed class ExchangeRateRecord : DocumentInfoRecord<ExchangeRateDimensions, ExchangeRateDetails>
{
}
public sealed partial class ExchangeRatesRegister : DocumentInfoRegister<ExchangeRateRecord>
{
}
Both [KandraRegisterEntity] and [RegisterCaption] go on the record class. The record
inherits Id, Period, DocumentId, Document, LineId and Active — see
Creating a register for who sets which.
3. DI and EF configuration — nothing to write
Build, then confirm this line was emitted into the generated persistence DI extensions:
services.AddDocumentInfoRegister<ExchangeRateRecord, ExchangeRateDimensions, ExchangeRateDetails>();
It registers the reader (IInfoRegisterReader<ExchangeRateRecord>) and a keyed writer
(IDocumentInfoRegisterWriter<ExchangeRateRecord>). EF configuration for the record is
generated the same way as for every family. If you use NoDetails there is no Details column
at all — the configuration ignores it.
4. Localization
Add to all three resx files: ExchangeRates, FromCurrency, ToCurrency, Rate.
5. Write it from a document Behavior
From RateImportBehavior, the document whose lines are "currency X has rate R on this date":
[KandraDocumentBehavior(typeof(RateImportDto))]
[RegisterTransactions(typeof(ExchangeRateRecord))]
public class RateImportBehavior(/* numbering, constants, currencies */) : IDocumentBehavior<RateImport>
{
public async ValueTask OnSubmitAsync(RateImport entity, bool isInsert,
ISubmitScope registers, CancellationToken cancellationToken)
{
var homeCurrency = /* resolved from a constant + the Currency dictionary */;
var records = entity.Lines.Select(line => new ExchangeRateRecord
{
LineId = line.Id,
Dimensions = new ExchangeRateDimensions
{
FromCurrencyId = line.CurrencyId,
ToCurrencyId = homeCurrency.Id,
},
Details = new ExchangeRateDetails { Rate = line.Rate },
}).ToList();
var ratesWriter = registers.GetWriter<IDocumentInfoRegisterWriter<ExchangeRateRecord>>();
await ratesWriter.PostAsync(entity.Id, entity.Date, records, cancellationToken);
}
public ValueTask OnUnsubmitAsync(RateImport entity, ISubmitScope registers,
CancellationToken cancellationToken) =>
new(registers.GetWriter<IDocumentInfoRegisterWriter<ExchangeRateRecord>>()
.UnpostAsync(entity.Id, cancellationToken));
public ValueTask OnDeleteAsync(Guid id, ISubmitScope registers, CancellationToken cancellationToken) =>
new(registers.GetWriter<IDocumentInfoRegisterWriter<ExchangeRateRecord>>()
.DeleteAsync(id, cancellationToken));
}
Notice how little there is compared to the Balance example: no IsExpense, no resources, no
delta. PostAsync stamps Id, DocumentId, Period = entity.Date and Active = true on each
record and replaces the document's whole record set — an edited-and-resubmitted rate import
with one line fewer really does drop that rate. UnpostAsync sets Active = false so readers
stop seeing the rates while the rows stay in the log; DeleteAsync unposts and removes them.
The [KandraDocumentBehavior]/[RegisterTransactions] attribute pair and the three hooks are
covered in Creating a document.
6. Read it back
IInfoRegisterReader<TRecord> is the reader for both Info families:
// Injected via a repository/report constructor like any service:
// IInfoRegisterReader<ExchangeRateRecord> currencyRatesReader
var latest = await currencyRatesReader.SliceOfLast(
asOfDate,
q => q.Where(r => r.Dimensions.ToCurrencyId == homeCurrencyId),
cancellationToken);
SliceOfLast(at, adjust, ct)— for each dimension combination, the most recent active record withPeriod <= at. This is the "what's the rate today?" query.Records(from, to, adjust, ct)— every active record in a period, i.e. the history. A second overload takes aproject:transform instead ofadjust:to shape the result inside the query (navigation such asr.Dimensions.FromCurrency.Codeis pulled into the SQL, noInclude()).
Both return AsNoTracking() rows. To fetch records for a specific set of dimension keys, use
DimensionPredicateBuilder.BuildOr rather than filtering client-side (see
Creating a register).
7. Migration and verify
Migration is the normal per-provider flow — Database migrations. Then:
dotnet build, check the generated AddDocumentInfoRegister<...>() call and the generated
record configuration, and run the tests
(ExchangeRateDimensionsPredicateTests and PriceRecordTransactionsReaderSqliteTests are the
closest existing shapes to copy). Live: submit a RateImport, open its Register
Transactions tab, check the rates appear, unsubmit and confirm they stop being returned by
the reader.
Gotchas specific to this family
SliceOfLastgroups in memory. Grouping by an owned-type navigation (Dimensions) doesn't translate to SQL on any provider, so the reader loads every active record withPeriod <= atthat survives youradjustfilter and picks the latest per dimension combination client-side. That's fine for small reference-style logs like rates and prices, but always pass anadjustthat narrows the query (by dimension) rather than slicing the whole register.- One record per
(DocumentId, LineId). The record table has a unique index on those two columns, so a document can't post two records with the sameLineId. Give each record its own source line'sId; if one line must fan out into several records, you'll need a differentLineIdfor each. (Balance/Turnover movements have no such restriction — a transfer's two legs share aLineId.) - No uniqueness on
(Dimensions, Period). Two records with the same key and the samePeriodcan both exist (from different documents), andSliceOfLastthen returns just one of them — not deterministically the "latest posted". If you need a hard "one rate per currency pair per day" rule, enforce it in the document's validator or Behavior. - Don't confuse this with the independent/periodic family. Records here belong to a
document and disappear from readers when it is unposted. If the data must survive independent
of any document — set from an admin screen or an import job — that's
PeriodicInfoRegister<>.
See also
- Creating a register — shared concepts and cross-family Gotchas.
- Balance register: Inventory.
- Creating a document.