Skip to content
Case study6 min read

QuickCampus — School ERP

QuickTouch Technologies · Senior Software Engineer, 2023 – 2025 · Visit

Stage
Live at 3,500+ schools; I left the team in 2025
Team
Frontend team of 11 — hiring, onboarding, planning, on-call and review
Mine
Admissions, payroll, user management, dashboards and reporting; HRMS portal frontend

Three and a half thousand schools sounds like a load problem. It mostly is not. Schools use an ERP in a pattern so lumpy that averages are useless — and that concentration turns a bug nobody could reproduce into one that happens forty times a fortnight.

What the design had to survive

The load is concentrated, not high
Admissions land in a six-week window, fee collection spikes on the same three days of the month across the entire customer base, and report cards all generate in the week before results. A one-in-ten-thousand trigger is invisible in testing and routine when the whole country is admitting at once.
There is no natural key for a student
Name and date of birth collide with siblings, and especially with twins. Guardian phone is shared across siblings by design. Aadhaar is not always available at admission and legally cannot be mandatory. Every candidate key had a real counterexample somewhere in the customer base.
The workflow is more real than the schema
An office administrator with a queue of parents in front of her and a Friday deadline will not wait for a birth certificate. Make the field mandatory with no path around it and you do not get correct data — you get 01/01/1900, thousands of times.
The numbers are people’s salaries
A payroll discrepancy of tens of rupees is worse than a large one. A large one gets reported the same day; a small one gets quietly corrected by hand every month until somebody mentions it in passing, which means it survives.
Every school is configured differently
The holiday calendar is per branch, so “working day” is not a constant. Any rule that divides by working days has to take the school’s own calendar as an input rather than assuming one.

Two bugs taught me more about where validation belongs than any amount of reading had, and eleven people taught me most of what I know about review. Both threads are summarised here; the full accounts are in separate pieces.

Stop defining what a student is

A parent fills in an admission form on a phone, on a patchy connection, and taps submit. Nothing appears to happen, so they tap it again. Two admission records, two admission numbers, two fee ledgers, one child. The school finds out three weeks later when duplicate fee reminders go out.

Three layers were supposed to prevent that and none of them did. The form disabled its submit button on click, which does nothing when the first request times out and the page never transitions. The service layer checked for an existing student before inserting, and two requests four hundred milliseconds apart both ran the check, both found nothing, both inserted — a read-then-write in application code is an optimistic wish, not a constraint, and it fails precisely when the system is busiest.

That left the database, and there was no key to put a constraint on. So we stopped trying to define what makes a student unique and defined what makes a submission unique instead: the form generates an idempotency key when it loads, sends it with the payload, and a unique index enforces it. Resubmitting the same filled form returns the original record. No amount of retrying, load balancing or double-tapping gets around it.

Separately — and this is the distinction I actually took away — a soft duplicate detector looks for similar name with same date of birth or same guardian phone, and flags the record for the admissions office rather than blocking it. A hard constraint the system enforces and a soft signal a person acts on are different tools, and confusing them is how you end up with either duplicates or a form nobody can complete.

One calculation, stored rather than recomputed

A teacher joins on the 18th. The salary slip, the payroll register and the HR portal preview gave three different figures for the same part month — tens of rupees apart, which is worse than wildly apart, because small discrepancies get hand-corrected rather than reported.

The same rule had been implemented three times, from one specification, by three people. They diverged on the questions the specification never answered: is the joining day itself paid, do you divide by calendar days or working days, and what is a working day when the holiday calendar is configurable per branch. None of those had a right answer. They needed an answer.

The fix was to delete two implementations. One function takes the employee, the period and the school's calendar and returns a breakdown; the preview calls the same endpoint payroll runs; the report reads what payroll stored rather than recomputing it. Recomputation is the specific smell — a number derivable in more than one place will eventually be derived differently, and it will surface to somebody who trusts you with their salary.

The result is stored with its inputs and the version of the rule that produced it. That part mattered when labour rules changed: old payslips still show what they showed. Recomputing history because a rule changed is its own category of disaster.

Where each layer belongs

The thesis both bugs arrive at. The database holds invariants — things that must never be true regardless of which code path ran or what the client sent. If the answer to "how do we prevent this" is a code review habit, it belongs here instead. The service layer holds rules needing context the database lacks: whether this user may admit to this branch, whether this fee structure applies this year. Rules have exceptions, so they live where a human can read them. The form exists to help the person filling it in — it is not a security boundary and nothing downstream should assume it ran.

The rule nobody follows

A school will want to admit a student without a date of birth, because the birth certificate is coming next week and admissions close on Friday. We found thousands of records reading 01/01/1900 — every one a place where the schema claimed we had a date of birth and we didn't, which is strictly worse than a null, because a null is honest.

So the incomplete admission became a first-class state. Records save with required fields missing, get marked incomplete, appear in a list the office works through, and certain operations stay blocked until they're resolved. The validation didn't get weaker; it moved from the moment of entry to the moment it actually mattered, and the data got more truthful for it.

You can enforce a rule, or you can find out what people are actually doing. Enforcing a rule that fights the workflow gets you neither.

Leading eleven people without the title

The job was hiring, onboarding, planning and the on-call rotation as much as it was review.

I interviewed for the frontend hires. Onboarding started with the code structure rather than a ticket — read how the modules fit together, then work through a run of minor bugs across them. Small, low-risk changes buy a mental map that documentation doesn't, and it means somebody's first real feature isn't also the first time they open the repository. Planning ran off the modules and the delivery date, and off knowing eleven people's actual throughput rather than an average. That works at eleven. It's also the first thing I'd expect to break at twenty, because it lives in one person's head.

Review is the part I got most wrong first, and the correction generalised.

Early on I left a review comment that said, in full: "why not just use a map here?" Six words, no malice in them. The reply arrived forty minutes later with a rewritten function and a slightly anxious "is this better?". The change was fine either way — what I had actually communicated was that a senior person had looked at their work and found it wanting, and they had spent forty minutes on something that didn't matter to get back to neutral.

The comment wasn't wrong. It was unlabelled. The reviewer knows whether they're blocking a merge, suggesting an improvement or thinking out loud; the author has no idea, and in the absence of a signal they assume the strongest reading. So three words at the front of every comment. Blocking means a defect or a real risk. Suggestion means take it or leave it, and I mean leave it. Musing means ignore me entirely.

When I tracked my own comments for a month, the blocking ones were under a fifth. The rest was preference dressed as feedback — and before I was labelling it, all of it read as blocking to the person on the other end.

Two other things did most of the remaining work. Keeping the blocking list deliberately narrow: correctness, anything touching money, permissions or tenancy, and data models or public interfaces, because those calcify while implementations don't. And getting fifteen minutes and a diagram before anything spanning more than one module was built — every time I skipped that on something significant, I paid for it in a review thread that ran to forty comments and left somebody demoralised.

The team uses the labels now, including on my pull requests. Being told "blocking: this breaks on an empty array" by someone I hired is the most direct evidence I have that any of it worked.

The system today

schools on the platform
3,500+
daily users
100k+
payroll calculation, down from 3
1
modules I owned
5

All projects, or get in touch if you’re building something with the same shape.