Database
One SQL database, twelve contexts. The separation between domains is logical and enforced at compile time, not by network distance.
What is in it
Each domain, and each module that owns data, has its own Entity Framework context. All twelve resolve the same connection string against the same database.
| Nine domain contexts | Authentication · Catalogue · Cms · Customer · Fulfilment · Inventory · Pricing · Tax · Trading |
| Three module contexts | Notifications · Payment · SiteConfiguration |
A domain has no reference to another domain's context, so it has no DbSet for another domain's
tables and physically cannot query them. The illegal join is not discouraged — it is unwritable.
Alongside the domain tables sits a queue table, holding work that must happen because of a request but must not happen during it.
Why not a database per domain
Splitting buys independent scaling and independent failure. Nothing here needs the first, and the second would cost something load-bearing.
Work that must not be lost — posting an order to the ERP, provisioning a user, sending a confirmation — is written to the queue table in the same transaction as the business change. Either both commit or neither does. There is no window in which the order exists and the instruction to post it does not.
Split the database and that guarantee needs a distributed transaction or a reconciliation process. That is the trade, and it is the reason the answer is not "we haven't got round to it yet".
Note
This is also why there is no message broker, despite one being provisioned in every environment. A broker cannot enlist in the transaction that saves the order, so closing the gap means building an outbox regardless — at which point the broker is an extra hop and an extra failure mode. The first consumer that is a separate deployable is the trigger to revisit; the publish side would not change.
Conventions, applied once
Five behaviours are enforced by a shared context base rather than written per entity. An entity opts in by implementing a marker interface.
| Convention | Effect |
|---|---|
| Auditable | Created and modified timestamps, stamped on save |
| Customer-scoped | Queries filtered to the request's customer; stamped on insert; a mismatch throws |
| User-scoped | The same, for the signed-in user |
| Soft-deletable | Deleted rows filtered out of every query |
| Created-by | Attribution stamped on insert — deliberately not filtered |
Created-by is not enforced the way the scoped conventions are, because legitimate cases exist for a record attributed to someone other than the caller — an administrator acting on a user's behalf.
Audit timestamps belong to Commerce, never to the sender
They answer "when did Commerce learn this", which is a question only Commerce can answer. Letting an upstream system supply them makes the answer depend on that system's clock and its retry behaviour.
So they are not part of any inbound contract: inbound payloads do not declare them, and a value sent is ignored.
Warning
There is a trap here that has already been paid for. Upsert handlers commonly copy every mapped property from an inbound object onto a tracked entity. That includes the created timestamp, as an unset default — so a naive upsert resets every row's creation date. The context base guards it by restoring the original value and excluding the column from the update. Do not set audit fields by hand, and be careful writing a new upsert path that bypasses the base.
Scope has two modes, and the route decides
| Mode | Applies to | Behaviour |
|---|---|---|
| Scoped | /api/ui, when the user has an active session |
Queries are filtered to that customer; inserts are stamped with it |
| Bypass | Everything else — admin, integration, sign-in, background handlers, the migrator | No filter; the caller supplies scope explicitly on the entity |
Why two modes rather than always filtering. An integration endpoint upserting stock for every customer, or a background handler posting one customer's order, has no ambient customer to filter by. Forcing them through a filter would mean inventing a fake scope per row.
Why a mismatch throws instead of overwriting. A scoped request handing over an entity carrying a different customer's identifier is a bug with a data-leak shape. Failing at save time is cheaper than finding it in someone's order history.
Important
Do not read the scope context inside a domain service. Services trust the base to apply it. A service that reads scope directly behaves differently depending on which route reached it — which is the class of bug this design removes.
Migrations
A dedicated migrator console reuses the application's own startup, so it discovers every registered context without being told about any of them. Adding a domain requires no migrator change.
| Verb | Does | Needs the source tree |
|---|---|---|
list |
Every discovered context | No |
migrate |
Applies pending migrations | No — works as a standalone deploy artifact |
add |
Scaffolds a migration | Yes |
script |
Emits SQL | Yes |
Warning
Entity Framework must stay on 8.x. Trader Core targets .NET 8, and EF 10 against the EF 8 SQL
Server provider throws at type-load. The design package is pinned in both the API and the migrator,
and the global dotnet-ef tool needs to match. This is not a version someone forgot to bump —
raising it means re-validating the whole Trader Core integration.
What is not in the database
The search index. Product search runs against Azure AI Search, populated by an indexer reading a SQL view on a change-detection column. The index is derived state, rebuildable from the database, and never the source of truth. How it is built and configured is on Search.
That indirection has a trap worth knowing: a column can exist on the table and as a field in the index, and still arrive empty on every document, because the view does not project it. Nothing errors — the facet simply renders nothing.
Content assets. Editable content lives in the database; the files it references sit on local application storage rather than in object storage.