Case study · Education operations platform · 2019–2026

One audited system for running school programs, kept modern for almost seven years

A national education-programs operator runs enrichment groups in schools across the country, staffed by field educators who are paid by the hour or by the unit. I led the platform that plans those programs, collects every hour reported in the field, routes it through a multi-stage approval chain and produces the payroll and budget reports. It started on .NET Core 2.2 in 2019 and runs on .NET 8 today.

.NET 8 / ASP.NET CoreEF Core 8SQL ServerSSRSAngularAngular Material · RTLJWTPython tooling
Client
Israeli education-programs operator (confidential)
My role
Team lead & architect (hands-on)
Timeline
Dec 2019 – Sep 2026, in production throughout
Status
In production
At a glance
2.2 to 8.NET runtime, moved forward through 3 major migrations with no rewrite
1 to 0database lookups per request for permissions once cached. The cache is cleared by SQL Server change notifications.
12 to 3×4collection joins when loading a project for duplication, split to stop a cartesian explosion
48tables purged in foreign-key-safe order by a generated archive framework, in one transaction
4 envscovered by one PowerShell script that deploys 11 SSRS reports. Before, each environment was deployed by hand.
474REST endpoints across 128 tables, 156 views and 30 stored procedures
9 · 105roles and fine-grained permissions, enforced per project on server and client
5,200+commits across 4 repositories over 6¾ years
The problem

Every hour reported, checked and approved before payroll

Each program has sub-projects, budget sections, schools, groups of students and the educators assigned to them. Every month, every educator reports hours or delivered units, and every report has to pass checks against the allocation, the monthly limit, the school calendar and the educator's own days off. Then it goes through coordinators, inspectors and school staff before it can be paid. Rates change, people move between groups and projects close, but last year's payroll still has to come out exactly the same.

The organization also needed a clear record of who changed what, reminders for educators who hadn't reported, and the ability to hand any table to finance as Excel.

What I owned

Architecture, team and the long tail

  • Led the team and owned the architecture across the API, the database, the admin web app, the field reporting app and the reporting layer.
  • Wrote most of the code myself: the approval workflow, the permission model, validation, Excel import and export, and the performance work.
  • Kept the stack current: .NET Core 2.2 → 3.1 → 6 → 8, EF Core upgrades and a refreshed database scaffold.
  • Ran the performance push in 2022 and the security and data-retention work in 2026.
  • Kept a steady delivery rhythm with the client over 6¾ years of change requests.
What I built

Five parts, one source of truth

Admin web app

Angular with Material, in Hebrew and English with full RTL support. It has 212 components in 59 lazy-loaded route modules: projects, allocations, groups, students, institutions, work reports, approvals, documents, dynamic forms and messaging. Every list uses one generic table and every dropdown is driven by metadata.

Field reporting app

A mobile web app for educators. They see their allocations, report hours, attendance and units for the salary month, and track each report's approval status.

ASP.NET Core API

474 endpoints across 16 controllers and a service layer. It handles JWT access and refresh tokens, per-entity authorisation and the report status workflow, validates against limits and calendars, and handles 105 Excel export types plus bulk Excel import.

SQL Server model

A database-first schema project with 128 tables, 156 views, 30 stored procedures and 333 index definitions, plus audit and history tables. Schema compare keeps the Dev, QA, staging and production databases aligned.

Reporting

11 SSRS reports covering budget vs actual, salaries, attendance, activity and limit exceptions. They are opened through one-time report keys issued by the API, which expire after 60 minutes.

Architecture

A layered .NET API over a database built for reporting

The diagram is wider than the screen: swipe sideways to see all of it →

Everything runs on the client's own Windows servers and SQL Server. The database is the source of truth: reporting logic lives in views and stored procedures that SSRS and the API both use, and the EF Core model is scaffolded from the schema project instead of the other way round.

Design decisions

Choices that had to hold for years

Database-first, views for reporting

Payroll and budget reports are relational questions, so they live in SQL views and procedures that SSRS, Excel exports and the API share. EF Core is scaffolded from a versioned schema project. Trade-off: each schema change is a two-step change, DB script and scaffold. I accepted that for one definition of every number.

Permissions are data, cached and pushed

Nine roles and 105 permissions are resolved per project, group and allocation. The resolved map is cached in memory with a 5-minute sliding window, and a SQL Server change-tracking queue clears a user's entry as soon as their roles change. Trade-off: it needs Service Broker enabled on the database, but there is no stale access and no per-request lookup.

Snapshot money at approval time

When a report is approved, the hourly cost, travel, extra expenses and budget section are copied into the report itself. Trade-off: deliberate denormalisation. A rate change can never quietly rewrite last year's payroll.

Deletes in FK order, never cascade

Retired projects are purged by six stored procedures, deepest children first, all inside one transaction. Cascade deletes and disabled constraints were ruled out. Trade-off: more code, but it is generated from the live foreign-key graph, so it stays complete when the schema grows.

Upgrade in place, never rewrite

