Architecture & .NET Aspire — Blazor Blueprint
Login Register

💡 The full stack — MongoDB or PostgreSQL / SQL Server, Redis, the API microservice, and the background worker — ships in one product. Run the pieces you need; MongoDB single-process is the simplest default.

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 WebTenantResolutionMiddleware. 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. acme in acme.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)

Application code depends only on two backend-neutral abstractions — never on a database driver — and each uses ITenantContext to decide where to read/write:

  • IRepository<TEntity> is tenant-scoped: it targets the current tenant DB and applies an organisation id filter for org-scoped entities.
  • IPlatformRepository<TEntity> always targets the platform DB, letting platform-owned entities be stored and queried consistently.

Each database backend supplies its own implementation of that pair — the MongoDB driver for the default backend, and a shared EF Core implementation for the PostgreSQL / SQL Server backends — so call sites stay identical whichever backend is configured.

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 WebTenantResolutionMiddleware 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 gates the custom-domain feature end to end — both configuring a hostname (the domain field on /Admin/Organisation and the /Platform/CustomDomains page) and honouring one when a request arrives. Turning it off takes every tenant custom domain offline immediately. WebTenantResolutionMiddleware matches a host against Organisation.Domain and requires DomainVerified, and then discards the match for any non-platform organisation while the flag is off — deliberately, because gating only the admin page left a withdrawn feature still routing live traffic. The platform organisation is exempt, so the operator's own site keeps working and the failure is invisible from where they are standing. Treat this flag as a deployment-shape decision made once, not a switch to experiment with on a running system. Nothing is retired when it goes off: each organisation keeps its Domain, its DomainVerified state and its history, so turning the flag back on restores every route with no re-verification. To retire one domain, clear it on that organisation instead. Whether selecting an organisation sends you to its custom domain is a separate flag, ForwardToCustomDomain (below). With it off — the code default — custom domains are direct-access aliases only: /api/org/switch does not redirect, and users navigate to the custom domain themselves. With it on, picking an org that has a verified custom domain forwards to it. Either way that flag governs the org-picker flow only, never ordinary request routing.

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.yourdomain.com) 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 UsePlatformBrandingForOrgsWithoutCustomDomain all enabled:

