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:

ProjectReferencesHolds
Blouppy.<Domain>.ModelsSharedKernelEntities, value objects, domain events. A leaf.
Blouppy.<Domain>.Abstractions.ModelsThe contract the Web calls: IXxxService and its input records.
Blouppy.<Domain>.PublicDataSharedKernelThe contract other domains call, with its own DTOs.
Blouppy.<Domain>the above, the driven ports it uses, peers' .PublicDataServices, validators, event handlers — all internal sealed, except AddXxxDomain().

SharedKernel

Workspaces.Models

Workspaces.Abstractions

Workspaces.PublicData

Workspaces

Organizations.PublicData

Infrastructure.Database.Abstractions

Web

SharedKernel

Workspaces.Models

Workspaces.Abstractions

Workspaces.PublicData

Workspaces

Organizations.PublicData

Infrastructure.Database.Abstractions

Web

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 needsIt usesShape
A yes/no, an id, or a few properties, before it actsThe peer's IXxxPublicDataServiceSynchronous; returns a fact or an immutable XxxPublicDataDto
Something to happen after it actsAn integration eventAsynchronous; 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:

ModuleOwns
BillingPlans, subscriptions, seats and entitlements — with its own DbContext, schema and migrations history
PrivacyThe GDPR data export and account erasure, across every domain
AuditThe organization's audit log, written from integration events
WebhooksOutgoing webhooks: subscriptions, signed deliveries, retries
AiThe 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' .PublicData holds 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.