Domains and modules
A domain is four projects
The Starter has four domains — Workspaces, Organizations, Users and Notifications — and each is split into up to four projects:
| Project | References | Holds |
|---|---|---|
Blouppy.<Domain>.Models | SharedKernel | Entities, value objects, domain events. A leaf. |
Blouppy.<Domain>.Abstractions | .Models | The contract the Web calls: IXxxService and its input records. |
Blouppy.<Domain>.PublicData | SharedKernel | The contract other domains call, with its own DTOs. |
Blouppy.<Domain> | the above, the driven ports it uses, peers' .PublicData | Services, validators, event handlers — all internal sealed, except AddXxxDomain(). |
Workspaces — with its teams — is the example domain. It is complete on purpose, so that your first domain can copy it: see the tutorial.
Two domains never touch
A domain that needs something from another has two doors, and only two:
| The caller needs | It uses | Shape |
|---|---|---|
| A yes/no, an id, or a few properties, before it acts | The peer's IXxxPublicDataService | Synchronous; returns a fact or an immutable XxxPublicDataDto |
| Something to happen after it acts | An integration event | Asynchronous; no answer |
Workspaces, for instance, must refuse a new workspace beyond the plan's limit. The limit belongs to Organizations, so the validator asks Organizations' public contract:
// Blouppy.Workspaces/WorkspaceCreationValidator.cs
private async Task<bool> HaveRoomForAnotherWorkspaceAsync(OrganizationId organizationId, CancellationToken cancellationToken)
{
// A fact from Organizations, through its public contract…
var entitlements = await _organizationsPublicDataService.GetEntitlementsAsync(organizationId, cancellationToken);
if (entitlements.MaxWorkspaces is not { } maxWorkspaces)
{
return true;
}
// …and a count from Workspaces' own repository.
var workspaces = await _workspacesRepository.CountByOrganizationAsync(organizationId, cancellationToken);
return workspaces < maxWorkspaces;
}
Why a separate .PublicData
.Abstractions carries write methods — IOrganizationMembersService can remove a
member. Handing it to a peer would let Workspaces remove someone from an organization.
.PublicData exposes only what peers legitimately need, and declares its own DTOs
instead of handing out entities.
That last point is not cosmetic. Repositories return entities tracked by a
DbContext that every domain shares within a request, and the unit of work saves the
whole context. An entity handed to a peer could be changed there, and the change would
be saved by the next commit — whoever makes it. A copy cannot be.
Every domain registers itself
Each domain exposes one entry point, called by the composition root:
services.AddNotificationsDomain();
services.AddOrganizationsDomain();
services.AddUsersDomain();
services.AddWorkspacesDomain();
This matters more than it looks: FluentValidation's AddValidatorsFromAssembly, the
domain-event registration and the integration-event scan all bind to the calling
assembly. A domain that forgets its own call still compiles — and silently stops
validating, or handling its events. A registration test per domain resolves its
services from a real container to catch it.
Modules
A module is shaped and registered like a domain, but owns a feature rather than a slice of the core model. The Starter ships five:
| Module | Owns |
|---|---|
| Billing | Plans, subscriptions, seats and entitlements — with its own DbContext, schema and migrations history |
| Privacy | The GDPR data export and account erasure, across every domain |
| Audit | The organization's audit log, written from integration events |
| Webhooks | Outgoing webhooks: subscriptions, signed deliveries, retries |
| Ai | The AI gateway: the switch, the allowances, the usage ledger |
A feature you add that is not part of the core model — search, an integration with an outside tool — takes the same shape.
The deliberate exceptions
- Notifications has no
.PublicData: no other domain needs anything from it. - Workspaces'
.PublicDataholds one fact — the organization a workspace belongs to — because the AI module needs to know who pays for a workspace's description. - Users owns only the time zone. Name, e-mail and avatar belong to the identity
host and arrive as a read-only projection,
users_view; language and theme are per device and stay in the browser.