Host Legal / Cookies / Email / PWA External auth (Facebook / Google / Microsoft) Site settings (header, logo, theme, contact) Org admin can customise?
yourdomain.com
(platform org)
Platform's own records Platform's own app credentials Its own record, like any organisation Deployment settings via /Platform/*; its website via /Admin/*
acme.yourdomain.com
(subdomain org)
Inherits platform records Inherits platform app — login flow routes through platform host Own record, seeded once from OrganisationDefaults Site settings, sitemap and robots. The other /Admin/* pages load and explain that these settings are the platform's
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 once from OrganisationDefaults Yes — full /Admin/* surface

Turning off UsePlatformBrandingForOrgsWithoutCustomDomain 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 — no /Platform/* page; the platform org edits its own at /Admin/* ✅ Always editable Seeded once from OrganisationDefaults, then independent
sitemap.xml — no /Platform/* page; the platform org edits its own at /Admin/* ✅ Always editable Seeded once from OrganisationDefaults, then independent
Site settings (title, tagline, theme, favicon, contact) — no /Platform/* page; the platform org edits its own at /Admin/* ✅ Always editable Seeded once from OrganisationDefaults, 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 UsePlatformBrandingForOrgsWithoutCustomDomain 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:

  • UsePlatformBrandingForOrgsWithoutCustomDomain = false → every org uses its own settings (or inherits if empty).
  • UsePlatformBrandingForOrgsWithoutCustomDomain = true + request on the org's custom domain → org-owned settings apply; the full /Admin/* surface is available.
  • UsePlatformBrandingForOrgsWithoutCustomDomain = 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 organisation-defaults template at /Platform/OrganisationDefaults 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.

    It is a template, not a copy of the platform organisation's row, and the distinction is load-bearing. Provisioning used to clone the platform org's whole SiteSettings and then blank a list of fields — and the fields nobody remembered to blank (analytics id, OG image, social handle) propagated to every organisation ever created, so every tenant's traffic reported into the operator's own analytics property. The defaults row carries an explicit allow-list instead — theme, locale, launcher, private-by-default — and no file references, because a logo or icon is a file id that resolves against the current tenant and means nothing in another one.
  • 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.

Read the Shipped column, not Code default. They are two different things and six of these disagree. Code default is the fallback used when the key is absent entirely — what a stripped deployment with no Features: section would get. Shipped is what appsettings.json actually sets, so it is what a fresh install does, and the bold values are the ones where the two differ. Reading the wrong column has produced confident, wrong conclusions more than once: PlatformAdminOrgImpersonation reads as false by code default, but ships true, so platform admins can enter organisations they are not a member of.

Every key below is declared in all three hosts' appsettings.json — the web app, the internal API and the background worker — and a flag read inside a queued job resolves from the worker's configuration rather than the web app's. Change a flag in all three files or in none: an absent key is not neutral, it is a third answer.

Two environment overlays change a value in this table, and both are easy to miss because they sit in a different file. appsettings.Lite.json turns SubdomainOrganisations off, because a one-container one-hostname stack has no wildcard DNS, wildcard certificate or parent-scoped cookie to serve subdomains with. All three hosts' appsettings.Production.json turn RequireEmailConfirmation on — and every shipped compose file runs ASPNETCORE_ENVIRONMENT=Production, so that is what a deployed stack does, whatever the Shipped column says. The column is the base file, which is what a local dotnet run and any custom environment inherit.

Flag Code default Shipped What it does
MultiOrganisation true true Enables multiple organisations with switching and membership. When off, the app runs in single-org mode on the platform organisation.
SubdomainOrganisations false true 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 true Organisations can set a verified custom domain in Organisation.Domain. Requests on that host resolve directly to the org.
UsePlatformBrandingForOrgsWithoutCustomDomain true true When on, an organisation with no domain of its own — a tenant subdomain or one sharing the platform host — inherits the platform organisation's legal / cookie / PWA / email / SMS / inbound-email / external-auth settings. An org on its own custom domain uses its own. The gate keys on whether the org has a domain, not on whether subdomain routing is enabled. (Push is not in the list — the VAPID key pair is deployment-wide, with no per-org concept to inherit.)
ForwardToCustomDomain false true 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 true Lets platform admins enter organisations they are not a member of. On in a shipped install.
TenantVisibleImpersonationAudit true true When a platform admin enters an organisation they are not a member of, records it in that organisation's own audit log, where its admins read it at /Admin/Audit. The platform-side record is written either way — this decides only whether the customer is shown it. Off is a choice to audit yourself without telling the tenant, which is what a procurement review asks about; it is legitimate where operator access is routine and a per-page row would be noise in the customer's log.
RequireEmailConfirmation false false Newly registered users must confirm their email address before they can sign in. This is the one flag an environment overlay changes: all three hosts' appsettings.Production.json set it true, and every shipped compose file runs ASPNETCORE_ENVIRONMENT=Production — so a deployed stack requires confirmation even though the base file above does not. Configure an email provider before your first deploy, or the first person to register cannot sign in and never receives the mail that would let them.
RequirePhoneNumber false 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.
LogApiRequests false true Persists an ApiRequest row per outbound provider call to the tenant database. On in a shipped install, and worth knowing before you leave it there: an AI request's row holds the verbatim prompt and completion, so it is both expensive and sensitive. The fields are encrypted at rest and pruned on the audit-retention window. Turn it off in all three hosts or in none — the value that decides whether queued work is audited is the background worker's.
LogApiRequestBodies false false Whether an ApiRequest row stores the verbatim request and response bodies, or only the metadata around them (endpoint, status, timing, sizes). Separate from LogApiRequests because the costs are different: the metadata is a small operational record, while an AI call's bodies are the prompt and completion — your tenants' content, and often their customers'. They are [Encrypted] at rest, and decryptable by anyone holding the deployment's key ring. Nothing in the product reads them back — no page, export, admin surface or API returns one, and the GDPR download does not include them — so leaving this on accumulates sensitive content with none of the benefit of an audit trail. Off by default: keep the trail, drop the payload. Turn it on deliberately, for debugging a provider or an obligation to retain what was sent. Error messages are kept either way, since a failed call is the one case where the row has a reader.
AppLauncher true 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 each plugin's dashboard at /plugin/{slug} (the /admin/plugin/{name} namespace is reserved for a plugin's settings pages, never its main entry point). When off, the dropdown shows a per-plugin list linking each plugin's dashboard and the Home page falls back to its marketing surface.
PerOrgExternalOAuth true true A custom-domain org that has entered its own Google / Facebook / Microsoft app credentials authenticates against that app, via a per-org OAuth scheme, instead of the platform's shared one. Platform and subdomain logins are unaffected.
OrganisationSso true true Per-organisation OIDC SSO — the runtime-registered oidc-org:{orgId} schemes, the SSO login endpoint, and the /Admin/Sso page. Off means none of it is registered or reachable, for a deployment that wants no per-org identity providers at all.
PlatformSubscriptionBilling false true Platform-level SaaS billing — the operator charging tenant organisations for access (monthly, yearly, or a one-off year). On surfaces the plan catalogue, subscription oversight and per-org billing pages, and enforces premium-plugin + seat entitlements. There is a second, runtime "billing is live" switch in the platform payment settings; both must be on.
SiteFooter true true The site-wide footer (copyright, the org's legal-policy links, "Powered by"). Turning it off removes it everywhere — the cookie banner then renders a standalone "Cookie settings" control so consent withdrawal stays reachable, as GDPR Art. 7(3) requires.

⚠️ "Default" here means the code default — the value used when the key is absent entirely, which is what a deployment with no Features: section gets. It is not necessarily what your appsettings.json sets. Several flags ship switched the other way, so read the file before concluding what a running instance does: a flag reading false in this column may well be true in the deployment in front of you.

Per-org public sites are configured outside the feature-flag table on the Pages page (/Admin/Pages): a SiteSettings.IsPrivate switch (members-only vs public), plus a home-page picker and per-URL hide, chosen from the org's public-URL registry — the allowlist of published pages projected from every plugin's IPublicPageProvider. When IsPrivate is on, the two access walls allow anonymous / non-member visitors ONLY on a registered public URL, the public home, or a plugin-declared public route; robots.txt returns Disallow: / and /sitemap.xml returns 404. Any public surface a plugin publishes — a scan redirect, a public landing page, or similar under its own path prefix — stays reachable regardless: each plugin declares its own public routes via IPublicRouteAllowList, honoured by both gates, so the members-only wall never has to hardcode a plugin's paths.

⚠️ Both walls govern PAGE routes. Neither covers /api, and neither survives a SignalR connection. The membership wall is a denylist of page root segments, so a controller you add gets no membership check from the pipeline; and a hub invocation is a frame on an already-open socket, so the pipeline runs for the handshake and never again. Both cases carry their own attribute instead — API Reference → writing your own controller has the rules and the two ways they fail silently.

Project Structure

📁 BlazorBlueprint/
📁 Core/ - Business Logic Layer
📁 Application/ - Services, interfaces, application logic
📁 Domain/ - Entities, value objects, interfaces
📁 ServiceDefaults/ - Shared configurations
📁 Rendering.Abstractions/ - Rendering contracts shared by the hosts and plugins
📁 Infrastructure/ - Data Access Layer
📁 Persistence/ - Backend-neutral repository abstractions
📁 Persistence.MongoDB/ - MongoDB implementation (default)
📁 Persistence.EntityFrameworkCore/ - Shared EF Core implementation
📁 Persistence.Postgres/ - PostgreSQL provider
📁 Persistence.Sqlserver/ - SQL Server provider
📁 Extensions/ - Service registrations
📁 Web/ - Presentation Layer
📁 BlazorBlueprint.Web/ - Blazor Server UI
📁 BlazorBlueprint.Web.Shared/ - Shared Razor components
📁 Services/ - API Services
📁 Api.Shared/ - The API contract both directions of a plugin REST surface are written against: the scoped base controllers, the [ControllerOrganisationScoped] / [ControllerPlatformScoped] attributes, the JWT client and handler, and the policy / scheme names. Sibling of Web.Shared
📁 InternalApi/ - REST API endpoints
📁 BackgroundWorker/ - .NET Worker for email, push, retention, plugins
📁 DbMigrator/ - Deploy-time schema / index runner (relational backends)
📁 Plugins/ - The home for plugin PROJECTS, referenced by every host through the one Plugins.props list rather than per-csproj. Not the same as the runtime drop-in folder at Web/BlazorBlueprint.Web/Plugins/, which takes built DLLs
📁 Weather/ - The one bundled plugin, and an EXAMPLE rather than a feature: it exists to show the plugin contract end to end (a settings document per scope, an output-cached page, a cached controller, its own SignalR hub) and persists nothing. It carries its own CLAUDE.md and test project
📁 Tests/ - Test projects. Run the SOLUTION, not one project
📁 BlazorBlueprint.Tests/ - The central suite
📁 BlazorBlueprint.Tests.Integration/ - What needs a real database container, plus the boot-path and convention guards. Self-skips the container tests when no Docker or database server is present
📁 BlazorBlueprint.Tests.Shared/ - Fakes and fixtures shared by the suites
📁 BlazorBlueprint.Tests.FixturePlugin/ - A plugin that exists only for tests: it carries the entity SHAPES the relational suites need. Never shipped, referenced by no host
📁 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 database-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, 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.

Two independent axes control serving. Serve mode is deployment-wide: Proxy (default) streams bytes through the app; Direct hands back a time-limited signed URL. Visibility is per file: Member (authenticated, tenant-scoped) or Public (anonymous and cacheable — for logos, OG images, PWA icons and anything an email client must fetch). Only a Member file, on a cloud provider, under Direct is ever redirected; everything else streams. Note the signed URL bounds only a copied raw link — the stable /api/files/{id} link never expires.
GridFS, Azure Blob, S3, PostgreSQL bytea, SQL Server varbinary. The default follows the database backend (Postgres → Postgres, SQL Server → SQL Server, MongoDB → GridFS), so the out-of-the-box choice never needs extra infrastructure.
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 through the job pipeline — run in this process or handed to the background worker, according to Messaging:Transport. Two-tier credentials: a platform-default Twilio sender stored in the platform organisation's own tenant database, which a custom-domain org can override with its own account at /Admin/SmsConfiguration (it is a deployment setting, so orgs on the platform host or a subdomain use the operator's sender). STOP keywords record opt-outs into SmsOptOut in the platform DB (hashed phone, PECR-compliant), keyed by (PhoneHash, SenderHash) so an opt-out is scoped per sender rather than global. SmsSidIndex maps the Twilio MessageSid → sending tenant so delivery-status callbacks can find the right tenant DB. Twilio (extensible to other providers via ISmsProvider)
Inbound email
MailgunInboundEmailHandler
Two-tier credentials: a platform-default Mailgun account + catchall inbox (configured at /Platform/InboundEmailConfiguration, stored as MailgunCredentials in the platform organisation's own tenant database), which a custom-domain org can override with its own account at /Admin/InboundEmailConfiguration. On the default (catchall) path each organisation holds an unguessable Organisation.InboundEmailAlias (platform DB, unique partial index, issued at creation), and 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 address to inbound+{alias}@{catchall domain}. The handler routes on the envelope recipient, extracting the tag and resolving the tenant by O(1) lookup on that alias.

Never on the To: header. An earlier design mirrored the org's public address and matched the forwarded message's To: — but that header is author-supplied and the address it matched was public, so any tenant who knew the shared catchall could file forged mail against another tenant's records. The alias is the fix, and a To:-header fallback would reinstate the hole. Sender SPF/DKIM verdicts are recorded on every row, and an org may opt in to rejecting catchall mail that fails both (forwarding legitimately breaks SPF, so it is off by default).
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. Payment providers (Stripe / PayPal) keep their credentials per-tenant in PaymentProviderCredentials; the Twilio (SMS status) and Mailgun (inbound email) handlers read the two-tier SmsProviderCredentials / MailgunCredentials and try the current-tenant-or-platform candidates until a signature matches, so the shared catchall POST doesn't 4xx back to the provider. Stripe, PayPal; Twilio, Mailgun (inbound); plugin-extensible
Webhooks (outbound)
IWebhookEventBus
Subscriptions + delivery rows + retry sweep with exponential backoff. Operators configure subscriptions per-org at /Admin/Webhooks, including HMAC rotation and a test-fire button; deliveries are signed with HMAC-SHA256 of the body. The retry sweep promotes Retrying → Queued before submit so duplicate workers can't double-fire. The host publishes 12 event types of its own (WebhookEventTypes): payments (payment.succeeded, payment.failed, payment.refunded, payment.charged_back), organisation membership (organisation.member.invited, .joined, .role_changed, .removed) and platform subscription billing (subscription.activated, .past_due, .cancelled, .expired). They are constants rather than inline strings so a producer and the pick-list advertising it can't drift apart — a ticked event at /Admin/Webhooks is a promise that it can actually fire. Your own code and plugins add theirs alongside by registering an IWebhookEventCatalog.
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 organisation invitations, and available to anything that hands an unauthenticated visitor a token in a URL — a guest link, a share link, a one-off access page. SHA-256 (Sha256TokenHasher)
Atomic sequences
ISequenceService
Per-org counters that survive concurrent writes behind the ISequenceService abstraction — an atomic $inc upsert on MongoDB, a RelationalSequenceServiceBase upsert on the PostgreSQL / SQL Server backends. Used for any per-organisation sequential reference — order numbers, ticket ids, document numbers — that has to stay gap-free under concurrent writes. MongoDB, PostgreSQL, SQL Server
Email templates
IEmailTemplateService
Per-org Markdown templates rendered into the email outbox, so transactional mail shares one set of branding instead of hand-rolled HTML. The host's own mail already goes through it — organisation invitations (OrganisationMembershipService) and subscription dunning (SubscriptionNotifier) both render here, as does any plugin that sends transactional mail. That is also what escapes the tenant-controlled organisation name before it reaches an external inbox: the renderer HTML-escapes every branding value and runs the body through Markdig with DisableHtml(), so markup typed into an org name can't reach a message sent over the operator's sending reputation. Branding itself is thinly wired — every call site sets only OrganisationName, and EmailBranding.LogoUrl has no producer, so a logo is something you pass in rather than inherit. Markdig
Plugin background services
IPluginBackgroundServices.RegisterBackgroundServicesAsync
Lifecycle hook called only by the BackgroundWorker host — Web and InternalApi skip it, so plugins can call services.AddHostedService<…>() here without firing twice across processes. A plugin uses it to run its own recurring sweeps — recurring-record generation, retention pruning, reminders, and the like. Adding a new plugin sweep is a one-method override — no edit to the worker host's Program.cs.

Configuration entry points

AI provider integrations (OpenAI / OpenRouter / Ollama / N8N) are platform-shared — manage them at /Platform/ApiIntegrations. Platform-default SMS (Twilio) and inbound email (Mailgun) each have their own surface — /Platform/SmsConfiguration and /Platform/InboundEmailConfiguration — and a custom-domain org can override either with its own account at /Admin/SmsConfiguration / /Admin/InboundEmailConfiguration. Payment credentials (Stripe / 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/PaymentSettings.

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
🗄️
Database
MongoDB (default), PostgreSQL, or SQL Server
Redis
Page Output Caching, API Response Caching, SignalR Backplane
📬
Redis Streams
Job Queue, Background Processing, Inbound Webhook Ingest
⚙️
.NET Worker Service
Background Processing, Queue Processing
📧
Email Providers
SendGrid, SMTP, MailerSend, Mailgun (out + inbound)
💳
Payment Providers
Stripe Checkout, PayPal Orders v2
📦
File Storage
GridFS, Azure Blob, S3, Postgres, SQL Server (per-row provider tag)
📨
SMS
Twilio, through the job pipeline
📄
PDF Rendering
QuestPDF templates + PdfSharp overlays
🪝
Webhooks
Inbound dispatcher + outbound delivery + retry sweep
🤖
AI Services
OpenAI, OpenRouter, Ollama, n8n (moderation is OpenAI-only)
🔐
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 / PostgreSQL / SQL Server 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 Container Registries (ACR / GHCR / Docker Hub) CI/CD Pipelines Multi-Stage Builds Gated DB Migrator

Features

Identity & Auth Plugin Architecture (IPlugin) AI (OpenAI / OpenRouter / Ollama / n8n) Multi-Tenant SaaS PWA Support Push Notifications User Management Dynamic Pages Audit Logging Privacy & Legal Policies .NET Worker Service Redis Streams Multi-Provider Email Inbound Email (Mailgun) SMS (Twilio) Payments (Stripe / PayPal) SaaS Subscription Billing Field-Level Encryption at Rest File Storage (GridFS / Blob / S3 / Postgres / SQL Server) PDF Rendering (QuestPDF + PdfSharp) Inbound + Outbound Webhooks Scheduling Primitive (RFC 5545 RRULE) Magic-Link Tokens (CSPRNG + SHA-256) Atomic Per-Org Sequences External OAuth (Facebook, Google & Microsoft) Enterprise SSO (OIDC, per-org) Two-Factor Auth Theme Management Notification Preferences Personal Data Management External Login Management

Ready to Build?

Start building on Clean Architecture and a real multi-tenant data layer. The complete source is free to use — including commercially — while your business earns under £100k/year. Past that, a one-time commercial licence (from £499, excl. VAT) applies. Every tier ships the same product; nothing is feature-gated.

🔓 Source-available • Free under £100k/yr revenue • Commercial licence (from £499) above that • Full source code included

An unhandled error has occurred. Reload

Connection lost

Trying to reconnect to the server…

Attempt 1 of 30, retrying in 0s

Could not reconnect

Check your internet connection, then try again.

Session expired

The server could not resume your session. Reload the page to carry on.