Post

Dunderly HR System: Technical Architecture

Dunderly HR architecture: a modular ASP.NET Core API, Blazor on Static Web Apps, Azure SQL and Blob, and a pull-based payroll file, with ADRs against the 2023 design.

Dunderly HR System: Technical Architecture

The 2023 design split Dunderly’s HR system into six services: Employee, Salary, Vacation, View, Payment Interface and Logging. Then it noticed that the data is shared between them and chose a single data store. That second decision was right, and it quietly undoes the first. Three services sharing one database, for ten concurrent users, is a distributed monolith with three times the deployment pipelines. This post keeps what the case study got right, merges what it over-split, and records every change as an ADR.

System context and component architecture

The target architecture, end to end, including the monthly trip to the server farm:

  1. Who uses it: About 250 employees, their managers and HR use one web app, with roughly 10 people active at once. Nobody installs anything.
  2. Load the app: Azure Static Web Apps serves the Blazor WebAssembly bundle from its edge. Nothing renders on a server, which is what the old View service did.
  3. Sign in: Microsoft Entra ID signs the user in with MFA and Conditional Access and issues a token carrying app roles such as Manager or HRManager.
  4. Call the API: The app in the browser calls one ASP.NET Core API on App Service with the Entra access token. The API checks the token, the role and the reporting line.
  5. Store and fetch: Employee, salary and vacation data live in one Azure SQL database; scanned documents go to Blob Storage. Both use private endpoints and managed identity.
  6. Observe: The API and the Functions send traces, logs and metrics to Application Insights through OpenTelemetry. This replaces the custom logging service and its queue.
  7. Approve payroll: When HR approves the month's payroll run, the API puts a message on a Storage queue. The Payroll Functions app reads the frozen run from SQL.
  8. Write the file: The Function writes the payroll file plus a manifest (record count, control total, SHA-256) to a locked-down outbound container.
  9. Pull, don't push: A scheduled agent in the server farm pulls the file over HTTPS 443 with an Azure Arc managed identity. No inbound port opens into the data center.
  10. Hand off: The agent verifies the checksum and drops the file into the payment system's input folder, the only interface the legacy C++ system has.
  11. Close the loop: The agent uploads a receipt. If none arrives by the deadline an alert fires, so a missed payroll is caught in hours, not on payday.
ComponentAzure serviceReplaces (2023)Responsibility
Web appStatic Web Apps (Standard), Blazor WebAssemblyView serviceUI, served as static files
HR APIApp Service (Linux, Premium v3), ASP.NET CoreEmployee, Salary and Vacation servicesAll business logic, as modules
Payroll FunctionsAzure Functions (Flex Consumption, .NET isolated)Payment interface (WebJob)Draft runs, write files, receipts, document promotion, outbox
Relational storeAzure SQL Database (serverless)SameEmployees, salaries, leave, payroll, audit
DocumentsBlob Storage (GRS)Storage AccountScanned contracts and reviews
Payroll outboundA second storage account(new)Payroll files, manifests, receipts
IdentityMicrosoft Entra ID(not in 2023)SSO, MFA, app roles
SecretsKey Vault(not in 2023)The few secrets left; keys
ObservabilityApplication Insights + Log AnalyticsLogging serviceTraces, logs, metrics, alerts
Pickup agentScheduled task on an Azure Arc-enabled server(new)Pulls the file into the payment system

Inside the API, the old services survive as modules with their own folders, their own EF Core DbContext configuration and schema (employees, salary, leave, payroll, audit), and no cross-module table access except through a module’s public interface. If one ever needs to scale or ship separately, it can be lifted out. At 10 concurrent users, I don’t expect that day to come.

Technology and framework selection

The client asked for Azure and .NET. Within that:

