Skip to content

engineering

Three repositories instead of one, and when that is wrong

7 min read

Tumbuhku's product is three repositories: a Go and Echo API, a Next.js backoffice for staff, and a Next.js app for parents. They deploy separately, run in different places, and share no code. A fourth repository holds the marketing site, which calls no API and is not part of this argument. That was a decision, not an accident, and it has been both correct and annoying in ways worth writing down.

Most writing on this subject argues a side. We want to describe the bill instead, because the split has a real price and the price is paid in a specific, measurable place: pull requests that span more than one repository.

What the split actually bought

Independent deploy cadence is the obvious one and it is the smallest. Copy changes in the parent-facing app ship several times a week without rebuilding a container image or running a database-backed test suite. A backoffice tweak does not touch anything a parent can see.

The boundary is the part that mattered more. The backoffice talks to admin routes with its own service credential. The parent app never has that credential, and does not have the code that would use it. Repository separation is not a security control, and We would not defend it as one. What it does is make accidental coupling into a deliberate act. In a single Next.js application, a shared route handler, a middleware matcher that is slightly too broad, or an import from a neighbouring folder is one line away, and the reviewer has to notice it. Across a repository boundary, someone has to consciously go get the thing. For a product holding children's health records, raising the cost of an accidental cross-wire is worth some friction.

Release risk differs by surface. The API handles medical records and its mistakes are durable. The backoffice has a handful of users, and its worst outcome is that somebody waits an hour. Those deserve different review depth, different test gates, and different rollback expectations. One repository tends to mean one CI configuration, which means either the interface is over-governed or the API is under-governed. In practice it is usually the second, because people optimise the gate for the thing they change most often.

There is a smaller benefit that shows up during incidents: a flaky browser test in the backoffice cannot block an API fix. Shared CI creates shared fate, and shared fate during an incident is expensive.

What it costs

Type drift is the headline cost and We come back to it in its own section.

The everyday cost is that a change spanning the API and a client is three pull requests with a mandatory order: extend the API, ship the clients, then remove the old shape. That is the same expand and contract discipline a database migration needs, which is a fair way to think about it, but it turns a twenty minute change into a two day sequence with a review queue in the middle. When the median feature is full stack, that tax lands on every feature.

Continuous integration duplicates. Three lint configurations, three formatter setups, three Node or Go versions, three sets of deployment secrets. They drift silently. Two repositories end up on different formatter settings, and now every file someone touches churns on whitespace, which makes review harder in a way nobody attributes to the real cause.

Onboarding is longer. Clone three things, run three things, and the API needs a database with seed data before either front end shows anything. A make target per repository and a written boot order help. They do not make it one command.

The cost people underestimate: there is no atomic revert and no cross-repository bisect. When a regression comes from the interaction of an API change and a client change shipped the same afternoon, you cannot bisect it, you reason about it. You compensate with tight deploy timestamps and a habit of writing the other repository's commit hash into the pull request description, which works and is clearly a workaround.

Keeping types in sync across a boundary

The rule is that the API is the source of truth and it emits a machine-readable contract. Everything else follows.

The Go service generates an OpenAPI document from its handler request and response types, and that document is committed in the API repository. The front ends generate TypeScript from it and commit the generated file too. Committing generated output is unfashionable and it is the right call here: it means a contract change shows up as a reviewable diff in the client pull request, where a person can see that a field became optional.

The step that makes this work rather than rot is one CI check in the API repository: regenerate the specification and fail the build if the committed file differs. Without that check, the specification is accurate for about two weeks and then it is decoration, and decoration is worse than nothing because people trust it. Clients pin to a tagged version of the specification rather than tracking the default branch, so an API merge cannot break a client build at a moment nobody chose.

Generated types catch shape, not meaning. A field that changed from millimetres to centimetres has the same type. So the API repository also holds a small set of contract tests that assert the literal JSON of two or three important endpoints, field names and units included. That way a rename fails in the repository where the rename happened, rather than three days later in somebody else's build.

The option we did not take

Publishing a private npm package of shared types is a legitimate alternative and it works well for larger teams. We did not use it because it adds a release step to every API change and a registry credential to every client's CI, and for three repositories and one team that is more machinery than the problem needs. If the number of consuming clients grows past three or two of them are outside the team, the published package becomes the better answer, because at that point you want an explicit version negotiation rather than a generation step.

Either way, never rename a field in place. Add the new one, keep the old one serving, remove it after the clients have shipped. The deprecation window is what makes a cross-repository change survivable, and it is the same pattern as a column rename in the database.

When a monorepo is the better answer

Run this test. Open the last twenty merged pull requests across all repositories and count how many required a coordinated change in more than one. Under about fifteen percent, the split is paying for itself. Above forty percent, you have a monorepo with extra steps and a lot of ceremony.

Beyond that number, three conditions push toward a single repository. One team small enough that everybody touches everything in a given week, because the boundary is protecting you from a coordination problem you do not have. A product where most features are genuinely full stack, so the ordered three-PR dance is the normal path rather than the exception. And any situation where atomic revert matters more than independent deploys, which is often true early when you are shipping fast and reverting often.

Shared user interface code is the strongest single argument. Two Next.js applications that share a component library across a repository boundary means either publishing a package or copying components, and copying components means they diverge within a quarter. Tumbuhku's two front ends deliberately share nothing visual: the parent app and the staff backoffice have different audiences and different design languages, so that cost never arrived. If they had shared a design system, We would have merged them.

The conditions that keep them apart are narrower than people assume: different deploy targets, different owners, different risk profiles, or a handover boundary. That last one is specific to how we work. The repository belongs to the client from the first commit, and sometimes the shape of the eventual handover is the shape the repositories should already have.

If you are choosing today with no history to measure, start with one repository and split when a boundary starts hurting. Merging three repositories later is a weekend of work. Splitting one is also a weekend of work. The difference is that the single repository lets you find out where the real boundary is before you commit to one.

Working on something similar?

Tell us what you are running into. We are happy to compare notes.