Obliga · technical summary


How Obliga is built

A single-tenant .NET 10 / Blazor Server application, deployed entirely inside the customer's own Azure subscription. The engineering bias throughout: boring, explicit code; configuration over hardcoding; and a small set of architecture rules enforced structurally rather than by convention alone.

Stack

Runtime & platform

App.NET 10 (LTS), Blazor Server, MudBlazor component library
DataEF Core → Azure SQL, always via IDbContextFactory — never an injected DbContext, because Blazor Server circuits outlive a normal request scope
IdentityEntra ID OIDC for sign-in; Microsoft Graph for directory sync and mail send
Auth modelManaged identity wherever Azure supports it — SQL and Graph auth both fall back from a client secret to DefaultAzureCredential(), so identical code runs against a developer's local credential and the deployed App Service's managed identity
InfraBicep, deployed via az CLI into the customer's subscription

Solution layout

Three projects, one direction of dependency

Obliga.Web
Blazor Server components, MudBlazor, the Program.cs composition root
Obliga.Infrastructure
EF Core, migrations, Graph adapters, mail dispatch
Obliga.Domain
Entities, domain services, the notification/scheduling engine — no EF, no I/O

Domain references nothing. It's pure C# — the notice-scheduling engine (business days, notice-point calculation, interim/terminal decision semantics, the failsafe window) lives here and is exhaustively unit-tested with zero database or network dependency.

Ground rules

Eight things enforced structurally, not by convention

  1. 01
    Authorization is always a scoped tuple.Every grant is (user, role, department | All). One central authorization service answers "does this user hold role X with scope covering department Y" — no ad-hoc User.IsInRole calls anywhere in the codebase.
  2. 02
    No hardcoded business constants.Notification schedules, roles, decision options, repeat cadences — all configuration data. A literal 180 or "Director" in logic is a defect.
  3. 03
    Decision behavior follows type, not name.A decision option's Kind is Terminal or Interim; the engine branches on Kind only. Renaming an option can never change behavior.
  4. 04
    Audit is append-only and transactional.Field-level change records are written in the same SaveChanges call as the change itself. No update or delete path exists for audit tables.
  5. 05
    Every send is idempotent.A unique send-log key — (contract, notice point, recipient, scheduled date) — makes the notification job safe to re-run at any moment, including mid-failure.
  6. 06
    One test-mode chokepoint.A single global setting, checked at the single mail-dispatch class every send passes through, reroutes to a test inbox with a subject marker. No send path can bypass it.
  7. 07
    Time discipline.Timestamps stored UTC; contract dates are DateOnly; "days before" math runs in the tenant's configured timezone, isolated in one Domain service and unit-tested hard.
  8. 08
    GUID primary keys, schema obliga, PascalCase.Consistent naming and key strategy across every table, no exceptions.

Patterns worth knowing

A few decisions that shaped the rest

Scoped visibility via EF query filters

One HasQueryFilter on Contract, referencing per-DbContext-instance fields (not static state) — the documented EF Core multi-tenancy pattern. Applied uniformly to the grid, dashboard, detail page, console, and exports through one shared ContractVisibilityScope.ApplyAsync call, so scoping can't drift between surfaces.

Mail dispatch chokepoint

One IMailDispatcher. Notices, weekly digests, and admin job-failure alerts are three kinds of thing it logs — not three separate send paths — so rule #6 holds by construction, not by remembering to check it everywhere.

Directory-sync provenance

Department mappings and rule-derived role grants carry a provenance flag (FromDirectorySync / FromRuleId), so a scheduled sync can safely retract what it granted without ever touching something an admin set by hand.

Real-database E2E, not just unit tests

Service-layer tests run against EF Core's InMemory provider; every task additionally verifies against a real SQL Server (Podman locally, Azure SQL for deployment) — catches provider-specific behavior InMemory silently papers over.

// The recurring shape: client-secret when configured, managed identity otherwise —
// identical code path locally and in Azure.
if (string.IsNullOrWhiteSpace(options.ClientSecret))
    return new DefaultAzureCredential();

return new ClientSecretCredential(tenantId, clientId, options.ClientSecret);

Deployment

Bicep into the customer's own subscription

App Service (Linux, .NET 10) with a system-assigned managed identity is the one credential everything else trusts: Storage Blob Data Contributor on the attachment storage account, Key Vault Secrets User for the sign-in secret, and — the more consequential choice — Microsoft Graph application permissions (User.Read.All, Group.Read.All, Mail.Send) granted directly to that same identity rather than to a separate service principal. Directory sync and mail send need zero stored secrets as a result. The interactive sign-in app registration is the one exception that genuinely needs a client secret — issued in a second deployment phase (once the App Service's hostname exists for its redirect URI) and held in Key Vault, never in application configuration.

270+ automated tests across the Domain and Infrastructure projects — xUnit, exhaustive coverage of the scheduling engine's business-day and cadence math in particular, since it's the one component where a subtle bug would silently cost a customer a missed renewal.

← All Obliga guides