โ† Back to writing

How I Architected Three Apps to Share One Backend (and When I'd Split Them Apart)

Jul 2026 ยท 10 min read

As solution architect for an early-stage property-tech platform, I was building solo, had about 10 months, and three genuinely different user bases to serve: property seekers searching and posting ads, agents managing multi-tenant portfolios of listings, and internal staff moderating and administering the whole thing. The obvious enterprise answer โ€” three separate backend services, three repos, three deploy pipelines โ€” was also the wrong one for where I actually was. The interesting design problem wasn't "how do I build this to scale eventually." It was "how do I keep this small enough to hold in my own head, without painting myself into a corner if it works."

Three apps, one backend, one engineer's worth of context

The backend is a single Fastify + TypeScript + MongoDB codebase, structured as three logical apps โ€” user-facing, agent portal, admin โ€” each with its own route prefix (/api, /api/agent, /api/admin) and its own authentication model. The user app uses lightweight OTP auth. The agent portal needs multi-tenant IAM โ€” accounts with owners, delegates, and viewers, because agents run teams, not solo operations. The admin app needs the strictest access control of the three, since it's the one surface where a mistake touches other people's data at scale.

Three different auth models, three different rate-limit profiles, three different sets of route handlers. What they share โ€” and the only thing they share โ€” is a core layer: database models, geospatial search, analytics, notification dispatch, the pieces of logic that genuinely don't care which app called them.

The trick: run standalone, or run unified

Fastify's plugin system is built around encapsulation contexts โ€” each plugin gets its own decorators and hooks, composable into a parent instance without leaking into siblings. That made it possible to write each app as a self-contained Fastify plugin and give it two completely different runtime shapes without duplicating a line of route code:

// standalone: each app boots its own Fastify instance, own port
// user app      -> :5000
// admin app     -> :5001
// agent portal  -> :5002

// unified: one root instance, each app registered under its prefix
app.register(userApp, { prefix: "/api" });
app.register(adminApp, { prefix: "/api/admin" });
app.register(agentApp, { prefix: "/api/agent" });
// -> :4000, everything

Standalone mode is what I actually used day to day โ€” running just the agent portal API while working on agent-facing features means faster restarts and a smaller mental surface area than booting all three every time. Unified mode is what runs in production: one process, one deploy, shared middleware and logging, which is exactly what a solo engineer wants operating something in prod. Same code, two very different operational postures, selected by how you boot it rather than by maintaining two versions of anything.

Why not microservices from day one

The case against splitting these into separate deployed services wasn't about scale โ€” it was about what you pay before you get any benefit. Microservices buy you independent scaling, independent deploy cadence, and fault isolation between services. Every one of those benefits shows up when you have the traffic, the team size, or the operational maturity to need them. None of them show up on day one, and the costs โ€” network calls where you used to have function calls, contracts to version between services, distributed debugging when something's slow and you don't know which hop is responsible โ€” show up immediately, for a solo engineer, whether you need the benefits yet or not.

I was nowhere near needing those benefits. I was very much going to feel those costs.

The boundary that actually matters isn't physical, it's disciplined

Here's the design decision I'd defend hardest: even inside one deployable, the three apps never reach into each other's route handlers or internal state directly. The user app doesn't import agent-portal code. The agent portal doesn't import admin code. Everything that crosses app boundaries goes through the shared core layer โ€” models and services designed from the start as the thing all three apps depend on, not as whatever-happened-to-be-reusable scraped out after the fact.

That module boundary is deliberately the exact seam you'd cut along if you split these into separate services later. The insight I'd want another engineer in my position to take away: you don't need physical service boundaries to get most of the benefit of logical ones. You need a module structure and code-review discipline that keeps the seams clean. Physical separation โ€” separate repos, separate deploys, a network hop instead of a function call โ€” is a decision you can defer until something is actually forcing your hand, and deferring it costs you almost nothing if the logical boundary was real all along.

What would actually force the split

I'd split the agent portal out first, and the trigger would be concrete, not aspirational: the agent portal's traffic pattern (bursty, tied to business hours and listing-update cycles) diverging enough from the user app's (steadier, consumer-facing search traffic) that scaling them together means over-provisioning one to satisfy the other. Or a deploy-cadence conflict โ€” admin-only changes that shouldn't require redeploying user-facing search because a rollback risk in one shouldn't hold the other hostage. Or, most simply, team growth: once there are dedicated engineers per app instead of one person touching all three, a shared repo stops being a convenience and starts being a coordination tax every PR has to pay.

None of those triggers had fired yet. Building for them preemptively would have meant paying microservices' overhead solo, against triggers that might never arrive in the shape I'd guessed at.

Where the frontends did split, immediately

The three frontends โ€” user search and ads, the agent portal, the admin dashboard โ€” are separate Next.js apps, deployed independently on Vercel with their own GitHub Actions preview pipelines, from day one. That's not an inconsistency with everything above; it's the same reasoning pointed at a different answer. The frontends genuinely share almost nothing โ€” different user experiences, different design requirements, different release cadences driven by different stakeholders. There was no real logical boundary being preserved by combining them, so combining them would have bought nothing and cost coordination overhead immediately. The backend had a real shared core worth not duplicating. The frontends didn't. The architecture followed where the actual reuse was, not a rule about what "should" be one repo.

The actual lesson

Building this solo in about 10 months, the constraint that mattered most was never "will this scale to a million users someday." It was "can I keep the whole system in my head well enough to move fast without breaking things I forgot existed." Boundaries enforced by module structure and review discipline are nearly free and you can put them in from day one. Boundaries enforced by separate deployables and network calls are not free, and the right time to pay for them is when a concrete trigger โ€” not a hypothetical one โ€” actually shows up.