Contents
The most flattering thing I can say about the platform is that for the whole of 2024 I did not touch it. Not one commit. It sat in production serving real clinics — booking, billing, prescriptions, OTP logins, reminder SMS going out on schedule — and it needed nothing from me. When the next change did land, in October 2025, it was not a bug fix. It was a vendor migration I chose to do, and it shipped as a config change.
That is the story I want to tell here: not a heroic launch, but a system I built solo in 2019, early in my career, still quietly doing its job six years later. The interesting part is why — the handful of unglamorous decisions that bought that longevity.
What it is
It is a pet-care and veterinary clinic-management platform for the Indian market. Pet owners register their animals; clinics run on role-based staff logins. A doctor writes prescriptions and tracks vaccines and deworming schedules. A receptionist bills. A groomer creates grooming bills. A shopkeeper runs a POS-style shop cart. That is five distinct user roles — owner, doctor, receptionist, groomer, shopkeeper — all served by one Django REST backend, a React admin dashboard, and mobile clients.
The backend does the unglamorous work a real clinic needs: OTP phone login, FCM push notifications, SMS reminders, a wallet/cashback system, Excel import/export. I built and operated all of it — 100% of the commits across both repos are mine.
Boring on purpose
The very first commit, in August 2019, was a Flask bootstrap — gunicorn config, Dockerfile, a seven-line requirements file. I threw it away within weeks and rewrote on Django and DRF.
For a solo builder shipping a multi-role product, that was the right unsexy call. Django’s batteries — the ORM, migrations, an admin I got for free, auth I did not have to design — meant every hour went into clinic logic instead of plumbing. The conventional early-career instinct is to reach for the newest thing. I reached for the most boring thing that would still be maintainable by one person years later. Six years on, the pinned Django 3.1 / Celery 4.4 stack is still the one in production. Boring compounded.
I made the same trade on the frontend: the clinic dashboard is built on Creative Tim’s Material Dashboard React template, not custom UI. Buying the shell let me ship a working clinic panel fast and spend my design budget on the parts that were actually product-specific.
Splitting Celery by queue, not by scale
Here is the decision I am proudest of, and it is a small one.
Most people, when they add Celery, run one worker pool and scale it horizontally.
I split the workers by queue instead — five dedicated queues, each in its own
container: send_text_messages, reminders_cron, export, import, wallet.
Routing lives in a single task_routes dict.
# petapp_api/celery.py
task_routes = {
"auth_profiles.tasks.send_otp": {"queue": "send_text_messages"},
"export.tasks.export_bills_xlsx": {"queue": "export"},
"clinic.tasks.import_medicines": {"queue": "import"},
"wallet.tasks.apply_cashback": {"queue": "wallet"},
# ...reminders_cron for the scheduled engagement jobs
}
Why bother, on a cluster this small? Because the tasks are not equal in urgency. A bulk Excel export of a clinic’s billing history can take a while and nobody is staring at it. An OTP SMS is the thing standing between a user and logging in — if it is late, the login feels broken. On a shared pool, a fat export can sit at the head of the line and delay that login code. Splitting the queues means a stuck export physically cannot starve OTP delivery. They are different workers.
That is a product-intuition call dressed up as an infra one. It cost me almost nothing to set up and it removed an entire class of “why was my OTP slow” complaints before they could happen.
The same queue topology drives the engagement loop. Celery beat runs six scheduled
jobs — daily 9am vaccine, deworming and prescription reminders, a midnight
pet-birthday job, a Saturday-evening “post a picture of your pet” nudge, a
Monday-morning credits reminder — and those fan out through reminders_cron
without ever touching the SMS-critical path.
Every vendor behind an env var
When I built the storage and email layers, I put every external vendor behind
configuration rather than wiring it into code. File storage went through
django-storages with custom PublicMediaStorage / PrivateMediaStorage
subclasses; whether we hit the CDN or the origin was a single USE_SPACES_CDN
toggle. The database host was a PROD_MODE switch between two config blocks. The
broker, the email backend — all env-driven.
At the time this just felt tidy. What it actually bought me showed up years later. In October 2025 I moved the platform’s file storage from AWS S3 to DigitalOcean Spaces and its transactional email from SendGrid to Resend. Two vendor migrations that, on a codebase wired the wrong way, are a rewrite and a scary weekend.
Here they were env changes:
# S3 → DigitalOcean Spaces: same S3Boto3 backend, new endpoint + keys
AWS_S3_ENDPOINT_URL=https://blr1.digitaloceanspaces.com
AWS_S3_REGION_NAME=blr1
USE_SPACES_CDN=true
# SendGrid → Resend: still SMTP, just a different host
EMAIL_HOST=smtp.resend.com
Rebuild the image for linux/amd64, promote it, done. I wrote a one-page deploy
runbook and moved on. No model changed, no view changed. An abstraction I put in as
an early-career reflex quietly paid for itself half a decade later.
Shipping fast, wearing five hats
I want to be honest about what this was: early-career, solo, full-stack, on a real deadline. I was designing the domain model and writing the React dashboard and standing up the Docker images and deciding how a groomer’s bill differs from a receptionist’s. The clinic domain core alone — Clinic, Doctor, Vaccine, Prescription, Bill, ShopBill, ShoppingCart, Groomer, GroomingBill and more — is one 882-line models file I owned end to end.
2021 was the heavy build year: 56 of the project’s 92 backend commits landed then — multi-clinic support, the wallet and cashback system, the per-queue Celery split. Then it settled. 2022 added in-app notifications mirroring the SMS reminders. In February 2023 I did one deliberate performance pass — and did it the right way round: added New Relic APM first, then used what it showed me to swap gunicorn’s sync workers for gevent and fix the querysets that were actually slow. Observability before optimization, not guessing.
I also kept the legacy top-level API routes (/auth/, /pets/, /doctor/) alive
alongside newer /v1/-namespaced endpoints, so already-deployed mobile clients
never got a forced breaking migration. Every one of those calls was mine to make —
I led the product, I did not just execute tickets against someone else’s spec.
What made it durable
None of the durability came from cleverness. It came from boring, maintainable tech; from putting every vendor behind config so change was cheap; from splitting work by urgency so the important path stayed fast; and from not breaking clients I had already shipped to. A team of one cannot afford heroics, so I optimised for the thing that lets one person walk away and come back years later to a system still running. In 2024 I got to test that directly: I walked away for a year, and the platform did not notice.
