Model the domain
Define the billing entities, the BillingDbContext data-access context, and the Core, Clients, and Invoicing modules.
Billing has two entities — Client and Invoice — and three modules. Clients and Invoicing
are feature modules; Core is an always-on foundation module that owns shared services. In this step
you define the data model and the module boundaries; the handlers come in
Write the features.
Define the entities
Entities are plain classes — generation is driven by an [EntityConfiguration] on each entity's
IEntityTypeConfiguration<T> (which you add a couple of steps down), not by a marker on the entity
itself. Both carry an OwnerId — the id of the signed-in account that owns the row — which is what
later lets reads and caches be scoped per user.
namespace Billing.Application.Domain;
public sealed class Client {
public Guid Id { get; set; }
public required string OwnerId { get; set; }
public required string Number { get; set; }
public required string Name { get; set; }
public required string Email { get; set; }
public DateTimeOffset CreatedAt { get; set; }
}namespace Billing.Application.Domain;
public sealed class Invoice {
public Guid Id { get; set; }
public required string OwnerId { get; set; }
public Guid ClientId { get; set; }
public required string Number { get; set; }
public long AmountCents { get; set; }
public required string Currency { get; set; }
public InvoiceStatus Status { get; set; }
public DateOnly DueDate { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset? SentAt { get; set; }
}
public enum InvoiceStatus { Draft, Sent, Paid, Overdue, Cancelled }Money is stored as integer minor units (AmountCents) to avoid floating-point rounding. The handlers
expose it as cents on the wire too, so the frontend formats it once at the edge.
Plan the data access
Handlers inject the concrete BillingDbContext directly — the database is application logic, accessed
on its own terms, so there is no repository layer and no
context interface. You abstract intent-only dependencies (sending email behind an IEmailSender); you do
not abstract the database, because you depend on its specifics — constraints, indexes, raw SQL. The
context itself, its [EntityConfiguration] classes, and the migrations all live in the application's
shared Persistence layer; only provider registration (UseNpgsql plus the connection string) is the
host's job.
You define BillingDbContext a couple of steps down. It carries [GenerateDbSets], and the
EF Core generator emits a DbSet<T> for every
[EntityConfiguration] entity straight onto that concrete class — DbSet<Client> Clients and
DbSet<Invoice> Invoices after the next build.
Add entity configuration
Configuration is part of the shared data layer, not feature-owned, and the generator discovers it
wherever it lives. The [EntityConfiguration] attribute on each IEntityTypeConfiguration<T> is the single source of truth for
an entity's participation — it drives both the generated DbSet<T> and the Configure(...) application,
so a configured entity is a discovered entity (a plain IEntityTypeConfiguration<T> with no attribute is
ignored). Because configuration is part of the shared data layer, it lives in a Persistence layer — a sibling of
Modules, under no [AppModule] — not inside a feature module; see
solution structure. It references only the shared-kernel entity, so
it never crosses a module boundary (ELMOD002 only inspects constructor/field/property surface, never the
Configure body). The generator emits direct ApplyConfiguration<T> calls — no
ApplyConfigurationsFromAssembly reflection.
using Billing.Application.Domain;
using Elarion.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Billing.Application.Persistence;
[EntityConfiguration]
public sealed class ClientConfiguration : IEntityTypeConfiguration<Client> {
public void Configure(EntityTypeBuilder<Client> builder) {
builder.HasKey(c => c.Id);
builder.HasIndex(c => new { c.OwnerId, c.Number }).IsUnique();
builder.Property(c => c.Name).HasMaxLength(200);
builder.Property(c => c.Email).HasMaxLength(320);
}
}using Billing.Application.Domain;
using Elarion.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Billing.Application.Persistence;
[EntityConfiguration]
public sealed class InvoiceConfiguration : IEntityTypeConfiguration<Invoice> {
public void Configure(EntityTypeBuilder<Invoice> builder) {
builder.HasKey(i => i.Id);
builder.HasIndex(i => new { i.OwnerId, i.Number }).IsUnique();
builder.HasIndex(i => new { i.OwnerId, i.Status, i.DueDate });
builder.Property(i => i.Currency).HasMaxLength(3);
builder.Property(i => i.Status).HasConversion<string>();
}
}Implement the concrete context in the persistence layer
The concrete BillingDbContext lives in the application's persistence layer — the database is application
logic, not an infrastructure detail — beside the configurations and migrations. Annotate it with
[GenerateDbSets]: the generator emits the matching DbSet properties into this partial class and a
ConfigureEntities call that applies every discovered configuration.
using Elarion.EntityFrameworkCore;
using Elarion.Messaging.Outbox;
using Microsoft.EntityFrameworkCore;
namespace Billing.Application.Persistence;
[GenerateDbSets]
public sealed partial class BillingDbContext(DbContextOptions<BillingDbContext> options)
: DbContext(options) {
protected override void OnModelCreating(ModelBuilder modelBuilder) {
base.OnModelCreating(modelBuilder);
ConfigureEntities(modelBuilder); // generated
modelBuilder.UseElarionOutbox(); // integration-event outbox table (Elarion.Messaging.Outbox)
}
}[GenerateDbSets] goes on the concrete context, the single declaration point for the model. The
generated DbSets and ConfigureEntities are emitted onto this class, so handlers query it directly.
Define the modules
A module is a static partial class marked with [AppModule] at the root of a namespace. Everything
under that namespace belongs to the module, and the generators register its handlers, services, and
validation metadata for you — the host bootstrapper calls a generated ConfigureDefaultServices, gated by the
module's feature flag, so there are no Add{Module}…() calls to write. A module declares only its JSON
resolver and any extra, non-generated wiring in ConfigureServices. See
Conventions.
Core is a foundation module — Kind = AppModuleKind.Core keeps it always enabled and initialized
before feature modules. It owns always-on domain capabilities other modules build on — such as the
account-standing (credit) policy you add next, which Core publishes as a [ModuleContract] so Invoicing
can consult it before raising an invoice.
using Elarion.Abstractions.Modules;
namespace Billing.Application.Modules.Core;
[AppModule("Core", Kind = AppModuleKind.Core)]
public static partial class CoreModule {
}Clients and Invoicing are feature modules — enabled by default, switchable with configuration. Each publishes its handlers, services, validation metadata, and JSON metadata.
using System.Text.Json.Serialization.Metadata;
using Elarion.Abstractions.Modules;
namespace Billing.Application.Modules.Clients;
[AppModule("Clients")]
public static partial class ClientsModule {
public static IJsonTypeInfoResolver GetJsonTypeInfoResolver() => ClientsJsonContext.Default;
}using System.Text.Json.Serialization.Metadata;
using Elarion.Abstractions.Modules;
namespace Billing.Application.Modules.Invoicing;
[AppModule("Invoicing")]
public static partial class InvoicingModule {
public static IJsonTypeInfoResolver GetJsonTypeInfoResolver() => InvoicingJsonContext.Default;
}The ClientsJsonContext and InvoicingJsonContext referenced above are created in the next step, as
you add handlers — each module contributes its own source-generated JSON metadata.
Because Invoicing is a feature module, an operator can switch it off entirely with
"Modules": { "Invoicing": { "Enabled": false } } — handlers, endpoints, JSON metadata, and
scheduled jobs disappear together. Core ignores that flag by design.
Create the migration
With the model and context in place, generate the initial migration and apply it. The host is the startup project (it owns the connection string); the migration lands in infrastructure.
The host needs to register BillingDbContext before EF tooling can find it. Add this to
Program.cs for now (the Host the API step fleshes the rest out):
using Billing.Application;
using Billing.Application.Persistence;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateSlimBuilder(args);
builder.Services.AddDbContext<BillingDbContext>(o =>
o.UseNpgsql(builder.Configuration.GetConnectionString("billing")));
builder.Services.AddScoped<DbContext>(sp => sp.GetRequiredService<BillingDbContext>());
var app = builder.Build();
app.Run();Handlers inject BillingDbContext directly; DbContext is also exposed so the transaction decorator
(added next) can begin transactions without knowing the concrete type.
dotnet ef migrations add Initial \
--project src/Billing.Infrastructure \
--startup-project src/Billing.ApiYou do not run dotnet ef database update by hand: the host applies pending migrations on startup
(await db.Database.MigrateAsync() in Host the API), against the database the
Aspire app host provisions. To scaffold migrations without launching the host, add a
design-time factory
that supplies UseNpgsql(...) — the runnable sample includes one.
What you have so far
- Two entities, each with an
[EntityConfiguration], and aBillingDbContextthat handlers will query directly. - A
BillingDbContextwhoseDbSets and configuration calls are generated, not reflected. - Three modules — one core, two feature — each owning its own surface.
Tutorial
A complete, end-to-end walkthrough that builds a small billing application with every opinionated Elarion feature, plus a typed React frontend.
Write the features
Build the Clients module end to end — a decorator pipeline, current-user scoping, handlers, declarative validation, results, and per-user caching.