Elarion

Solution structure

Where entities, modules, and schema configuration belong relative to the module-boundary rule — keep entities in a shared-kernel namespace, treat configuration as part of the shared data layer (not feature-owned), and graduate to bounded contexts only when they earn their keep.

Project structure covers the mechanical layout the generators rely on. This page is the decision behind that layout: given that Elarion's module-boundary analyzer (ELMOD002) treats everything inside an [AppModule] as module-internal, where do shared things like entities go?

The rule that drives everything

The boundary analyzer treats every type declared inside a module as module-internal — reachable from another module only through a [ModuleContract] interface. Declaring DependsOn does not grant access to another module's internals; it only orders initialization.

Concretely, the rule is location-based: any type declared inside module A — an entity, DTO, [Service], handler, or [EntityConfiguration] — cannot be referenced from module B except through a [ModuleContract]. Types declared under no [AppModule] (the shared kernel and platform ports) are shareable, so every module may depend on them freely. Entities live in that shared kernel — that is what makes them shareable; an entity placed inside a module would become module-owned and flagged like any other internal type.

That distinction — code is module-internal, data is shared — decides where entities go.

Said as a model: a feature is a plugin over the application's shared data layer. A feature composes the data layer (the entities + their configuration) and the host's platform capabilities into behavior; it may touch any entity, but it never depends on another feature except through a [ModuleContract]. The dependency arrow points feature → data layer, never the reverse and never feature → feature. Touching many entities is using the data layer; depending on another feature is the thing to avoid. Why Elarion develops this model — including what "platform" means (the host) versus the data layer — and what it implies for where the database and configuration belong.

Active modules vs. passive building blocks

Everything in an Elarion application is one of two kinds, and this — not "core-ness" — is the axis that decides where it lives:

  • A module is active. It is a self-registering unit of behavior: the generators discover its handlers, services, validation metadata, scheduled jobs, and event consumers and wire them as a gated unit, and it can expose transports (JSON-RPC / HTTP / MCP). It contributes DI registrations.
  • A shared building block is passive. It is referenced or composed by modules and the host; it never self-registers behavior. Entities, value objects, [EntityConfiguration], the DbContext, and intent-only ports are all passive.

The test is one question: does it contribute behavior the framework should discover and wire as a gated unit? Yes → a module. No (it is referenced, applied to something, or wired by the host) → a shared building block.

They are not two flavors of "core," and they cannot be merged: entities must live outside modules precisely because modules are boundaries. ELMOD002 makes everything in a module module-internal, and shared data must not be boundaried (modules are feature separation, not data separation) — so it lives outside every module. The shared kernel exists because modules are boundaries; they are opposites.

Where does it go?

If it is……it lives inExample
Pure data or a value — no behavior, no DIthe shared kernel (Domain)a Money value type, the Client entity, an enum
How the database is shaped or accessedthe persistence layer (Persistence)[EntityConfiguration], the DbContext, migrations
An intent-only mechanism — you care only what, the impl is swappable, no domain data you querya port (in the application) + adapter (Infrastructure), wired by the hostIEmailSender, IBlobStore
Behavior — logic, a DI participant, perhaps endpointsa modulea feature; the audit capability
…used by one area and switchablea feature moduleInvoicing
…a foundation every module assumes is present, always onthe Core modulerecording an audit event

A single capability often spans these — and that is the model working, not a smell. The audit trail is the worked example: the AuditEntry record is shared-kernel data, its [EntityConfiguration] is in Persistence, and the recording capability (a [ModuleContract] plus its implementation) is in the Core module.

Port or module? (the email-vs-audit line)

Both a platform-capability port and a module capability are shared and injected, so they are easy to confuse. The split is the same intent-vs-specifics line that decides the data layer:

  • A port wraps a domain-blind mechanism you only invoke ("send this email") and could swap (SMTP ↔ SES) without changing your application's meaning. It is platform — provided by the host, never aware of your domain.
  • A module capability is domain-aware behavior — it has business meaning, owns or queries domain data, and may expose endpoints. Recording an audit event and reading its history is domain behavior, so audit is a module capability (published cross-module as a [ModuleContract]), not a port.

Entities are shared kernel, not module-internal

Do not put entities inside a module. Domain entities cross-reference each other through foreign-key navigation properties, and handlers routinely query across aggregates — so a single module cannot own them, and a [ModuleContract] interface cannot model an EF relationship. Modules are feature separation, not data separation: every module reaches the whole database through the shared concrete DbContext by design (real data isolation would be a separate DbContext). The boundary analyzer is purely location-based — it flags any type placed inside a module, so keeping entities in the shared kernel (under no [AppModule]) is what keeps a cross-aggregate reference unflagged. (Reference other aggregates by id rather than by navigation property; see the delete-the-module test for why.) Keeping entities in one shared-kernel namespace gives shared data an obvious home; placing an entity inside a module instead deliberately makes it module-owned — the first step toward a bounded context.

Keep entities and their enums in a shared-kernel namespace under no [AppModule] — for example MyApp.Application.Domain — that every module may depend on and that depends on no module.