ChoiceWhyTrade-off
.NET 10 (LTS)Long-term support through the system’s first yearsUpgrade to the next LTS every two to three years
ASP.NET Core minimal APIs, grouped per moduleSmall surface, fast, OpenAPI out of the boxLess structure than controllers; we add it with module conventions
EF Core with migrationsTeam knows it; migration bundles fit the pipelineMust watch for N+1 queries; raw SQL for reports
Blazor WebAssemblyOne language and shared validation code with the APILarger first download than a JS SPA; fine on office networks
Fluent UI BlazorMatches Microsoft 365; accessible componentsTies the look to Fluent
Azure Functions, isolated workerTimer, queue and Event Grid triggers for monthly and event-driven jobsA second deployable
Bicep with Azure Verified ModulesAzure-only estate, no state file to manageNot multi-cloud; Terraform would be equally valid
GitHub ActionsCode, issues and pipeline in one place, OIDC to AzureIf Dunderly standardizes on Azure DevOps, pipelines move

The case study rejected Azure Functions for the payment interface as “designed for lightweight actions”. I’d turn that around: a monthly export of 250 to 500 rows is about as light as a job gets. What makes it important isn’t its weight, it’s that it must be verified, which the design below handles.

API and integration architecture

REST over HTTPS with JSON, versioned in the path (/api/v1), described by OpenAPI. The case study’s API tables are kept, with three fixes: one plural resource name everywhere (2023 mixed employee and employees), DELETE on employees becomes deactivation, and approvals return the updated resource.

FunctionalityPathReturn codes
Get employee by IDGET /api/v1/employees/{id}200 / 404
List employees by parametersGET /api/v1/employees?name=…&department=…&page=1&pageSize=50200 / 400
Add employeePOST /api/v1/employees201 / 400
Update employeePUT /api/v1/employees/{id} with If-Match200 / 400 / 404 / 412
Deactivate employeePOST /api/v1/employees/{id}/deactivation200 / 404 / 409
Add documentPOST /api/v1/employees/{id}/documents202 (pending scan) / 400 / 404
List documentsGET /api/v1/employees/{id}/documents200 / 404
Get documentGET /api/v1/employees/{id}/documents/{docId}200 / 404 / 409 (not scanned yet)
Remove documentDELETE /api/v1/employees/{id}/documents/{docId}204 / 404
Add salary requestPOST /api/v1/salary-requests201 / 400 / 403
Get salary requestsGET /api/v1/salary-requests?status=Submitted200
Withdraw salary requestPOST /api/v1/salary-requests/{id}/withdrawal200 / 404 / 409
Approve salary requestPOST /api/v1/salary-requests/{id}/approval200 / 403 / 404 / 409
Reject salary requestPOST /api/v1/salary-requests/{id}/rejection (reason required)200 / 400 / 403 / 404 / 409
Set vacation entitlementPUT /api/v1/employees/{id}/leave/entitlements/{year}200 / 404
Get vacation balanceGET /api/v1/employees/{id}/leave/balance200 / 404
Request leavePOST /api/v1/leave-requests201 / 400 / 409
Payroll runsGET/POST /api/v1/payroll-runs, POST …/{id}/approval200 / 201 / 403 / 409

Errors use RFC 9457 problem details. Lists are paged. Writes that matter carry If-Match with the row version, so two HR admins can’t overwrite each other (412). The integration with the payment system is not an API at all, it’s a file; see External systems.

Data architecture and data models

One Azure SQL database, one schema per module; documents in Blob Storage with metadata in SQL. This is the case study’s “single data store”, made explicit:

