Table of Contents

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.