A Spain-based preventive health team had a validated concept, paying intent from
both sides of their market, and no engineering function. They needed the whole
platform: patient and provider accounts, two-sided session booking, a membership
and credits model, invoicing reconciled with the Odoo system their operations
already ran on, an admin panel, a custom CRM for leads, and the infrastructure
underneath, built and run by one person.The architecture was specified by the client. My job was to implement all of it
as the only engineer, and to catch what the specification had not accounted for.The code and the model are under NDA. What follows is architecture and
reasoning, with no implementation detail or client data.
Constraints
Sole engineer, a non-technical client, and a timezone gap, so every requirement
had to survive being written down: decisions were made on calls, confirmed in
writing, and built in slices the founders could review as working software
rather than documents. Clinical compliance rules and a large role-based access
matrix constrained the data model from day one. Invoicing had to reconcile with
an Odoo instance that already existed and was not going to change. And the
handover was a requirement, not an afterthought: the client had to be able to
run, rebuild, and extend the system without me.The domain carried its own constraints, and they were the hard part rather than
the stack:
Memberships are entitlements, not subscriptions. A member holds a balance of credits that different session types consume at different rates, with expiry and carry-over rules. Booking has to check entitlement, not just availability.
Two-sided booking. Coaches and members each have calendars, and a session is only real when both sides hold. Cancellation has to return the credit under some conditions and not others.
Invoice state lives in Odoo. Payment status originates in more than one payment rail and has to reconcile against the platform's view of what a member is owed or owes.
How it's built
Django and Django REST Framework behind a Next.js frontend, PostgreSQL as the
primary store, Celery and Redis for anything that must not block a request. On
AWS: containerised application instances behind a load balancer across two
availability zones, RDS for PostgreSQL, S3 for files, SES for transactional
mail, and SNS with SQS on the eventing path, all inside a VPC with scoped IAM
roles and no public database path.
A Next.js frontend covering both sides of the product: the customer-facing panel and a full admin panel for operations and content.
A custom CRM module for leads and pipeline, built into the platform rather than bolted on from a third-party tool.
The admin panel is not a convenience. A non-technical team that has to file a
ticket with their only engineer to correct a booking or adjust a credit balance
does not have a working product, so operations, content, scheduling, entitlement
adjustments and the compliance-sensitive record views all have first-class
screens. The CRM sits inside the platform for the same reason: a lead becoming a
member is then one system's state change instead of two systems disagreeing.What it does in production. It is live at
fitter.health, built and operated by one engineer.
Entitlement, booking and invoice reconciliation are modelled with
database-level constraints, so a credit cannot be double-spent by a race
between two requests. Notification delivery is durable and inspectable after
the change described below, with failures landing in a dead-letter queue rather
than vanishing. The infrastructure is defined and reproducible, and the client
owns the AWS account, the repositories and the domains.
The incident
The platform had a re-engagement requirement: members inactive for 21 days or
more should get an email and a push notification nudging them back. The pipeline
was event driven. A scheduled EventBridge rule ran daily, a Lambda picked out the
inactive members, and the results were published to an SNS topic that fanned out
to the notification consumers for email and push.The original design had SNS delivering directly to those consumers. That works
right up until a consumer fails mid-delivery. From the publisher's side SNS is
fire and forget: if a subscriber errors or times out, the message is retried
briefly and then it is simply gone. No exception surfaces in the application, no
job shows as failed, nothing lands anywhere a human looks. Members who should
have been nudged just quietly were not.It surfaced as a product complaint rather than as an alert, which is the worst
way for anything to surface: someone on the client's side noticed that members
they expected to be nudged had not been. There was no count that disagreed with
any other count, so there was nothing to reconcile. My first instinct was that
the scheduling job was selecting the wrong members, and I spent real time
verifying that query before accepting that the messages were being generated
correctly and lost after publication.The diagnosis was the uncomfortable kind: the system was behaving exactly as
built. Nothing was broken in the code. The architecture had no durable place for
an undelivered notification to exist, so an undelivered notification did not
exist.The fix was to put SQS between SNS and the consumers. SNS fans out to queues
instead of directly to workers, the workers consume at their own pace, failed
deliveries return to the queue and retry, and anything that keeps failing lands
in a dead letter queue where it is visible, inspectable, and replayable.
CloudWatch alarms on queue depth mean a stuck consumer now announces itself
instead of hiding.What it changed for me is that I stopped treating durability as an optimisation.
A queue gets its dead letter path before it gets traffic, and "what happens when
this fails" is a design question, not an operations question. Silent success and
silent failure look identical from the outside, which is why the failure path has
to be designed on purpose.
Trade-offs I made
Implementing the client's architecture rather than redesigning it. The design
was theirs; my leverage was execution speed and catching gaps, like the
notification path above. The cost was living with structural choices I would have
made differently. The benefit was momentum and a client who fully owned and
understood their own system, which mattered more, because they keep it after I
leave.Two async systems instead of one. Celery on Redis handles application
background work; EventBridge with Lambda handles the scheduled re-engagement
scan. Running both means two things to operate and monitor. Collapsing everything
into one would have meant either dragging scheduled infra concerns into the app
workers or moving app tasks out to Lambda, and each job sat more naturally where
it landed.Integrity in the database, not just the serializer. Credits, entitlements and
booking rules are enforced with database-level constraints and transactions
rather than trusting the API layer. The cost is stricter migrations and less
flexibility when requirements moved. The benefit is that no code path, including
future ones nobody has written yet, can corrupt a balance.
What I'd do differently
I would have built the notification path with the queue in it from the first
day. The SNS-direct version was not a shortcut I took knowingly, it was a
default I did not question, and questioning it would have cost an hour against
the days it eventually took to find.I would also have separated two things I treated as one: accepting the client's
architecture, and accepting its data model. Taking the architecture as given was
the right call and I would make it again, but I extended that deference to
structural decisions underneath it that were mine to question, and the
trade-offs section above is honest that I then lived with choices I would have
made differently. The failure-path review I now run before building a feature is
the same instrument, and it works just as well pointed at a specification as at
my own design.
I’m open to full-time backend, platform and data engineering roles, and happy to walk through any decision on this page in more detail than it deserves.