erDiagram
    DEPARTMENT ||--o{ EMPLOYEE : has
    EMPLOYEE ||--o{ EMPLOYEE : manages
    EMPLOYEE ||--o{ SALARY_RECORD : "is paid"
    EMPLOYEE ||--o{ SALARY_REQUEST : "is subject of"
    SALARY_REQUEST ||--o| SALARY_RECORD : creates
    EMPLOYEE ||--o{ LEAVE_ENTITLEMENT : "is entitled"
    EMPLOYEE ||--o{ LEAVE_ENTRY : books
    EMPLOYEE ||--o{ DOCUMENT : owns
    PAYROLL_RUN ||--|{ PAYROLL_LINE : contains
    EMPLOYEE ||--o{ PAYROLL_LINE : "appears in"
    EMPLOYEE {
        int EmployeeId PK
        string EmployeeNumber
        guid EntraObjectId
        int ManagerId FK
        date StartDate
        date EndDate
        string Status
        rowversion RowVersion
    }
    SALARY_RECORD {
        int SalaryId PK
        int EmployeeId FK
        decimal Amount
        date EffectiveFrom
        int SourceRequestId FK
    }
    SALARY_REQUEST {
        int RequestId PK
        int EmployeeId FK
        decimal ProposedAmount
        date EffectiveFrom
        string Status
        guid RequestedBy
        guid DecidedBy
    }
    LEAVE_ENTRY {
        int EntryId PK
        int EmployeeId FK
        string Type
        decimal Days
        string Status
    }
    DOCUMENT {
        guid DocumentId PK
        int EmployeeId FK
        string Type
        string Sha256
        string Status
    }
    PAYROLL_RUN {
        int RunId PK
        string Period
        string Status
        int RecordCount
        decimal ControlTotal
        string FileSha256
    }

Three modelling choices do most of the work:

  • Salaries are effective-dated rows, never updated in place. “What was Sam paid in March?” is a query, not an archaeology project. EMPLOYEE and SALARY_RECORD are also SQL temporal tables, so every change keeps its previous version automatically.
  • Leave is a ledger. Entitlements add days; bookings subtract them; adjustments are their own entries. The balance is a sum, and every number can be explained.
  • Payroll runs are snapshots. A run copies the figures it pays into PAYROLL_LINE, so later edits can’t change a file that’s already gone.

Sizing from the case study: about 1 MB of structured data per employee, so SQL stays under 1 GB for years; the ~25 GB is documents, which is why they live in Blob Storage and not in the database.

Identity and access architecture

Assumption: Dunderly uses Microsoft 365 (assumption A5), so every employee already has an Entra ID account and MFA can be enforced with Conditional Access.

  • Two app registrations: dunderly-hr-web (single-page app, MSAL, PKCE) and dunderly-hr-api (exposes the HR.Access scope and defines app roles).
  • App roles assigned to Entra groups: Employee (everyone), Manager, HR.Admin, HR.Manager, Payroll.Operator, Auditor.
  • Two layers of authorization. The role says what kind of thing you can do; the data says to whom. A Manager can raise salary requests, but only for employees whose ManagerId is them, checked in the API on every call. The reporting line lives in the HR database, which is the source of truth for it.
  • Workloads have no passwords. The API and the Functions use managed identities for SQL (Entra-only authentication), Blob, queues and Key Vault. The on-premises pickup agent uses an Azure Arc managed identity, so even it has no stored secret.
  • Admin access to Azure goes through PIM for just-in-time elevation.

More in Authentication and authorization and the Entra posts on app registrations and modern authentication.

Hosting and deployment model

Everything PaaS, one region, private data plane:

ResourceSKU / planNotes
Static Web AppsStandardCustom domain, PR preview environments
App Service planLinux Premium v3 (P0v3), 1 to 2 instancesDeployment slots, VNet integration
Functions appFlex ConsumptionVNet integration, scales to zero between jobs
Azure SQLGeneral Purpose serverless, max 2 vCoresAuto-pause off in prod, on in dev/test
Storage (documents)StorageV2, GRSVersioning, soft delete, Defender malware scanning
Storage (payroll)StorageV2, GRSImmutability policy on files and receipts
Key VaultStandard, RBAC modePrivate endpoint
Log Analytics + App InsightsPay as you goWorkspace-based

Assumption: West Europe as the primary region (as in the 2023 estimate), with North Europe for disaster recovery (assumption A1).

The VNet has two subnets: one delegated for App Service and Functions VNet integration, one for private endpoints (SQL, both storage accounts, Key Vault). Public network access is disabled on SQL and Key Vault. The payroll storage account also allows the server farm’s egress IP, unless a VPN exists (see Network security). Environments are covered in Infrastructure.

Scalability and performance strategy

The honest answer is that scale isn’t the problem. Ten concurrent users and 500 employees will barely register on a P0v3 and a serverless database. The strategy is about not wasting money and not being slow:

  • Autoscale 1 to 2 instances on CPU above 70%, mainly for resilience during platform maintenance. The 2023 design used autoscale on each of three App Services; one plan does the same job.
  • SQL serverless scales vCores with load and bills per second; max 2 vCores covers month-end reporting.
  • Documents never pass through the database. Downloads stream from Blob through the API (5 MB files, so a SAS URL isn’t needed and every download is authorized and audited).
  • Cache reference data (departments, leave types, public holidays) in memory for minutes.
  • Paging everywhere, and an index on every foreign key and on (EmployeeId, EffectiveFrom).

What would change at 10× (5,000 employees, 100 concurrent users)? Probably only SKUs. The first real change would be a read replica for reports.

Resilience and disaster recovery

“Not mission critical”, with one time-critical job, gives this shape:

FailureEffectResponse
API instance restartsSeconds of errorsSecond instance during scale-out; client retries idempotent reads
SQL transient faultsFailed requestsEF Core retry on failure (connection resiliency)
Payroll Function fails mid-fileNo fileQueue retry; file write is idempotent by run ID
Pickup agent offlineFile not deliveredMissing-receipt alert days before payday
Region outageSystem downRestore in North Europe within RTO

Assumption: RTO one business day, RPO 15 minutes (assumption A7).

DR plan: Azure SQL geo-restore from geo-redundant backups (RPO typically under an hour) or, if 15 minutes is firm, an auto-failover group with a serverless secondary; documents and payroll storage are GRS; Bicep redeploys the compute in North Europe; DNS for the custom domains points at the new endpoints. No warm standby: for this RTO it would double the cost for little benefit.

TODO: Is a 15-minute RPO firm? Geo-restore alone gives roughly an hour of possible data loss; a failover group gives seconds but adds a second database’s cost.

Architectural decision records

Each ADR lives in docs/adr/ in the repo. Here’s the summary, with what changed from the 2023 case study:

ADRDecision2023 designWhy it changed
ADR-001One API, modular monolithThree App Services (Employee, Salary, Vacation)Shared data, one UI page needs all three, 10 users; three deployables triple the work for no gain
ADR-002Blazor WebAssembly on Static Web AppsStatic Web Apps for the View serviceKept; Blazor shares validation code with the API
ADR-003Azure Functions for background and monthly jobsApp Service WebJobWebJobs have no built-in redundancy (the case study noted it) and tie jobs to the API’s deployment; Functions give queue retries and Event Grid triggers
ADR-004No Application Gateway WAF in v1App Gateway WAF v2 (~$352/month)41% of the 2023 cost to protect an internal app with SSO and MFA; revisit if exposure grows
ADR-005Private endpoints and managed identitiesFirewall rules on App Service outbound IPsOutbound IPs are shared and can change; identity is a stronger boundary than an IP
ADR-006Application Insights with OpenTelemetryA custom logging service fed by a queueThe SDK already batches asynchronously; a logging service is code we’d have to run
ADR-007Pull-based payroll file with manifest and receiptWebJob sends a file to the payment systemNo inbound path into the server farm; delivery is verified, not assumed
ADR-008Azure SQL serverless with temporal tablesSQL GP provisioned, 3-year reservedPays for actual use; temporal tables give history for audit
ADR-009Bicep with Azure Verified ModulesPortal configurationRepeatable environments and DR

An ADR stub, as the team would write it:

1
2
3
4
5
6
7
8
9
# ADR-004: No Application Gateway WAF in v1
Status: Accepted (2026-10)
Context: 2023 design put App Gateway WAF v2 in front of three APIs, $352.15/month (41.5% of total).
  Users are ~250 staff signing in with Entra ID, MFA and Conditional Access.
Decision: No WAF in v1. Rely on Entra ID, JWT validation, rate limiting, input validation, and
  Defender for Cloud recommendations.
Consequences: Lower cost; no OWASP rule set in front of the API.
Revisit when: the app is exposed to non-employees, a pen test finds an issue a WAF would block,
  or a regulator or insurer requires it. Front Door Premium with WAF is the likely choice then.

Checklist

  • Review ADR-001 to ADR-009 with the client and record acceptance.
  • Confirm Microsoft 365 / Entra ID tenant and Conditional Access licensing.
  • Confirm region choice (assumption A1) and the RPO question.
  • Publish the OpenAPI document from the walking skeleton.
  • Agree module boundaries and schema names before the first migration.
  • Confirm the server farm can reach Azure Storage on HTTPS 443.
  • Re-price the hosting table (see Budget).

This post is licensed under CC BY 4.0 by the author.