Every .NET jump (2.2 → 3.1 → 6 → 8) went in as a focused migration branch on the running product, with client releases continuing. Trade-off: some legacy front-end tooling stays. The client never paid for a big-bang rewrite.

Engineering log

Hard problems, and how they were solved

A selection from the commit history, in order.

May 2020

Queries that EF Core 3 refused to run

The move from .NET Core 2.2 to 3.1 ended EF's silent client-side evaluation. Several queries used string comparisons that can't be translated to SQL.

Rewrote them as server-translatable expressions, relying on the database's case-insensitive collation, and migrated the build and publish setup to 3.1.

Aug 2020

Authorisation was the slowest query in the system

Every request needed the user's resolved access across projects, groups and allocations, built by one very wide query.

Split it into 3 narrower queries to cut the result set, and applied index recommendations from a SQL Server tuning session.

Sep 2021

Duplicating a project timed out

Loading a project with all 12 child collections in one query multiplied the rows: a cartesian explosion.

Split the load into 3 queries of 4 includes each and merged the results in memory.

Feb 2022

Permission changes took effect late

Caching the permission map made requests fast, but a role change didn't apply until the cache expired.

Added a SQL Server change-tracking table and a Service Broker queue. The API subscribes and clears just that user's cache entry the moment their access changes.

Mar 2022

Under a second in SQL, slow in the app

The same SQL that EF generated ran in under a second in Management Studio but was very slow from the API.

The two sessions used different ARITHABORT settings, so they had different cached plans. The fix sets SET ARITHABORT ON on every EF command, and the app now gets the same plan.

Mar–May 2022

Big Excel exports failed

Exporting all students or reports for a large project failed in a single request.

The export now reads in batches of up to 100,000 rows. It moved from OpenXML to ClosedXML with ID formatting that keeps leading zeros, and the database got a round of new indexes.

Apr–Jun 2022

Chasing educators who hadn't reported

The “who hasn't reported” screen was slow, and the reminder action blocked the request while it sent the emails.

Moved the calculation into a stored procedure, stored a timestamp for each reminder, and sent the emails without holding the response.

Jun 2026

Hebrew text broke the payroll report

A salary report failed because an employee's accounting code contained Hebrew characters where the report expected a number.

Hardened the conversion with safe casts (TRY_CONVERT) and a matching database change. The report now handles bad input without failing.

Aug 2026

A user could change their own role

A security review found that a user could change their own role without the required permission, and that uploads weren't checked by type.

Closed the role-change path, validated uploaded file types, hid role and permission data from the configuration API, and removed an unused generic data endpoint.

2026

Seven years of data, no safe way to retire it

Closed projects reached 48 tables up to 6 foreign-key levels deep, and some tables had several paths back to the project.

Wrote a Python tool that walks the live FK graph (237 relationships) and generates 6 level-ordered delete procedures and 74 supporting indexes. One orchestrator runs them in a single transaction, in batches of 1,000 rows, and rolls back on any error.

Modernisation path

Seven years, one running product

Choose a stage to see what changed and what it bought. Dates and figures come from the repository history.

Outcome

Where it landed

AreaBeforeAfter
Runtime.NET Core 2.2 (2019).NET 8 LTS, EF Core 8, through 3 in-place migrations
Permission checksA wide DB query on every request3 narrower queries, then cached and cleared by change notifications
Project duplication1 query joining 12 collections3 queries × 4 includes
App vs SQL query timeFast in SQL, slow from the appSame plan in both: under 1 s, as measured in SQL
Large Excel exportsOne request, failing on large projectsBatched at up to 100k rows; 105 export types
Report deploymentManual, once per environmentOne script, 11 reports, 4 environments
Monthly report cycleCollected and checked by hand (≈5 working days, estimate)Web and mobile reporting with validation and batch approval (≈1–2 days, estimate)
Retiring old projectsNo safe path (≈2–3 days of manual DBA work, estimate)Generated, transactional purge of 48 tables (<1 hour, estimate)

Estimates are conservative engineering estimates, marked with “≈”. The rest comes from the code and commit history.

Stack

Technology stack

Back end
.NET 8, ASP.NET Core, EF Core 8, AutoMapper, NLog, Swagger
Data
SQL Server, schema project + compare, Service Broker, SqlTableDependency
Reporting
SSRS (11 reports), PowerShell deployment, ClosedXML / NPOI Excel
Front end
Angular, Angular Material, RxJS, ngx-translate (he/en, RTL)
Security
JWT + refresh tokens, lockout, password policy, per-entity authorisation, audit log
Tooling
Python FK-graph analyser and SQL generator

Free scoping call · 30 minutes · No commitment

Bring one process. Leave with a plan and a number to beat.

Write a few lines: the process or system, what hurts today (time, errors, cost or an AI pilot that stalled) and who uses it. After a short call you get a written plan and a fixed quote within ≈3 days.

Get a free scoping callafif.mansour@gmail.com

Opens a pre-filled email · reply within one business day

The model reads. The code counts.

All projects