Elarion

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.

src/Billing.Application/Domain/Client.cs
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; }
}
src/Billing.Application/Domain/Invoice.cs
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.

src/Billing.Application/Persistence/ClientConfiguration.cs
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);
    }
}
src/Billing.Application/Persistence/InvoiceConfiguration.cs
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.

src/Billing.Application/Persistence/BillingDbContext.cs
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.

src/Billing.Application/Modules/Core/CoreModule.cs
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.

src/Billing.Application/Modules/Clients/ClientsModule.cs
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;
}
src/Billing.Application/Modules/Invoicing/InvoicingModule.cs
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):

src/Billing.Api/Program.cs
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.Api

You 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 a BillingDbContext that handlers will query directly.
  • A BillingDbContext whose DbSets and configuration calls are generated, not reflected.
  • Three modules — one core, two feature — each owning its own surface.

On this page