📐 Architecture Overview
Clean Architecture Benefits
- Separation of Concerns: Each layer has a single responsibility
- Testability: Business logic isolated from infrastructure
- Maintainability: Changes in one layer don't affect others
- Flexibility: Easy to swap databases, UI frameworks, or external services
🏢 Tenancy Model
Tenant resolution (hostname -> tenant)
Requests are mapped to a tenant using TenantResolutionMiddleware. It normalizes the incoming host,
resolves which Organisation owns that hostname, then sets the tenant database + tenant organisation id
via ITenantContextSetter.
Resolution rules (high level):
- Exact domain match: any active organisation whose
Organisation.Domainequals the full request host (including the platform organisation). Arbitrary subdomains of a tenant's custom domain (e.g.anything.customer.comwhen the org's Domain is onlycustomer.com) do not resolve — each organisation owns exactly one domain. - Platform subdomain match: if the host ends with the platform organisation's
Domain, the first label (e.g.bradinbrad.yourdomain.com) must match a tenant's organisationName. - Localhost: resolves to the platform organisation.
- Single-org mode: resolves to the platform organisation by default (and uses the single non-platform org only when it is the only active tenant).
Tenant resolution results are cached briefly (in-memory) and cleared when admin actions update domain mappings.
Multi-org mode supports a cookie override:
BB_SelectedOrg can select which organisation is used when the resolved tenant is the platform
(for example on localhost for local development, or when Features:SubdomainOrganisations is false and org switching is cookie-based).
When Features:SubdomainOrganisations is true, the cookie is suppressed on the platform's
configured public hostname so that a stale value left over from visiting a tenant subdomain does not incorrectly
override the main marketing/apex site back to that tenant. When subdomain routing is off, the cookie
remains active on the platform hostname because it is the primary mechanism for org switching.
Repository scoping (tenant DB vs platform DB)
Mongo repositories use ITenantContext to decide where to read/write:
MongoRepository<TEntity>usesCurrentTenantDatabaseName(tenant DB) and applies an organisation id filter for org-scoped entities.PlatformMongoRepository<TEntity>always usesPlatformDatabaseName(platform DB), letting platform-owned entities be stored and queried consistently.
Routing modes (single-org / subdomain / custom domain)
How a request is mapped to an organisation depends on the routing feature flags and the request hostname.
On the platform's configured apex/marketing hostname, tenant context follows the host (platform). Cookie-based
selection applies on localhost and other cases where the cookie is still used; routing to subdomains is controlled
by the selection flow in /api/org/switch.
- Single-org mode:
Features:MultiOrganisationis false → users only operate on the platform organisation context (no org switching UI). - Cookie-based routing (platform host):
Features:SubdomainOrganisationsis false →/Org/SelectsetsBB_SelectedOrg, andTenantResolutionMiddlewareapplies the tenant override on page routes. - Subdomain routing:
Features:SubdomainOrganisationsis true → selecting an organisation redirects to<organisationName>.<platformDomain>(for non-custom-domain orgs; name is the storedOrganisation.Name). - Custom domain aliases:
Features:CustomDomainOrganisationscontrols whether custom hostnames resolve to organisations viaOrganisation.Domain. Custom domains are direct access aliases only:/api/org/switchdoes not auto-redirect to a custom hostname. Users must navigate to the custom domain directly for it to take effect.
Auth cookies remain valid via the normal session-cookie flow; cookie scoping (host-only vs shared parent-domain) is handled in the auth cookie configuration.
🧭 Platform vs Organisation Responsibilities
BlazorBlueprint splits administration into two sections. /Platform/* is where a platform admin
manages the whole SaaS — the list of organisations, identity, and the platform organisation's own
settings (which double as the fallback for sub-orgs on the platform host). /Admin/* is where an
organisation admin manages a single organisation's own settings.
Sub-organisations that live on a subdomain of the platform host (e.g. acme.blazorblueprint.net)
typically inherit platform-level settings for legal, cookies, email, push, PWA and external auth — so the
platform experience stays consistent. When the same organisation is accessed via its own custom domain (e.g.
acme.com), it is free to override those settings because the site is effectively "its own".
Quick reference: behaviour by host
The short version, for the typical setup with MultiOrganisation, SubdomainOrganisations, CustomDomainOrganisations, and UsePlatformBrandingForSubdomainOrgs all enabled:
| Host | Legal / Cookies / Email / PWA | External auth (Facebook / Google / Microsoft) | Site settings (header, logo, theme, contact) | Org admin can customise? |
|---|---|---|---|---|
blazorblueprint.net(platform org) |
Platform's own records | Platform's own app credentials | Platform's own record | Via /Platform/* |
acme.blazorblueprint.net(subdomain org) |
Inherits platform records | Inherits platform app — login flow routes through platform host | Own record, seeded from platform at creation | Only site settings — other /Admin/* pages hidden |
acme.com(custom-domain org) |
Inherits platform records until admin saves their own | Must register own OAuth app — platform credentials can't be used (redirect URI is domain-bound). Buttons hidden until configured. | Own record, seeded from platform at creation | Yes — full /Admin/* surface |
Turning off UsePlatformBrandingForSubdomainOrgs collapses the subdomain row into the custom-domain row — subdomain orgs then behave exactly like custom-domain orgs (inherit until they save their own, full /Admin/* surface available). Use that mode when subdomains represent independent tenant sites rather than "part of the same product experience".
What lives where
| Area | Platform Admin (/Platform/*) |
Organisation Admin (/Admin/*) |
Fallback |
|---|---|---|---|
| Platform-only (always managed at platform level) | |||
| Organisations (list, create, edit any org) | ✅ Yes | — | — |
| Users & roles (platform-wide identity) | ✅ Yes | — | — |
| Platform audit logs | ✅ Yes | — | — |
| Infrastructure & health | ✅ Yes | — | — |
| Orphaned organisations | ✅ Yes | — | — |
| Push notification VAPID keys | ✅ Yes | — | Always platform |
| Shared settings (subdomain orgs inherit from platform; custom-domain orgs own their copy) | |||
| Legal policies | ✅ Platform copy | Custom-domain orgs only | Platform copy |
| Cookie consent | ✅ Platform copy | Custom-domain orgs only | Platform copy |
| PWA configuration | ✅ Platform copy | Custom-domain orgs only | Platform copy |
| Email provider | ✅ Platform copy (seeded from appsettings) | Custom-domain orgs only | Platform copy |
| External authentication (Facebook / Google / Microsoft) | ✅ Platform copy (seeded from appsettings) | Custom-domain orgs only — must register their own OAuth app | Subdomain orgs only (OAuth credentials are domain-bound) |
| Per-organisation only (no inheritance) | |||
| robots.txt | ✅ Platform's own copy | Only when subdomain or custom-domain routing is enabled | Seeded from platform at org creation, then independent |
| sitemap.xml | ✅ Platform's own copy | Only when subdomain or custom-domain routing is enabled | Seeded from platform at org creation, then independent |
| Site settings (title, tagline, theme, favicon, contact) | ✅ Platform's own copy | ✅ Always editable | Seeded from platform at org creation, then independent |
| Organisation identity (Name, Domains, verification) | Via Organisations list | ✅ Yes (own org) | — |
| Per-org SSO (OIDC) | — | ✅ Yes | — |
| Navigation menu | Platform org via /Admin/* |
✅ Yes | — |
| Plugin enablement | Platform org via /Admin/* |
✅ Yes | — |
| Roles (org-scoped membership roles) | — | ✅ Yes | — |
| User audit logs (per-org activity) | — | ✅ Yes | — |
"Custom-domain orgs only" means the admin page is visible and editable only when the request
is on the organisation's own verified custom domain (or UsePlatformBrandingForSubdomainOrgs is off).
On the platform host or a tenant subdomain with platform-branding active, those admin pages redirect back to
/Admin with a note explaining the setting is managed at platform level.
How the fallback is resolved
For each "shared" setting in the table above (legal, cookies, PWA, email), the system asks: does this organisation have its own row? If yes, use it. If no, fall back to the platform organisation's row. A sub-org silently inherits platform values until an admin explicitly saves their own — at which point the sub-org "diverges" and owns its copy from then on.
Whether a sub-org is allowed to diverge depends on how it's being accessed:
UsePlatformBrandingForSubdomainOrgs = false→ every org uses its own settings (or inherits if empty).UsePlatformBrandingForSubdomainOrgs = true+ request on the org's custom domain → org-owned settings apply; the full/Admin/*surface is available.UsePlatformBrandingForSubdomainOrgs = true+ request on a subdomain of the platform → platform settings apply; the corresponding/Admin/*pages are hidden.
This is the "platform branding" gate. It ensures the main marketing site and its tenant subdomains share a single consistent legal/cookie/PWA/email footprint, while custom-domain tenants get the full toolkit to run their site independently.
Two exceptions to the inheritance pattern:
- Site settings are always per-org. Every organisation — including sub-orgs on
subdomains — gets its own
SiteSettingsrow at creation, seeded from the platform's current values but with the site header set to the org's name. There is no runtime fallback to the platform. Site identity (header, logo, contact, theme) is considered intrinsically per-org. - External authentication inherits for subdomain orgs (their login flow routes through
the platform host where the platform's registered OAuth redirect URIs are valid). It does
not inherit for custom-domain orgs — OAuth credentials are domain-bound, so a platform
Facebook/Google/Microsoft app registered with
platform.com/signin-facebookwill be rejected by the provider when the callback lands onacme.com/signin-facebook. A custom-domain org that wants external-login buttons has to register its own OAuth app with each provider; otherwise the login page simply hides the buttons rather than showing broken ones.
🚦 Feature Flags
Routing, tenancy, and runtime behaviour are controlled by flags under Features: in
appsettings.json. They can be toggled per environment without code changes.
| Flag | Default | What it does |
|---|---|---|
MultiOrganisation |
true |
Enables multiple organisations with switching and membership. When off, the app runs in single-org mode on the platform organisation. |
SubdomainOrganisations |
false |
Selecting an organisation redirects to <orgName>.<platformDomain>. Requires Authentication:Cookie:Domain to be set to a shared parent domain so the auth cookie is valid across subdomains. |
CustomDomainOrganisations |
false |
Organisations can set a verified custom domain in Organisation.Domain. Requests on that host resolve directly to the org. |
UsePlatformBrandingForSubdomainOrgs |
same as SubdomainOrganisations |
When on, orgs accessed via subdomain inherit the platform organisation's legal / cookie / PWA / email / push / external-auth settings. Orgs on their own custom domain still use their own. |
ForwardToCustomDomain |
false |
When selecting an organisation from the org picker, if the org has a verified custom domain the user is redirected to it. Applies to the selection flow only, not every request. |
PlatformAdminOrgImpersonation |
false |
Lets platform admins enter organisations they are not a member of. |
RequireEmailConfirmation |
false |
Newly registered users must confirm their email address before they can sign in. |
RequirePhoneNumber |
false |
When on, the phone-number field is required on the Register form, the InitialSetup admin form, and /Account/Manage. When off (default), the field is shown but optional — the [Phone] validator still checks format if a value is supplied. The Register submit handler also enforces the flag server-side, so a client that strips the HTML5 required attribute can't bypass it. |
UseKafkaPushNotificationPipeline |
false |
Sends push notifications via Kafka to the BackgroundWorker instead of in-process. |
UseKafkaApiPipeline |
false |
Routes API requests through a Kafka pipeline. |
LogApiRequests |
false |
Persists API request logs to the tenant DB for review. |
AppLauncher |
true |
Odoo-style /apps tile launcher. When on, signed-in members land on /apps after login, org selection, and org creation. The user-dropdown collapses to a single Apps link, and PluginManager.GetPluginMainPagesAsync emits /apps/{name} (legacy /admin/plugin/{name} URLs still resolve as aliases). When off, the dropdown shows the legacy per-plugin list pointing at /admin/plugin/{name} and the Home page falls back to its marketing surface. |
Per-org private workspaces are configured outside the feature-flag table. Each organisation has a
SiteSettings.PublicAccess field (default true) toggled by the
Private workspace (members only) switch in /Admin/OrganisationSettings. When off,
PrivateWorkspaceMiddleware redirects anonymous visitors to /Account/Login,
robots.txt returns Disallow: /, and /sitemap.xml returns 404.
📁 Project Structure
🧰 Built-in Host Services
Reusable application primitives the host ships with. Plugins consume them via DI;
you can use them directly in your own services too. All are tenant-scoped and
follow the standard IRepository<T> pattern — no MongoDB driver
types in the call site.
| Service | What it does | Bundled providers |
|---|---|---|
PaymentsIPaymentService |
Initiate hosted-checkout sessions, capture refunds, dispatch webhook events to per-plugin handlers. Amounts stored in micros (1/1,000,000 of a currency unit) to avoid rounding bugs across providers' minor-unit / decimal-string conventions. | Stripe Checkout, GoCardless (UK Direct Debit), PayPal Orders v2 |
File storageIFileService / IFileStorage |
Per-org file upload + retrieval with size cap, content-type sniff, and tenant-scoped StoredFile rows. Per-row StorageProvider means switching backends doesn't orphan old uploads — they keep working from wherever they were originally written. |
GridFS (default, no extra infra), Azure Blob, S3 |
PDF renderingIPdfRenderer |
Render QuestPDF templates, or stamp values onto an existing PDF (overlay templates). Wired in all three host processes so the renderer is safe to inject anywhere without per-host conditionals. |
QuestPDF (Community licence), PdfSharp (overlay) |
SMSISmsService |
Outbound SMS via the existing IApiRequestDispatcher Kafka pipeline (no parallel infra). Platform-shared: one Twilio account, one sender number used across every tenant — customers see the platform's number, not the tenant's. STOP keywords land platform-side and record opt-outs into SmsOptOut in the platform DB (hashed phone, PECR-compliant) — a single STOP applies across every tenant since the sender is shared. SmsSidIndex maps Twilio MessageSid → sending tenant so delivery-status callbacks can find the right tenant DB. |
Twilio (extensible to other providers via ISmsProvider) |
Inbound emailMailgunInboundEmailHandler |
Platform-shared: one Mailgun account, one catchall inbox (Platform:MailgunInbound:SharedInboxAddress in appsettings). Tenants set up server-side forwarding (Gmail filter / Microsoft 365 mail-flow rule / Postfix .forward — NOT the manual Forward button, which strips headers) from their own SiteSettings.SiteEmail to the shared inbox. The handler reads the original To: header and resolves the tenant via O(1) lookup on Organisation.InboundEmailAddress (mirrored from SiteEmail on save by IInboundEmailRegistrationService, unique partial index in platform DB). |
Mailgun (Routes / Inbound parsers) |
Webhooks (inbound)IInboundWebhookHandler |
Single anonymous endpoint at /api/webhooks/{provider} — signature-verified, rate-limited (60 req/min per IP × tenant × provider), 1 MB body cap. Path variant /api/webhooks/{provider}/{organisationId} for platform-only deploys. Auth-related failures collapse to 401 so an attacker can't enumerate orgs / providers via response codes. Each provider carries an IntegrationProviderScope — tenant-scoped (Stripe / GoCardless / PayPal, credentials in PaymentProviderCredentials) or platform-scoped (Twilio / MailgunInbound, credentials in platform-DB ApiIntegration). Platform-scoped providers skip the tenant-required guard so the catchall POST doesn't 4xx back to the provider. |
Stripe, GoCardless, PayPal (tenant); Twilio, MailgunInbound (platform); plugin-extensible |
Webhooks (outbound)IWebhookEventBus |
Subscriptions + delivery rows + retry sweep with exponential backoff. Operators configure subscriptions per-org; deliveries are signed with HMAC-SHA256 of the body. The retry sweep promotes Retrying → Queued before submit so duplicate workers can't double-fire. |
— |
SchedulingIAppointmentService |
Tenant-scoped Appointment entity with RFC 5545 RRULE recurrence. Reminder dispatch via AppointmentReminderBackgroundService in the worker process — guards against past-appointment refire, stamps ReminderSentUtc before dispatch (sent-but-unconfirmed beats double-fire). |
— |
Token hashingITokenHasher |
Issue + verify high-entropy magic-link tokens. CSPRNG (RandomNumberGenerator.GetBytes(32)) for issuance, CryptographicOperations.FixedTimeEquals for verify. Used by org invitations, LiveChat guest tokens, CRM customer-portal magic links. |
SHA-256 (Sha256TokenHasher) |
Atomic sequencesISequenceService |
Per-org counters that survive concurrent writes (Mongo $inc upsert). Used for invoice / quote / job numbering and any other "next-number-please" workflow. |
MongoDB |
Email templatesIEmailTemplateService |
Per-org Markdown templates rendered into the existing email outbox — share branding (logos / colours / footers) across all transactional emails without hand-rolling HTML. | Markdig |
Plugin background servicesIPlugin.RegisterBackgroundServicesAsync |
Lifecycle hook called only by the BackgroundWorker host — Web and ApplicationService skip it, so plugins can call services.AddHostedService<…>() here without firing twice across processes. Bundled today: CRM ships CrmContractGenerationBackgroundService (recurring contracts → child jobs) and CrmRetentionBackgroundService (CrmActivity + archived CrmInboundEmail retention). LiveChat ships ChatMessageCleanupBackgroundService. Adding a new plugin sweep is a one-method override — no edit to the worker host's Program.cs. |
— |
Configuration entry points
Configuration entry points after the platform-move refactor:
AI / SMS / inbound-email integrations (OpenAI, N8N, Twilio, MailgunInbound)
are platform-shared — manage at /Platform/ApiIntegrations
and /Platform/Webhooks. Payment credentials (Stripe / GoCardless
/ PayPal) stay per-org for regulatory reasons — manage at
/Admin/PaymentSettings. Platform-wide payment policy
(which providers tenant orgs are allowed to use, plus the platform's
own SaaS-billing credentials) lives at
/Platform/PaymentConfiguration.
Webhook URLs for each provider are surfaced in the integration row's
WebhookUrlPathTemplate — paste-ready for the provider's dashboard.
🏗️ Solution Architecture
Clean Architecture with .NET Aspire orchestration
🛠️ Technology Stack
Built with modern technologies and best practices
Frontend
Backend
Architecture
DevOps & Deployment
Features
📦 Ready to Download?
Start building with Blazor Blueprint's Clean Architecture today. Download the free Developer version or get Enterprise with premium plugins and commercial licensing.
🔓 Open Source on GitHub • Free for personal/non-commercial use • Enterprise license (£399) required for commercial use • Full source code included