💡 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.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.acmeinacme.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)
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: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, andWebTenantResolutionMiddlewareapplies 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:CustomDomainOrganisationsgates the custom-domain feature end to end — both configuring a hostname (the domain field on/Admin/Organisationand the/Platform/CustomDomainspage) and honouring one when a request arrives. Turning it off takes every tenant custom domain offline immediately.WebTenantResolutionMiddlewarematches a host againstOrganisation.Domainand requiresDomainVerified, 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 itsDomain, itsDomainVerifiedstate 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/switchdoes 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
SiteSettingsrow at creation, seeded from the organisation-defaults template at/Platform/OrganisationDefaultswith 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 wholeSiteSettingsand 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-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.
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
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 |
|---|---|---|
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, 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.
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 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 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 emailMailgunInboundEmailHandler |
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. |
— |
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 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 sequencesISequenceService |
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 templatesIEmailTemplateService |
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 servicesIPluginBackgroundServices.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
Technology Stack
Built with modern technologies and best practices
Frontend
Backend
Architecture
DevOps & Deployment
Features
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