Architecture - Blazor Blueprint Clean Architecture & .NET Aspire Guide
Login Register

📐 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.Domain equals the full request host (including the platform organisation). Arbitrary subdomains of a tenant's custom domain (e.g. anything.customer.com when the org's Domain is only customer.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. brad in brad.yourdomain.com) must match a tenant's organisation Name.
  • 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> uses CurrentTenantDatabaseName (tenant DB) and applies an organisation id filter for org-scoped entities.
  • PlatformMongoRepository<TEntity> always uses PlatformDatabaseName (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:MultiOrganisation is false → users only operate on the platform organisation context (no org switching UI).
  • Cookie-based routing (platform host): Features:SubdomainOrganisations is false → /Org/Select sets BB_SelectedOrg, and TenantResolutionMiddleware applies the tenant override on page routes.
  • Subdomain routing: Features:SubdomainOrganisations is true → selecting an organisation redirects to <organisationName>.<platformDomain> (for non-custom-domain orgs; name is the stored Organisation.Name).
  • Custom domain aliases: Features:CustomDomainOrganisations controls whether custom hostnames resolve to organisations via Organisation.Domain. Custom domains are direct access aliases only: /api/org/switch does 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 SiteSettings row 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-facebook will be rejected by the provider when the callback lands on acme.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

📁 BlazorBlueprint/
📁 Core/ - Business Logic Layer
📁 Application/ - Services, interfaces, application logic
📁 Domain/ - Entities, value objects, interfaces
📁 ServiceDefaults/ - Shared configurations
📁 Infrastructure/ - Data Access Layer
📁 MongoDB/ - Database implementation
📁 Extensions/ - Service registrations
📁 Web/ - Presentation Layer
📁 BlazorBlueprint.Web/ - Blazor Server UI
📁 Services/ - API Services
📁 ApplicationService/ - REST API endpoints
📁 BackgroundWorker/ - .NET Worker for email, push, retention, plugins
📁 Dev/ - Development Tools
📁 AppHost/ - .NET Aspire orchestration
📁 .github/workflows/ - GitHub Actions CI/CD
📁 .azure/devops/ - Azure DevOps CI/CD

🧰 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
Payments
IPaymentService
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 storage
IFileService / 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 rendering
IPdfRenderer
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)
SMS
ISmsService
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 email
MailgunInboundEmailHandler
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.
Scheduling
IAppointmentService
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 hashing
ITokenHasher
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 sequences
ISequenceService
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 templates
IEmailTemplateService
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 services
IPlugin.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

🎨 Presentation Layer
🌐
Blazor Web
UI Components, Pages, Controllers
📡
SignalR Hubs
Real-time Communication
📱
PWA
Service Worker, Manifest
⚙️ Application Layer
🔧
Services
Business Logic, Use Cases
📋
Interfaces
Service Contracts, Abstractions
📊
Models
DTOs, ViewModels, Settings
🏛️ Domain Layer
🏢
Entities
Business Objects, Domain Models
🔗
Interfaces
Repository Contracts, Domain Abstractions
Extensions
Domain Logic, Business Rules
🔧 Infrastructure Layer
🗄️
MongoDB
Data Persistence, Repository Implementation
Redis
Page Output Caching, API Response Caching, SignalR Backplane
📬
Kafka
Message Queue, Background Job Processing
⚙️
.NET Worker Service
Background Processing, Queue Processing
📧
Email Providers
SendGrid, SMTP, MailerSend, Mailgun (out + inbound)
💳
Payment Providers
Stripe Checkout, GoCardless DD, PayPal Orders v2
📦
File Storage
GridFS, Azure Blob, S3 (per-row provider tag)
📨
SMS
Twilio (rides Kafka API pipeline)
📄
PDF Rendering
QuestPDF templates + PdfSharp overlays
🪝
Webhooks
Inbound dispatcher + outbound delivery + retry sweep
🤖
AI Services
OpenAI API, Chat Moderation
🔐
Identity
ASP.NET Core Identity, Custom Stores
🚀 .NET Aspire Orchestration
🔍
Service Discovery
📊
Health Checks
🔗
Service Communication
📈
Observability
🐳
Container Orchestration
Resource Management
🌐 External Services
🔐
Identity Providers
📧
Email Services
🤖
AI APIs
🔗
Application Service

🛠️ Technology Stack

Built with modern technologies and best practices

Frontend

Blazor Server Fluent UI Responsive Design

Backend

.NET 10 ASP.NET Core SignalR MongoDB Redis Caching Output Caching Distributed Caching SignalR Backplane IRepository Pattern

Architecture

Clean Architecture Service Layer Pattern Dependency Injection Generic Repository

DevOps & Deployment

Docker Docker Compose Azure DevOps GitHub Actions Docker Hub GitHub Container Registry CI/CD Pipelines Container Orchestration Kubernetes Multi-Stage Builds

Features

Identity & Auth Real-time Chat Content Management (plugin) AI Integration Multi-Tenant SaaS PWA Support Push Notifications Support System User Management Dynamic Pages Audit Logging Privacy & Legal Policies .NET Worker Service Kafka Kubernetes Multi-Provider Email Inbound Email (Mailgun) SMS (Twilio) Payments (Stripe / GoCardless / PayPal) File Storage (GridFS / Azure Blob / S3) PDF Rendering (QuestPDF + PdfSharp) Inbound + Outbound Webhooks Recurring Appointments (RFC 5545) Magic-Link Tokens (CSPRNG + SHA-256) Atomic Per-Org Sequences External OAuth (Facebook, Google & Microsoft) Enterprise SSO (OIDC, per-org) SEO Toolkit (coming soon) Two-Factor Auth Theme Management Notification Preferences Personal Data Management External Login Management

📦 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

Welcome! How can we help you today?
An unhandled error has occurred. Reload