Skip to content

Use Reference Data — Lookup Tables Guide

Granit.ReferenceData provides a generic framework for managing lookup tables (countries, currencies, document types) with built-in i18n support for 14 languages, ISO 27001 audit trail, automatic inactive-entry filtering, and in-memory caching.

Two registration modes are available: strongly-typed (subclass ReferenceDataEntity) for domain-specific CLR properties, and dynamic (no subclass) for simple types extended via Metadata and MapProperty<T>().

  • A .NET 10 project referencing Granit
  • An EF Core DbContext for persistence
  • Familiarity with EF Core entity configurations
Terminal window
dotnet add package Granit.ReferenceData
dotnet add package Granit.ReferenceData.EntityFrameworkCore
dotnet add package Granit.ReferenceData.Endpoints

Every reference data type inherits from ReferenceDataEntity, which provides Code, 14 label properties, Label (culture-resolved), Activated, SortOrder, ValidFrom, ValidTo, MetadataJson, and ParentCode.

Add domain-specific properties to the subclass:

namespace MyApp.Domain;
public sealed class Country : ReferenceDataEntity
{
public string Alpha3Code { get; set; } = string.Empty;
public string CallingCode { get; set; } = string.Empty;
}

Create a configuration class inheriting from ReferenceDataEntityTypeConfiguration<T>. The base class configures the primary key, unique index on Code, label columns, audit columns, and the Activated filter.

namespace MyApp.Persistence;
public sealed class CountryConfiguration
: ReferenceDataEntityTypeConfiguration<Country>
{
public CountryConfiguration() : base("ref_countries") { }
protected override void ConfigureEntity(EntityTypeBuilder<Country> builder)
{
builder.Property(e => e.Alpha3Code)
.HasMaxLength(3)
.IsRequired();
builder.Property(e => e.CallingCode)
.HasMaxLength(10);
}
}

Apply the configuration in your DbContext:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ConfigureReferenceData(new CountryConfiguration());
modelBuilder.ApplyGranitConventions(currentTenant, dataFilter);
}
services.AddReferenceDataStore<Country, AppDbContext>();
services.AddReferenceDataStore<Currency, AppDbContext>();

This registers IReferenceDataStoreReader<Country>, IReferenceDataStoreWriter<Country>, and a IDataSeedContributor bridge for seeding.

Implement IReferenceDataSeeder<T> for idempotent data seeding. Seeders are ordered by the Order property and executed via the IDataSeedContributor pipeline:

public sealed class CountrySeeder : IReferenceDataSeeder<Country>
{
public int Order => 1;
public async Task SeedAsync(
IReferenceDataStoreReader<Country> storeReader,
IReferenceDataStoreWriter<Country> storeWriter,
CancellationToken cancellationToken)
{
if (await storeReader.GetByCodeAsync("BE", cancellationToken) is null)
{
await storeWriter.CreateAsync(new Country
{
Code = "BE",
LabelEn = "Belgium",
LabelFr = "Belgique",
LabelNl = "België",
LabelDe = "Belgien",
Alpha3Code = "BEL",
CallingCode = "+32",
SortOrder = 1
}, cancellationToken);
}
}
}

Register the seeder:

services.AddTransient<IReferenceDataSeeder<Country>, CountrySeeder>();
// Routes: /reference-data/countries, /reference-data/currencies
app.MapGranitReferenceData<Country>();
app.MapGranitReferenceData<Currency>(opts =>
{
opts.RoutePrefix = "api/ref";
opts.TagName = "Currencies";
});
// Override the auto-pluralized segment for irregular nouns
app.MapGranitReferenceData<Person>(opts =>
opts.EntitySegment = "people");

Entity type names are auto-pluralized and kebab-cased: Countrycountries, ProductCategoryproduct-categories.

Read (requires ReferenceData.Entries.Read):

MethodRouteDescription
GET/{prefix}/{entities}QueryEngine-powered paginated list
GET/{prefix}/{entities}/metaQuery metadata (columns, filters, sorts)
GET/{prefix}/{entities}/{code}Single entry by code
GET/{prefix}/{entities}/{code}/childrenDirect children (hierarchical types)

The list endpoint uses the QueryEngine. Query parameters follow standard syntax: filter[code.contains]=BE, sort=LabelEn, search=belg, page=1, pageSize=20.

Admin (requires ReferenceData.Entries.Create / ReferenceData.Entries.Manage):

MethodRoutePermissionDescription
POST/{prefix}/{entities}Entries.CreateCreate an entry
PUT/{prefix}/{entities}/{code}Entries.ManageUpdate an entry
DELETE/{prefix}/{entities}/{code}Entries.ManageDeactivate (soft delete)

Create and update requests accept optional Metadata (max 50 entries) and ParentCode. The Code field must match ^[A-Za-z0-9_.\-]+$.

Every reference data entity supports a JSON property bag for custom metadata:

// Write
country.SetMetadataValue("Continent", "Europe");
country.SetMetadataValue("Population", "11500000");
// Read (string)
string? continent = country.GetMetadataValue("Continent");
// Read (typed — uses IParsable<T>)
int? population = country.GetMetadataValue<int>("Population");
// Check existence
bool hasPop = country.HasMetadataValue("Population");
// Remove
country.SetMetadataValue("Continent", null);
public sealed class CountryService(
IReferenceDataStoreReader<Country> storeReader)
{
public async Task<PagedResult<Country>> SearchAsync(
string? searchTerm,
CancellationToken cancellationToken) =>
await storeReader.GetAllAsync(
new ReferenceDataQuery(
ActiveOnly: true,
SearchTerm: searchTerm,
SortBy: "Code",
Page: 1,
PageSize: 25),
cancellationToken).ConfigureAwait(false);
public async Task<IReadOnlyList<Country>> GetChildrenAsync(
string parentCode,
CancellationToken cancellationToken) =>
await storeReader.GetChildrenAsync(parentCode, cancellationToken)
.ConfigureAwait(false);
}