Four surfaces, four repos: why BuilderHelp is not a monorepo
BuilderHelp is four surfaces with four release cadences — Next.js system of record, Expo field app, marketing site, and a Python OCR microservice — so we used four repos instead of a monorepo. The deciding factor was independent deploys: a pricing-page edit must not share blast radius with payments code. Submodules are annoying; unclear ownership across peers would have been worse.
Four surfaces, four cadences
Start with how often each thing changes and who changes it. Marketing copy and pricing get edited frequently, sometimes by someone who is not writing product code. The web app ships on a normal product cadence. The field app ships through app store review, which imposes its own rhythm no matter what your CI does. The OCR service changes rarely and mostly when invoice formats surprise it.
Those are four genuinely different release cycles with different risk profiles. A pricing page edit should not have any relationship to the code that moves subcontractor payments. In a single repo with a single pipeline, it does — maybe only through shared CI, shared version numbers, and shared blast radius, but that is enough.
The concrete rule we wanted was simple: a marketing copy change does not redeploy the product. Everything else followed from that.
What a monorepo would have given us
It is worth being fair to the alternative, because monorepo advantages are real and we gave several of them up. Atomic cross-surface changes are the big one: with one repo you can change an API and every consumer in a single commit, and the build tells you what you broke. We do not have that. Coordinating a breaking API change across the web app and the field app takes deliberate sequencing.
Shared tooling is easier too — one lint config, one TypeScript setup, one dependency graph. We maintain more configuration than a monorepo team would, and it drifts if nobody watches it.
And discovery suffers. A new engineer in a monorepo can grep everything. With four repos they need to know where to look first, which is a real onboarding cost that a README only partly offsets.
Each surface deploys on its own schedule. The API underneath is the only thing they share.
Why the deploy boundary won anyway
The deciding factor was that the surfaces do not share much code, and what they do share is narrow. They share an API contract and some types. They do not share components — the field app is React Native for one-handed use in the field, the web app is dense desktop screens for office staff, and pretending those are the same component library produces components that serve neither well.
When surfaces share a contract rather than an implementation, the monorepo's main advantage shrinks, because the atomic-change benefit applies mostly to shared implementation. Meanwhile the independent-deploy benefit stays at full strength, and it compounds: the marketing site can be deployed by anyone at any time with no possibility of touching production payments code.
Independent deploys also cap failure. A bad marketing deploy is a bad marketing page. In a unified pipeline it is at minimum a blocked queue for everyone, and depending on the setup, worse.

Submodules, and the specific annoyance
Shared pieces live in a submodule. Submodules have a reputation and most of it is earned. The particular failure is that a submodule pointer is a commit reference, so it is entirely possible to change the shared code, commit in the parent, and have consumers still sitting on the old pointer without any error anywhere. Nothing is broken. Nothing is updated either.
The second annoyance is that a fresh clone without the recursive flag gives you an empty directory and a confusing failure at build time rather than a clear message about submodules. Every new environment hits this once.
We mitigate with convention rather than tooling: the pointer bump is its own commit with a message saying what moved, and CI fails if a submodule pointer is behind the shared repo's default branch. That check is what converts the silent case into a loud one, because a consumer sitting on a stale pointer is otherwise indistinguishable from one that is current — you find out when a type that was supposed to have changed did not. Breaking changes to the shared contract get sequenced deliberately: land the additive version first, bump every consumer, then remove the old shape in a follow-up. That is three coordinated steps where a monorepo would have needed one commit. That is not elegant. It is a small amount of friction traded for a boundary we wanted anyway, and the friction is at least loud when it shows up.
The rule that makes it work
The structural decision that makes four repos tolerable is that the web app is unambiguously the system of record and owns the API. It is not a peer of the other surfaces. The field app is a client. The OCR service posts line items back into it. There is exactly one place where state is authoritative.
Without that, four repos becomes four services with ambiguous ownership of the same data, and that is genuinely worse than a monorepo — you get distributed-systems problems without any of the isolation benefits, because everyone still has to coordinate on every change. The repo split works because the ownership question was settled first.
So the advice generalizes as a question rather than an answer. If your surfaces share implementation and ownership is diffuse, a monorepo is probably right. If they share only a contract, ownership is clearly settled, and their release cadences genuinely differ, splitting buys you real isolation for a manageable amount of friction.
Questions
- When should multiple apps skip a monorepo?
- When surfaces share a contract more than implementation, ownership of state is settled (one system of record), and release cadences genuinely differ — especially when some ships through app-store review and some is marketing copy anyone can edit.
- What monorepo benefits do you give up?
- Atomic cross-surface changes, shared tooling defaults, and easy grepping across the whole product. Breaking API changes need deliberate sequencing, and onboarding costs more because engineers must know which repo to open first.
- How do you keep git submodules from going stale silently?
- Bump the submodule pointer in its own commit, and fail CI when a consumer's pointer lags the shared repo's default branch. That turns "nothing broken, nothing updated" into a loud failure.