src/
  MyApp.Application/
    Domain/                  ← shared kernel: entities + enums (NOT in any [AppModule])
      Client.cs              (plain entity — no marker)
      Invoice.cs             (plain entity; references Client by id, not a navigation)
      InvoiceStatus.cs       (enum)
    Persistence/             ← the database is application logic (under no [AppModule])
      ClientConfiguration.cs   [EntityConfiguration] : IEntityTypeConfiguration<Client>
      InvoiceConfiguration.cs  [EntityConfiguration] : IEntityTypeConfiguration<Invoice>
      AppDbContext.cs          the concrete DbContext (+ design-time factory)
      Migrations/ …            EF Core migrations
    Modules/
      Clients/               ← vertical slice: handlers, services — behavior only
        ClientsModule.cs       [AppModule("Clients")]
        Handlers/ …
      Invoicing/
        InvoicingModule.cs     [AppModule("Invoicing")]
        Handlers/ …
  MyApp.Infrastructure/      ← intent-only mechanism adapters: IEmailSender (SMTP), external clients
  MyApp.Api/                 ← host: [GenerateModuleBootstrapper], middleware, transport, UseNpgsql + connection

The persistence layer is application logic

The application depends on the database's specifics — constraints are invariants, indexes serve specific queries, raw SQL and provider functions encode rules — so the database is application logic, not an infrastructure detail (Why Elarion). The whole persistence concern therefore lives in one Persistence layer in the application: the [EntityConfiguration] classes, the concrete DbContext, and the migrations. The only genuinely host concern is provider registrationUseNpgsql(...) and the connection string.

[EntityConfiguration] is the single source of truth for an entity's participation: it drives both the generated DbSet<T> and the configuration application. The generator emits direct ApplyConfiguration<T> calls into a generated ConfigureEntities(ModelBuilder) on the context — no ApplyConfigurationsFromAssembly reflection. Because the configurations and the DbContext share an assembly, that emission is in-place and needs no cross-assembly manifest. Keep the layer flat while there is one DbContext; use one folder per DbContext once you scope.

A configuration never crosses the module boundary, for two reasons:

  1. It references only the shared-kernel entity (Client), never another module's internals.
  2. ELMOD002 inspects only the dependency surface — constructor parameters, fields, and properties — never a method body like Configure.

Infrastructure is not where the database lives. It holds only intent-only mechanism adapters — the SMTP IEmailSender, external API clients — the things you depend on by intent ("send an email") and could swap without changing your application's meaning. The database, which you depend on by specifics, is application logic and stays in Persistence.

A separate Domain project is usually unnecessary

For one bounded context / one DbContext, a shared-kernel namespace inside the application project is enough. A separate assembly only earns its keep when multiple host projects share the code. Until then, MyApp.Application.Domain (a namespace, not an assembly) avoids a project boundary you would only have to wire and reference.

Whatever assembly declares [EntityConfiguration]/[GenerateDbSets] types must reference Elarion.EntityFrameworkCore (whose bundled generator emits that assembly's configuration manifest), not merely depend on a project that does. NuGet analyzer assets are not transitive. If you do split entities and their configurations into their own project, that project needs the reference directly — otherwise no manifest is emitted, the context resolves zero configurations, and no DbSets are generated, with no error. See Entity Framework Core → Setup.

The delete-the-module test

A module is meant to be a plugin: delete its folder and every other module still compiles, untouched. That holds because cross-module code goes through [ModuleContract]s (ELMOD002 keeps it honest) and [EntityConfiguration] is discovered structurally, never referenced by name — so removing a feature removes its behavior cleanly. Two disciplines keep the test green:

  • Reference other aggregates by ID, not by navigation property. Invoice.ClientId (a Guid) leaves the model untangled, so removing the Clients feature does not ripple into Invoicing. A cross-module navigation creates an EF relationship that couples the two.
  • Treat new data as shared. A feature that adds an entity, index, or column extends the shared data layer; that data lives in the shared persistence layer and does not vanish when the feature folder is deleted — its removal is a deliberate migration step, because data is durable and shared. (How a plugin should own new typed data while staying cleanly removable is an open design question.)

Graduating to a bounded context

The shared-kernel default is right until an aggregate cluster becomes a genuinely separate bounded context with its own DbContext and schema — real data separation, not just feature separation. At that point its entities become context-private (correctly, since nothing outside the context references them). The graduation is incremental, not a rewrite:

StageLayout
One bounded context, one DbContextShared-kernel namespace + a shared Persistence layer in the application project
Partition into separate DbContexts without splitting assembliesScopes[GenerateDbSets("ctx")] / [EntityConfiguration("ctx")], one Persistence folder per scope
An aggregate becomes its own bounded contextA self-contained Context.Application / .Infrastructure / .Contract assembly group owning its entities, configs, and migrations

The by-ID reference discipline above is what keeps each promotion cheap. A bounded context collaborates with the rest of the system only through a [ModuleContract] and integration events (the outbox) — exactly as modules already do — so promoting one is mostly moving files and splitting the schema, not a rewrite. The boundary it adds is enforced by the compiler (separate assemblies) rather than the analyzer, which is the heavier guarantee real data separation deserves.

See also

  • Why Elarion — the principles behind this layout: features as plugins over the shared data layer, and the database as application logic.
  • Project structure — the mechanical layout and the conventions the generators rely on.
  • Cross-module communication[ModuleContract] and the ELMOD002 analyzer in full.
  • Entity Framework Core[EntityConfiguration], [GenerateDbSets], and configuration discovery.
  • The runnable samples/Billing app follows this layout — entities in Billing.Application.Domain, the persistence layer (configuration, BillingDbContext, and migrations) in Billing.Application.Persistence, and the SMTP adapter in Billing.Infrastructure.

On this page