CASE STUDY
Our own product · live on Android
A write-up of the architecture decisions behind Amour, with the reasoning and the cost of each one. It is long, it is specific, and the parts we would reconsider are in it.
01
Amour is our own product. It is not a client engagement and not a white-label build we handed over at launch. We designed it, built it, shipped it to the Play Store, and we run it in production today.
That distinction is the reason this write-up exists. Client work rarely lets you own the consequences of your own architecture. You make a call, the system gets handed over, and the operational bill for that call lands on someone else’s rotation. On Amour it lands on ours. Every decision below has a cost we have already paid, which is why each one is written with the cost attached.
Nothing here was inherited or decided by committee. Where a choice looks unusual, it was deliberate. Where it looks wrong in hindsight, it is in section 10.
02
Amour is a dating product for the Indian market. The functional requirements are unremarkable for the category and expensive in combination: real-time messaging, heavy media upload, automated moderation of everything users produce, subscription billing through the app stores, and government ID verification before people can contact each other.
Two constraints shaped everything below. The first is team size: every piece of infrastructure we run competes directly with product work, and an hour spent on the cluster is an hour not spent on the app. The second is unit economics. Consumer subscription pricing in this market sets a hard ceiling on what a user can cost to serve. A B2B product can bury a managed-cloud bill inside a seat price. A consumer dating app cannot, particularly one storing and processing media for every user whether or not that user ever pays.
Read the rest of this page as consequences of those two sentences.
03
The backend is Node services with their contracts defined in protobuf and their communication over gRPC. The app reaches them through Envoy, which translates gRPC-Web into gRPC behind it, so the app and the services share one generated set of types instead of a hand-maintained REST contract that drifts.
The reasoning for decomposing at all, stated honestly: at this scale it is usually the wrong call. Most products this size are better served by one deployable with clean internal boundaries, and there are enough published postmortems on premature service splits to make that the default. The specific justification here is load profile. Discovery, profiles, and matching have to answer while somebody is looking at a screen. The media and moderation paths do not — they are bursty, dominated by upload and by third-party API calls, and nothing about them needs to finish inside a request. Separating them is what lets one absorb a spike without dragging the other down.
The cost is real and continuous. More surface to deploy, more to observe, more places for a request to die. A bug that would be one stack trace in a monolith becomes a correlation exercise across logs and traces. Protobuf contracts have to be versioned with discipline, because a field removed carelessly breaks an app already installed on someone’s phone. For a team our size that overhead is a standing tax, and whether we drew the boundaries in the right places is the first item in section 10.
04
PostgreSQL, accessed through Prisma, is the primary store. Everything with a definition — accounts, profiles, photos, subscriptions, verification records — lives there. The reason is unglamorous: the data is relational, it needs constraints, and migrations need to be reviewable. Prisma generates a typed client from the schema, which pairs well with a typed gRPC surface. Its cost is distance between the code and the query it produces; on hot paths we check what actually runs and drop to raw SQL where the generated query is the wrong shape.
Neo4j holds the matching and relationship graph. The queries that decide who to show someone are traversals: who they liked, who liked back, who they blocked, who connects to whom, and how those edges combine with stated preferences. In SQL those become recursive joins that get slower and harder to read with every hop. In a graph store they are the native operation, and that is the whole argument for it.
The cost is a second database to run, back up, upgrade, and restore-test, plus a synchronisation path between it and Postgres. That sync runs asynchronously off the queue, so the graph is eventually consistent with the primary store and every feature reading it has to tolerate the lag. That is one more operational component, and one more class of bug a single-database design would not produce.
DragonflyDB handles caching. It speaks the Redis protocol, so nothing in the application had to be written differently, and the reason to pick it over Redis is that it is multi-threaded. Redis uses one core well. On hardware we own outright, where the cache shares a machine with everything else, an instance that uses the cores in front of it is worth more than protocol familiarity. The cost is ecosystem: Redis has two decades of written-down failure modes and Dragonfly does not.
05
Chat and activity feeds run on GetStream. This is a buy decision and it was not close. Real-time messaging is a deep well — delivery guarantees, ordering, read and typing state, presence, push fan-out, offline sync, and the moderation hooks around all of it — and none of it differentiates a dating product. Users expect messaging to behave the way it behaves in every other app on their phone, which makes the whole category table stakes with a high floor. Building it would have consumed months that belonged to matching and safety.
The cost is that a per-user vendor line now scales with growth, message content lives in a system we do not operate, and anything we build on top of chat is bounded by what their API exposes. Moving off it later would be a project, not a refactor.
Push notifications go through Firebase. Media is stored on Cloudflare R2, chosen for zero egress fees: a dating app serves photos on every screen, and egress is the line item that punishes that pattern hardest on the large clouds. The cost is that image processing is ours to run — resizing, format conversion, and thumbnails happen in our own pipeline behind the upload rather than at a provider’s edge.
06
Two questions matter here: what is in the content people upload, and whether the person uploading it is real. Different systems answer them, and they fail differently.
Images are checked with AWS Rekognition and text with AWS Comprehend. Both run asynchronously. An upload is accepted, stored, and published to RabbitMQ; a consumer performs the moderation call and acts on the result. Nothing in a user’s request waits on a moderation API.
upload → store → publish(photo.process)
│
├─ consumer: process image, generate variants
├─ consumer: moderation call
└─ decision → approve | take down + notifyThere were two designs available. Block the send until moderation clears, or accept optimistically and take down retroactively. We chose optimistic acceptance, and the reasoning is availability rather than speed. A moderation call is a round trip to a third party. Putting it inside the request path means every upload inherits that provider’s latency and, more importantly, its availability. If the moderation API is slow, the product is slow. If it is unreachable, uploads stop. Behind a queue, a provider outage becomes a backlog that drains later.
The failure mode of that choice is a window. Between the moment content is published and the moment a decision comes back, offending content is visible to somebody. We narrow it — the consumer on that path does nothing else, takedown and notification are automatic, and user reports feed the same review path with an admin decision that syncs back — but we do not close it, and this design cannot. Anyone describing an async moderation pipeline with no exposure window has moved the window, not removed it. We traded a short period of exposure for an upload path that does not depend on a third party’s uptime. Where we would revisit that is in section 10.
Identity is handled separately. Government ID verification is required before an account can message, call, or use the paid features. It is deliberately a gate on interaction rather than on signup: anyone can create an account and look around, but contacting another person requires verification. The point is to make a throwaway account expensive at the exact moment it would be used to harm somebody, without putting an identity check in front of a user who has not yet seen whether the product is worth it.
That costs us twice. It adds friction at the moment a user first wants to talk to somebody, which is the worst point in the funnel to add a step, and we accept that deliberately. And holding verification records is a data protection obligation rather than a feature: they are encrypted at rest, access is audited, and retention is a question we keep answering rather than one we decided once.
07
Logto handles identity and access, and we run it ourselves alongside the rest of the cluster rather than paying per monthly active user. Building auth would have meant sessions, refresh token rotation, social providers, password reset, account linking, and the review around all of it — weeks of work in the part of a system where mistakes are not bugs but incidents. The cost is that uptime is now our problem: if the auth service is down, nobody signs in.
RevenueCat handles subscriptions and entitlements. Store billing looks small until you are inside it — receipt validation, renewals, grace periods, refunds, restore flows, and one entitlement state kept correct across platforms and app versions. Building it would have cost weeks, then kept costing them every time a store changed its rules. The price is a revenue share, and an authoritative answer to “is this user subscribed” that lives in a system we do not run and our code has to trust over its own database.
08
The app is React Native, live on Android and built so iOS ships from the same source. State is Redux Toolkit, with session material persisted to the device’s secure storage rather than to ordinary app storage. Communication with the backend is gRPC-Web through Envoy, generated from the same protobuf definitions the services use, so a contract change surfaces as a type error at build time instead of a runtime surprise in the field.
What React Native bought is one codebase and iteration speed: a small team shipping a product change once rather than twice, in the same language the backend is written in.
What it cost is control at the native boundary. Every capability that matters — video calling, push, in-app purchase, camera, background behaviour — arrives as a native module written by somebody else, and upgrades are where the time goes. An SDK bump can turn into a week of reconciling native dependencies that has nothing to do with the product. Media-heavy list screens need deliberate work to stay smooth. And a crash can originate on either side of the JavaScript boundary, which makes some bugs harder to chase than in either native stack alone.
09
The application runs on self-hosted K3s on Hetzner, and everything under it is self-hosted too: PostgreSQL under an operator, RabbitMQ, DragonflyDB, Neo4j, Logto, and Envoy at the edge. Deployments go through Flux, which reconciles the cluster against manifests in git.
The reason is the shape of the cost curve. Managed cloud bills on precisely the axes a consumer media product is heaviest on: egress, storage operations, managed database hours, per-user authentication, per-message add-ons. Those axes scale with every user regardless of whether that user ever subscribes. Against consumer subscription revenue in this market the arithmetic does not close. Hetzner plus K3s put hosting roughly an order of magnitude lower, on hardware billed monthly and predictable rather than metered per operation.
The part that makes this defensible rather than reckless is GitOps. Cluster state is declared in a repository, Flux reconciles against it, and image automation updates the deployment tag when a build lands. Nothing is configured by hand on a server, a rollback is a git revert, and a rebuild is the same manifests applied to a new node.
git push
└─ CI builds image → registry
└─ Flux image automation writes the new tag back to git
└─ Flux reconciles the cluster to match gitSo the trade is cost down and operational discipline up. It is not cost down and reliability down, and if it were, it would not be worth writing about.
What we gave up is genuine. There is no managed control plane, so Kubernetes upgrades, kernel patching, and node failure are ours. Backups are ours to take and ours to restore-test, and an untested backup is not a backup. Nothing autoscales to absorb a spike, so capacity is decided in advance. Provider support is a ticket about a machine, not about our workload.
That makes this right for a team with someone comfortable operating Linux and Kubernetes, whose costs are dominated by infrastructure rather than salaries. It is wrong for a team that would rather buy the operational answer, wrong where a compliance regime expects a managed provider’s attestations, and wrong for a product that does not yet have a cost problem — at that stage the managed bill is cheaper than the attention it saves. We fit the first description. Plenty of the teams who ask us about this do not, and we tell them so.
10
Three judgements we would revisit. They are open questions rather than settled conclusions, which is the honest state of them.
Whether the service boundaries were drawn too early. We split by domain before we had the traffic to justify it. The split we can defend on evidence is the one between the request path and the queue-driven media and moderation work; the rest was drawn on expected shape rather than observed load. One deployable with the same internal boundaries would have given most of the same clarity with less to operate, and still allowed pulling a piece out when its load profile actually diverged. What the right consolidation looks like now is something we are still working out.
Whether two databases were worth it at this stage. Neo4j is the right tool for the traversals it serves. The open question is whether those traversals needed it yet. The cost is not the query layer, it is everything around it: a sync path with its own failure modes, a second backup and restore procedure, a second upgrade path, and one more system to understand before anyone can debug a matching bug. Whether an indexed edge table and recursive queries in Postgres would have carried us far enough to defer it is unsettled.
Where the async moderation model leaves exposure. The optimistic model applies one policy to all content regardless of who posted it. The version we would consider is tiering it by account risk: hold the first uploads from a new or unverified account until moderation clears, and keep established accounts optimistic. That keeps the availability property where the volume is and narrows the window where abuse is most likely to start. We have not built it, and the reason is prioritisation rather than principle.
11
Amour is a consumer mobile app and the services beneath it, shipped and operated by the same people. Service decomposition with the tradeoffs understood rather than assumed. Async pipelines where a third party’s latency would otherwise land inside a user’s request. Vendor integration at the depth where failure modes matter, not the depth of a demo. And infrastructure owned end to end, including the parts that wake you up.