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], theDbContext, 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 in | Example |
|---|---|---|
| Pure data or a value — no behavior, no DI | the shared kernel (Domain) | a Money value type, the Client entity, an enum |
| How the database is shaped or accessed | the persistence layer (Persistence) | [EntityConfiguration], the DbContext, migrations |
| An intent-only mechanism — you care only what, the impl is swappable, no domain data you query | a port (in the application) + adapter (Infrastructure), wired by the host | IEmailSender, IBlobStore |
| Behavior — logic, a DI participant, perhaps endpoints | a module | a feature; the audit capability |
| …used by one area and switchable | a feature module | Invoicing |
| …a foundation every module assumes is present, always on | the Core module | recording 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 + connectionThe 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 registration —
UseNpgsql(...) 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:
- It references only the shared-kernel entity (
Client), never another module's internals. ELMOD002inspects only the dependency surface — constructor parameters, fields, and properties — never a method body likeConfigure.
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(aGuid) leaves the model untangled, so removing theClientsfeature does not ripple intoInvoicing. 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:
| Stage | Layout |
|---|---|
One bounded context, one DbContext | Shared-kernel namespace + a shared Persistence layer in the application project |
Partition into separate DbContexts without splitting assemblies | Scopes — [GenerateDbSets("ctx")] / [EntityConfiguration("ctx")], one Persistence folder per scope |
| An aggregate becomes its own bounded context | A 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 theELMOD002analyzer in full. - Entity Framework Core —
[EntityConfiguration],[GenerateDbSets], and configuration discovery. - The runnable
samples/Billingapp follows this layout — entities inBilling.Application.Domain, the persistence layer (configuration,BillingDbContext, and migrations) inBilling.Application.Persistence, and the SMTP adapter inBilling.Infrastructure.