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>().
Prerequisites
Section titled “Prerequisites”- A .NET 10 project referencing
Granit - An EF Core
DbContextfor persistence - Familiarity with EF Core entity configurations
Step 1 — Install packages
Section titled “Step 1 — Install packages”dotnet add package Granit.ReferenceDatadotnet add package Granit.ReferenceData.EntityFrameworkCoredotnet add package Granit.ReferenceData.EndpointsStep 2 — Define your reference data
Section titled “Step 2 — Define your reference data”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;}For simple types, skip the subclass entirely. Custom properties are declared via the fluent API and stored as EF Core Shadow Properties (real SQL columns) or in the JSON bag.
No entity class to create — proceed directly to Step 3.
Step 3 — Configure EF Core persistence
Section titled “Step 3 — Configure EF Core persistence”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);}No EF configuration class needed. The table name and column mappings are declared in the fluent API (next step). EF Core Shadow Properties are created automatically.
Step 4 — Register stores and services
Section titled “Step 4 — Register stores and services”services.AddReferenceDataStore<Country, AppDbContext>();services.AddReferenceDataStore<Currency, AppDbContext>();This registers IReferenceDataStoreReader<Country>, IReferenceDataStoreWriter<Country>,
and a IDataSeedContributor bridge for seeding.
services.AddReferenceData<AppDbContext>(rd =>{ rd.Add("Countries", opts => opts .Table("ref_countries") .MapProperty<string>("Alpha3Code", maxLength: 3, isFilterable: true) .MapProperty<string>("Region", maxLength: 100, isFilterable: true) .MapProperty<bool>("IsEuMember", isFilterable: true));
rd.Add("DocumentTypes", opts => opts.Table("ref_document_types"));
rd.Add("Categories", opts => opts .Table("ref_categories") .Hierarchical());});This registers keyed IReferenceDataStoreReader<DynamicReferenceDataEntity> and
IReferenceDataStoreWriter<DynamicReferenceDataEntity> per type name, an
MetadataSyncInterceptor for Shadow Property synchronization, and a
QueryDefinition for advanced querying.
Step 5 — Seed initial data
Section titled “Step 5 — Seed initial data”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>();Step 6 — Map endpoints
Section titled “Step 6 — Map endpoints”// Routes: /reference-data/countries, /reference-data/currenciesapp.MapGranitReferenceData<Country>();app.MapGranitReferenceData<Currency>(opts =>{ opts.RoutePrefix = "api/ref"; opts.TagName = "Currencies";});
// Override the auto-pluralized segment for irregular nounsapp.MapGranitReferenceData<Person>(opts => opts.EntitySegment = "people");// Map all registered dynamic types in one callapp.MapGranitAllReferenceData();
// Or map selectivelyapp.MapGranitReferenceData("Countries");app.MapGranitReferenceData("Categories", opts => opts.TagName = "Categories");Available endpoints
Section titled “Available endpoints”Entity type names are auto-pluralized and kebab-cased: Country → countries,
ProductCategory → product-categories.
Read (requires ReferenceData.Entries.Read):
| Method | Route | Description |
|---|---|---|
GET | /{prefix}/{entities} | QueryEngine-powered paginated list |
GET | /{prefix}/{entities}/meta | Query metadata (columns, filters, sorts) |
GET | /{prefix}/{entities}/{code} | Single entry by code |
GET | /{prefix}/{entities}/{code}/children | Direct 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):
| Method | Route | Permission | Description |
|---|---|---|---|
POST | /{prefix}/{entities} | Entries.Create | Create an entry |
PUT | /{prefix}/{entities}/{code} | Entries.Manage | Update an entry |
DELETE | /{prefix}/{entities}/{code} | Entries.Manage | Deactivate (soft delete) |
Create and update requests accept optional Metadata (max 50 entries) and ParentCode.
The Code field must match ^[A-Za-z0-9_.\-]+$.
Step 7 — Use Metadata
Section titled “Step 7 — Use Metadata”Every reference data entity supports a JSON property bag for custom metadata:
// Writecountry.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 existencebool hasPop = country.HasMetadataValue("Population");
// Removecountry.SetMetadataValue("Continent", null);Step 8 — Query in code
Section titled “Step 8 — Query in code”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);}Next steps
Section titled “Next steps”- Reference Data module reference — full API surface and architecture
- Manage application settings — dynamic settings with cascading resolution
- Implement the audit timeline — track changes to reference data
- Create a module — package reference data types into a reusable module