Case study · 202302 / 06
RealHRSoft
A multi-tenant HR suite serving 20+ companies from one Django codebase, with automated payroll for 500+ employees a month. I went from engineer to team lead.
- Role
- Software Engineer → Team Lead
- Year
- 2023
- Stack
- Python
- Django
- PostgreSQL
- Django-Q
- Redis
- Vue.js
- Multi-tenancy

Problem
RealHRSoft is an HR suite built at Aayulogic. It covers the whole employee lifecycle: recruitment, attendance from biometric devices, leave, performance appraisal, training, reimbursement and payroll. The backend alone is 28 Django apps.
Each client company needed its own data kept strictly apart, but building and running a separate system per client would not scale. Payroll was the hardest part. Every client has its own salary structure and tax rules, more than 50 rules in all, and payroll has to be right every month.
My role
I worked on RealHRSoft from November 2020 to September 2023. I started as a Software Engineer shipping features and grew into Team Lead for a team of six to seven developers.
Over that time I made about 1,650 non-merge commits to the backend, roughly a quarter of all backend commits in those years and more than any other developer. I also made over 200 to the Vue frontend. The work I owned included:
- Payroll. I wrote about 400 of the payroll app’s commits, nearly a third of the total in that period. This included the voluntary tax rebate engine (health and life insurance, donations, retirement fund contributions), projected annual tax for rebate planning, worklog-based pay, incentives and payroll reports.
- Projects and worklogs. Projects, activities and to-dos with separate client and employee rates, which feed into payroll through a worklog plugin.
- Leave and appraisal. Offline leave requests by HR and supervisors, with their validations and notifications, and the scoring and reports for performance appraisal.
- Client onboarding. Most of the commits on the database-per-tenant mode, including provisioning a new client’s database, migrating it and creating its admin account in the background.
As lead I mentored three engineers from intern to mid-level and introduced code review and documentation standards for the team.
Architecture
- Vue.js frontend (Vue 2 and Vuetify) talking to a Django REST Framework API, with JWT auth carried in cookies.
- Organization scoping. Tenant data lives in organization-owned rows. Organization-level API routes carry the organization’s slug, and shared viewset mixins resolve it and filter every queryset by it.
- Database per tenant (optional). Behind a single setting, a custom PostgreSQL backend picks the database for each request from its hostname. The same switch covers background workers, cache keys, websocket groups, uploaded files and auth tokens.
- Attendance devices. Face matching happens on the biometric devices, so the server never handles face data. Scheduled jobs pull punches from each device, either from a device server or directly from the device, map the device user ID to an employee and record the punch on their timesheet.
- Payroll engine. Pay is made of headings (additions, deductions, tax and so on) grouped into packages. Each heading has rules written as small formulas over employee variables and plugin functions.
- Django-Q on Redis runs payroll generation, device sync, notifications and scheduled jobs outside the request cycle.
The same codebase runs two ways. By default, many client companies share one installation, each kept apart by organization scoping. For clients that need harder isolation, the database-per-tenant mode gives each one its own PostgreSQL database behind its own hostname, with no code changes and no fork. Which model a client gets is a deployment decision, not a development project.
Key decisions & tradeoffs
Isolation in shared code, not in every view. Organization filtering lives in shared viewset mixins, so new endpoints get it by inheriting the mixin rather than remembering a filter. The tenant database switch sits even lower, in the database connection itself: application code doesn’t know which database it’s talking to. Tokens record the tenant they were issued for and are rejected on any other domain, so a session can’t cross tenants. The tradeoff is that the protection only covers code that goes through these layers. A raw query or a view that skips the mixin isn’t protected.
Payroll rules as data, checked before they run. Clients set their own formulas instead of us changing code for each one. When a rule is saved, the formula is compiled, checked against the allowed variables and functions, and test-run with dummy values and no built-ins. Anything outside that set is rejected. Logic that a formula can’t express, like attendance penalties, leave encashment or rebates, goes into registered plugin functions that formulas call by name. I wrote several of those plugins myself.
Payroll as a guarded background job. Starting a run validates the input first, then queues the job and returns straight away. A second run for the same organization is refused while one is queued or in progress. Each run is logged with its exact inputs and any errors, so a run can be regenerated with the same parameters. Generated payroll goes through configurable multi-level approval before it is confirmed. While a run is processing or waiting for review, attendance, leave and employee changes that would affect that period are refused.
Standards before scale. As the team grew, I introduced code review and documentation standards so quality didn’t depend on who happened to write the code.
Outcome & metrics
- 20+ client companies served from one codebase with isolated data.
- Automated payroll for 500+ employees every month.
- Bugs down 40% after introducing code review and documentation standards.
- Onboarding time for new developers halved.
- Led a team of six to seven developers and mentored three engineers from intern to mid-level.
- About 1,650 backend commits over almost three years, the most of any contributor in that period.
What I’d do differently
- Sandbox the formulas properly. Rule validation limits which names a formula can use, but formulas still run through Python’s
eval. Next time I’d use a small expression parser built for the job, so safety doesn’t depend on the validator catching everything. - Enforce tenancy below the view layer. Scoping relies on views using the right mixin. A default model manager that requires an organization, plus tests that try to read across tenants, would catch the endpoint that forgets.
- Pick one task queue. The core runs on Django-Q, and I later added Celery just for client onboarding in the tenant mode. That left two queues to run and monitor. I’d consolidate on one.