Three repo shapes, and the one question that picks between them
"Monorepo or polyrepo" is the wrong framing β it invites a religious answer. The useful question is narrower and answerable:
What has to change together, in one commit, to keep the system correct?
Things that must change together belong in one repository. Things that can change independently should be free to. Everything else in this workshop follows from applying that question honestly β including the awkward cases where the answer is "mostly independent, but they share one thing", which is exactly where subtrees earn their keep.
| Shape | Use when | What it costs you |
|---|---|---|
| Monorepo one repo, many deployables |
The pieces share state, a schema, a design system, or a release. One atomic commit can change all of them and CI proves the whole thing still works. | Every clone is the whole thing. Your CI has to learn which parts actually changed, or every push builds everything. |
| Polyrepo one repo per service |
Services own their own data and deploy on their own clock. Different teams, different languages, different release cadences. | Shared code has to travel somehow. Pipelines drift apart unless you centralise them. A cross-cutting change becomes N coordinated PRs. |
| Polyrepo + vendored shared code subtrees or a published package |
The middle, and where most real systems land: independent services that all depend on the same handful of libraries and pipelines. | You need a deliberate mechanism for the sharing β and the mechanism you pick is the subject of half this workshop. |
We run all three at once, on purpose. jnet-apps-factory is a pnpm monorepo of three Next.js apps. The JunctionNet platform is ~15 independent API repos. Those API repos vendor two shared repos with git subtree. Each shape is answering the "what changes together" question for a different part of the system.
Our monorepo: three Next.js apps, one lockfile
jnet-apps-factory holds three separate products. This is the whole
structure:
jnet-apps-factory/
βββ package.json # name: junctionnet Β· private Β· scripts only
βββ pnpm-workspace.yaml # packages: ["apps/*"]
βββ pnpm-lock.yaml # ONE lockfile, at the root
βββ Makefile # cross-app tasks
βββ supabase/ # ONE migration timeline (shared by two apps)
β βββ config.toml
β βββ migrations/ # 40 files: quizhub's tq_/iv_ + tracker's ht_
β βββ functions/
βββ apps/
βββ tracker/ # β tracker.junctionnet.nl
βββ quizhub/ # β quizhub
βββ junctionnet-nl/ # β junctionnet.nl (this site)
The root package.json ships no dependencies at all β it is a
task runner. Every real command is a filtered pnpm invocation:
"scripts": {
"dev:tracker": "pnpm --filter trackingcadence dev -p 3001",
"dev:quizhub": "pnpm --filter quizhub dev -p 3000",
"dev:junctionnet-nl": "pnpm --filter junctionnet-nl dev -p 3002",
"build": "pnpm -r build",
"build:tracker": "pnpm --filter trackingcadence build",
"lint": "pnpm -r lint"
}
Filter by package name, not directory name. Tracker's directory is apps/tracker but its package.json name is trackingcadence. pnpm --filter tracker silently matches nothing and exits 0 β the most confusing possible failure. Fixed ports per app (3000/3001/3002) are the other half of a livable dev loop: you can run all three at once.
Do not hoist the dependencies β the isolation is the feature
The instinct in a monorepo is to unify the stack. Resist it until you have checked whether the stacks can be unified. Ours cannot:
| tracker | quizhub | junctionnet-nl | |
|---|---|---|---|
| Next.js | 15 | 15 | 16 |
| Tailwind | v3 (tailwind.config.js) | v4 (CSS-first) | v4 (CSS-first) |
| Charts | Recharts v2 + @tremor/react v3 | Recharts v3 | β |
| Language | JavaScript | JavaScript | TypeScript |
@tremor/react v3 requires Tailwind v3 and React 18 peers. That
single constraint is why tracker cannot move to Tailwind v4 and why the other two cannot use
Tremor. pnpm's per-app node_modules is precisely what lets all
three build unmodified in one repo β with npm or yarn's flat hoisting you would be forced
into a migration you did not ask for, on somebody else's schedule.
Write the constraint down where someone will hit it. Ours lives as a comment block inside pnpm-workspace.yaml and again in the root CLAUDE.md. A monorepo invites well-meaning cleanup; an un-explained constraint gets "tidied up" within a quarter.
Why these three live together β and why one of them barely does
Apply the question. What has to change together?
- tracker + quizhub: yes. They share one Supabase project
(
jbcatulvrpvqgayivhnq) β one database, oneauth.users, and therefore one migration timeline. That is a hard technical reason, not a preference. - junctionnet-nl: not really. It is a static-ish marketing site with no database. It is in the repo for a single install, lint and deploy story β an operational convenience, and it is worth being honest that that is all it is.
The Supabase point deserves the detail, because it fixed a real bug. Supabase records applied
migrations in supabase_migrations.schema_migrations
per project, not per app. When the two apps kept separate
supabase/migrations/ directories, a db push
from either one saw remote versions it had no local file for and refused to run. Tracker
needed a supabase migration fetch workaround and a
.gitignore rule that hid quizhub's SQL. One directory at the repo
root, one timeline, no workaround.
# Run the CLI from the repo root, never from an app directory
$ npx supabase db push # apply migrations to REMOTE
$ npx supabase db reset # LOCAL only: migrations + seed.sql
Ownership inside the shared schema is by table prefix β tq_*,
iv_*, qcat_* for quizhub,
ht_* for tracker. Prefixes are how you keep a shared resource
shared without a coordination meeting for every table.
Deploying a monorepo on Vercel: one project per app
Vercel has first-class monorepo support and the model is simple: one Vercel project per deployable, each pointed at the same Git repository, each with its Root Directory set to its app folder.
| Vercel project | Root Directory | Domain |
|---|---|---|
| tracker | apps/tracker | tracker.junctionnet.nl |
| quizhub | apps/quizhub | quizhub |
| junctionnet-nl | apps/junctionnet-nl | junctionnet.nl |
With that set, a push to main triggers all three projects. Vercel
clones the whole repo, runs pnpm install at the root β so the
workspace resolves β then builds from the Root Directory. Each project gets its own domain,
its own environment variables, and its own rollback history. That last point matters more
than it sounds: independent rollback is the main operational reason to keep three projects
rather than one.
Skip builds for untouched apps. Three projects rebuilding on every push wastes build minutes. Vercel's Ignored Build Step takes a command that exits 0 to skip: git diff --quiet HEAD^ HEAD -- . from the Root Directory builds only when that folder changed. Add the shared paths you actually depend on (../../pnpm-lock.yaml, ../../supabase) or you will skip a build you needed. This only does anything with Git integration connected β it compares against VERCEL_GIT_PREVIOUS_SHA, which a CLI deploy has no notion of. See the next section.
Deploying from the CLI: run from the root, and never pass --cwd
Deploy from the repo root with no --cwd, and pick
the project with an environment variable:
# Makefile β run from the REPO ROOT. No --cwd.
VERCEL_ORG_ID := team_XiuMRxoJLUkefpg4pJSzLqp2
PRJ_TRACKER := prj_Sg3qreAJcUEQ0gSo05QbeP1N6imw
PRJ_QUIZHUB := prj_Tko8dJ8BZQ2G4L5BM5wUH5SQn6m5
export VERCEL_ORG_ID
deploy-tracker:
VERCEL_PROJECT_ID=$(PRJ_TRACKER) npx vercel --prod --yes
deploy-quizhub:
VERCEL_PROJECT_ID=$(PRJ_QUIZHUB) npx vercel --prod --yes
Three things are load-bearing here, and the first one is counter-intuitive.
- The CLI resolves Root Directory relative to its own working directory, not to the
upload. With Root Directory set to
apps/tracker, runningvercel --cwd apps/trackermakes it look forapps/tracker/apps/trackerand die before it uploads anything:
The path in that message isError: The provided path β~/GitHub/jnet-apps-factory/apps/junctionnet-nl/nextjsβ does not exist.<cwd>/<rootDirectory>concatenated. Read it that way and the fix is obvious: stay at the root and let Root Directory do its job. - The build needs the workspace root anyway. pnpm's real dependency tree
lives in
<root>/node_modules/.pnpmwith symlinks into it, and theoutputFileTracingRootguard in the next section keys off<root>/pnpm-workspace.yamlexisting. - The target project comes from
VERCEL_PROJECT_ID, not from a link file. A directory holds exactly one.vercel/project.json, and a monorepo root has several apps under it. There is no way to express "this directory is three projects" in a link file β so don't try; pass the id.
A bare vercel --yes at the repo root silently creates a new project named after the directory, links the root to it, and deploys the whole monorepo into it as a single app. It usually fails the build, but you are left with a junk project and a .vercel link pointing at it β and the next legitimate deploy from the root goes to the wrong place. Always pass the project id.
Check that Git integration is even available to you
Almost every monorepo guide, this one included, assumes push-to-deploy. Confirm you can have it before you design around it. Ours cannot:
$ # creating a project with a gitRepository, via the REST API
{
"error": {
"code": "repo_owned_by_org",
"message": "The repository \"jnet-apps-factory\" is private and owned by an
organization, which is not supported on the Hobby plan."
}
}
A private, organisation-owned repository needs a paid plan to connect. A personal private repo is fine; a public org repo is fine. Ours is the one combination that isn't β and moving three apps into an org monorepo is exactly how a project that used to qualify stops qualifying.
Without Git integration you silently lose four things:
| What you lose | Consequence |
|---|---|
| Push-to-deploy | The CLI is not the escape hatch, it is the deploy path. Document it that way instead of leaving "just push to main" in the README as an aspiration. |
| Preview deployments per PR | No automatic per-branch URL. You deploy previews by hand or not at all. |
| Ignored Build Step | Needs VERCEL_GIT_PREVIOUS_SHA. Nothing to compare against, so the affected-only optimisation above is unavailable β you get it for free anyway by only deploying the app you changed. |
vercel link --repo | The one CLI command built for monorepos maps directories to Git-connected projects. With none connected it finds nothing and links nothing: "No Projects are linked β¦ Repository not linked." This is why the deploy targets pass a project id. |
Decide this at restructure time, not after. The plan gate is a property of where the repo lives, not of your code. If push-to-deploy matters more than the org, keep the repo personal; if the org matters more, budget for Pro or accept CLI deploys. What you should not do is design a workflow around previews and affected-builds and only discover on deploy day that none of it is switched on.
The outputFileTracingRoot trap that fails after a successful build
This is the single sharpest edge in a pnpm + Next.js + Vercel monorepo, and it is worth the whole section because the failure mode is so misleading: the build succeeds, then the deploy fails.
Next.js traces which files a serverless function needs. In a pnpm workspace it has to trace
from the workspace root, or it misses packages that live in
.pnpm and only appear as symlinks. So you pin it:
// the naive version β correct locally, broken on a CLI deploy
const nextConfig = {
outputFileTracingRoot: join(__dirname, '../..'),
};
Now run vercel deploy from the app subdirectory. Only that folder
is uploaded, so ../.. resolves to somewhere outside the upload
root. Next compiles fine and then emits its output to a doubled path β
/vercel/path0/vercel/path0/.next β and the deploy fails after a
green build. The fix is to make the pin conditional on the workspace actually being present:
// apps/quizhub/next.config.mjs β the guard is load-bearing
import { existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const workspaceRoot = join(__dirname, '../..');
const hasWorkspaceRoot = existsSync(join(workspaceRoot, 'pnpm-workspace.yaml'));
const nextConfig = {
// pin when the root is really there; otherwise let Next infer,
// which is correct for the app-only upload case
...(hasWorkspaceRoot ? { outputFileTracingRoot: workspaceRoot } : {}),
};
export default nextConfig;
Probe for a file that only exists at the workspace root β pnpm-workspace.yaml
is the honest marker. Checking for package.json would match the app
itself and prove nothing.
Two smaller traps in the same file
Turbopack needs the same root
On Next 16, junctionnet-nl sets both. Without it Turbopack cannot
resolve next/package.json through pnpm's symlinks and fails with
"Next.js inferred your workspace rootβ¦":
const root = existsSync(join(workspaceRoot, "pnpm-workspace.yaml"))
? workspaceRoot : appDir;
const nextConfig: NextConfig = {
outputFileTracingRoot: root,
turbopack: { root },
};
There is a related nuisance worth knowing: if a stray
package-lock.json sits in the directory above your repo,
Next will happily infer that as the workspace root. Pinning removes the guesswork.
Do not name it __dirname in a TypeScript config
Next compiles next.config.ts to CommonJS, where
__dirname is already bound. The collision fails config loading with
a bare, unhelpful exports is not defined. Ours is named
appDir for exactly this reason β the .mjs
configs can and do use __dirname.
Moving existing apps into a monorepo: what actually breaks
The build is the easy part. What breaks is every piece of configuration that lives outside the repo and still points at the old shape. We hit all four of these, and three of them were invisible until someone tried to deploy.
| What breaks | How it shows up | Check |
|---|---|---|
| Stale Root Directory | Every deploy fails. Ours still said nextjs β the folder name from before the move β long after that path stopped existing. |
Read it back from the API rather than trusting the dashboard from memory: GET /v9/projects/<name> β rootDirectory. |
| Apps with no project at all | Two of our three had none. The restructure had been treated as done because the repo built. | Count projects against apps. vercel project ls versus ls apps/. |
| Orphaned domains | tracker.junctionnet.nl and quizhub.cloud both returned 404 from Vercel's edge β DNS pointing at the platform with nothing behind it. That is a live outage that no build, test or lint will ever catch. |
curl -sI https://<domain> on every domain you own. A 404 with server: Vercel means the DNS is right and the project attachment is missing. |
| Env vars that never moved | New projects start empty. With a hardcoded fallback in the source, the app comes up anyway and points at production. | Diff the keys each app reads against the keys its project has, per environment. |
A 404 from your own platform is the signature of this whole class of bug. DNS resolves, TLS terminates, the edge answers β and no project claims the hostname. Nothing in the repository can detect it, because the missing configuration isn't in the repository. Put a one-line curl over your domain list somewhere you will actually see it.
One repo is not one origin β and the browser only cares about origins
This is the lesson that cost us the most, so it gets its own section. A monorepo unifies the things your build sees: one lockfile, one import graph, one commit. It unifies nothing the browser sees. The browser's unit is the origin, and three apps on three hosts are three origins no matter how close together the source lives.
Our tracker and quizhub share one Supabase project β one database, one
auth.users. Server-side they had always agreed on who you were:
auth.uid() returns the same id in both. Yet signing in to one left
you signed out of the other, and it took reading both clients to see why:
// what both apps did β the supabase-js default
createClient(URL, KEY, { auth: { persistSession: true } })
// β session goes to localStorage
// β localStorage is keyed by ORIGIN and cannot be shared, ever,
// not even between two subdomains of the same site
The fix is to move the session into a cookie, because a cookie β unlike localStorage β can be scoped to a parent domain:
import { createBrowserClient } from '@supabase/ssr';
createBrowserClient(URL, KEY, {
cookieOptions: { domain: '.junctionnet.nl', path: '/', sameSite: 'lax', secure: true },
});
Cookie scope stops at the registrable domain, and that is a public-suffix rule rather than a setting. tracker.junctionnet.nl and quizhub.junctionnet.nl can share a session. quizhub.cloud can never share one with either, however you configure it. If a shared session is a requirement, it is a domain requirement β decide it before you buy the second domain, not after you have marketed it.
So "should these be one repo?" and "should these be one origin?" are separate questions with separate answers. Three ways to get one origin, cheapest first:
| Approach | What it costs |
|---|---|
| Subdomains of one registrable domain + a cookie session | Nearly nothing. Every app keeps its own stack, its own Vercel project, its own release. This is what we did. |
Multi-zone rewrites β one host proxying /app-a/* and /app-b/* to separate deployments | A router app and some basePath care. Gets you a literal single origin, so the cookie question disappears entirely. Vercel's Microfrontends is the managed version, and it is a paid feature. |
| Merge into one Next.js app | The expensive one. Ours would force tracker off Tailwind v3 and drop @tremor/react β the exact coupling the workspace isolation exists to avoid. Almost never worth it just to unify a session. |
Shared state means shared authorization β decide it deliberately
One auth.users means an account is implicitly a user of every app
that reads it. While the sessions were separate that was disguised; the moment one cookie spans
both, "they have an account" stops being an authorization check. Put per-app access in
app_metadata or a roles table before you unify the
session, not after.
The same trap has a cheaper cousin. Both our apps ship a hardcoded fallback for the publishable key:
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || 'sb_publishable_β¦'
It keeps local dev working with no .env, and the key genuinely is
safe to ship. But it also means a Vercel project with no environment variables at all
still connects to the production database β quietly, and looking healthy. We deployed two apps
that way and only noticed because the server-role key, which correctly has no fallback, failed
loudly. In a monorepo you now have N projects each needing their own env, so prefer a
loud failure to a convenient default for anything that selects an environment.
Sharing code across repos: package, submodule, or subtree
A monorepo shares code with an import. Across repositories you need a mechanism, and there are exactly three worth considering.
| Mechanism | What lands in the consumer | Best when |
|---|---|---|
| Published package npm / PyPI / CodeArtifact |
A versioned artifact, resolved at install time | The library is stable, semver means something, and you can afford a publish step on every change. The cleanest option when it fits. |
| Git submodule | A 40-character pointer to a commit in another repo. Files arrive only after git submodule update --init. |
You need the consumer to track an exact upstream commit and you genuinely want the two histories kept apart. In practice: rarely. |
| Git subtree | The actual files, committed into the consumer repo, plus history markers so future syncs can find their way. | Shared code that changes often, must be present in every clone, and needs upstream syncs to be reviewable as a normal diff. |
Inside a monorepo the mechanism is an import β the discipline is the dependencies
Within one repo, sharing is a workspace package and an import. The interesting constraint is not how to share but what a shared package is allowed to depend on β because a shared package's dependencies become every consumer's dependencies, and that is precisely how you undo the isolation you set up earlier.
Ours is one file. It exists because tracker's auth helper carried a comment saying it had been copied from quizhub's, and the two had drifted into near-identical duplicates:
packages/auth/ @junctionnet/auth
package.json dependencies: @supabase/ssr
peerDependencies: @supabase/supabase-js β the APP's copy
index.js createAuthClient() + makeAuthHelpers()
apps/tracker/lib/supabase-browser.js thin adapter, exports unchanged
apps/quizhub/lib/controllers/sb.js thin adapter, exports unchanged
Two rules make that safe, and both are easy to violate by accident:
- Take the client as a peer dependency, not a dependency. Anything stateful β a database client, a React instance, an auth SDK β must resolve to the app's single copy. Bundle your own and you get two instances that do not see each other's state, which presents as "I signed in and it didn't notice".
- No UI in the shared package. The moment it imports React or a Tailwind preset, every consumer must agree on those versions β and our two apps are on Tailwind v3 and v4 precisely because they cannot. A logic-only package keeps them independent.
Keep the adapters, too. Both apps still export exactly what they exported before, so the shared package was introduced without touching a single call site β which is what made it a ten-minute change rather than a refactor.
What submodules actually cost us
Every JunctionNet API repo mounted shared code as submodules β typically
src/common (python-lambda-common) and
infrastructure/common
(platform-infrastructure). Four concrete costs, all of which we
paid repeatedly:
- A fresh clone is broken. The root Makefile does
include infrastructure/common/makefiles/terraform.mk, so on an uninitialised clone evenmake installfails before it starts. Every new engineer hit this on day one. - Detached HEAD. Submodules check out detached, so local edits are silently lost.
- Unreviewable syncs. Consuming an upstream change is a 40-character pointer bump. The PR shows one line. Nobody can review what actually changed.
- Foot-guns. The old
git-submodulesMake target ranrm -rf src/commonbefore re-initialising.
What vendoring cost instead: about 950 KB of tracked content in documents-manager-api. That is the entire price of the trade, paid once, in exchange for a clone that works and a diff you can read.
Git subtree: the four commands you need
Subtree is plumbing built into git β no extra tooling, nothing for a new contributor to
install or know about. It grafts another repository's tree under a
--prefix directory in yours.
# 1. Vendor a repo under a prefix, first time only
$ git subtree add --prefix=src/common \
git@github.com:junctionnet/python-lambda-common.git <SHA> --squash
# 2. Pull upstream changes in later (the everyday command)
$ git subtree pull --prefix=src/common \
git@github.com:junctionnet/python-lambda-common.git main --squash
# 3. Push local changes back upstream β possible, but see below
$ git subtree push --prefix=src/common <remote> <branch>
# 4. Prove it worked: no gitlinks, no submodules
$ git ls-files -s | grep ^160000 # must print nothing
--squash collapses the upstream history into a single marker commit
instead of importing every commit from the other repo. Use it consistently β and understand
what it depends on, which is the subject of the next section.
Migrating a repo from submodules to subtrees
documents-manager-api was our pilot (JNET-2784). Work on a
JNET-XXXX-submodules-to-subtrees branch cut from
origin/main.
Preconditions β check all four before you start
- Squash-merge must be disabled. This is the one that cannot be fixed after the fact. Details below β read it before anything else.
- Enumerate the actual submodules. Repos differ; do not assume.
git config -f .gitmodules --get-regexp '^submodule\..*\.(path|url)$'. One of ours has a third submodule; another uses an HTTPS remote for the same upstream. - Confirm no CI job calls a Make target you are about to rename. Grep the
shared reusable workflows for
make <target>invocations. - Check for
.gitignorecollisions. The vendored files stop being governed by their ignore rules and fall under the host repo's, which can silently swallow them:$ git -C src/common ls-files | sed 's|^|src/common/|' > /tmp/subfiles $ git check-ignore --no-index --stdin < /tmp/subfiles # must print nothing
β The precondition that cannot be undone: squash-merge
git subtree pull --squash finds the previous sync point by locating
git-subtree-dir: and git-subtree-split:
trailers in the squash-marker commits. Squash-merging a PR flattens those commits out
of main, and every future pull then dies with:
Can't squash-merge: 'src/common' was never added.
Recovering means redoing the migration. Check it, and turn it off, before you migrate:
$ gh api repos/junctionnet/<repo> \
--jq '{squash: .allow_squash_merge, merge: .allow_merge_commit}'
The standing rule that follows: never squash-merge a PR containing a subtree add or subtree pull commit. If your org mandates squash-merge everywhere, subtrees with --squash are not available to you β find that out now, not on the third sync.
The procedure, five steps
Step 0 Β· Capture a baseline
This is what lets you prove the migration changed no content.
$ git ls-tree HEAD src/common infrastructure/common # record the pinned SHAs
$ mkdir -p /tmp/baseline/src-common
$ git -C src/common archive <SHA> | tar -x -C /tmp/baseline/src-common
Step 1 Β· Remove the submodules, in one commit
$ for p in src/common infrastructure/common; do
git rm --cached "$p"
rm -rf "$p" ".git/modules/$p"
done
$ git rm -f .gitmodules # only if ALL submodules are being migrated
$ git commit -m "JNET-XXXX: remove git submodules"
Step 2 Β· Add the subtrees, pinned to the old SHAs
Pin to the SHAs the submodules recorded, not main.
That keeps the PR a pure mechanism change with zero content drift, so the reviewer only has to
trust the mechanism. Move to upstream main in a separate follow-up
PR β one idea per review.
$ git subtree add --prefix=src/common \
git@github.com:junctionnet/python-lambda-common.git <SHA> --squash
$ git subtree add --prefix=infrastructure/common \
git@github.com:junctionnet/platform-infrastructure.git <SHA> --squash
Step 3 Β· Remove orphan nested gitlinks
See the next section β this one broke git outright.
Step 4 Β· Rewrite the Makefile
Delete the git-submodules target β it
rm -rfs a directory that is now tracked source β drop it from
.PHONY and from install-clean, and
replace git-pull with subtree pulls.
Step 5 Β· Update the docs
CLAUDE.md, AGENTS.md,
README.md, docs/CICD.md. Drop the
git submodule update --init --recursive clone step and point syncs
at make subtrees-pull. Keep the "do not edit these files
here" warning β it matters more now, because git no longer enforces it. A local edit
to vendored code is no longer rejected; it diverges silently until the next pull conflicts.
The nested-gitlink trap
git subtree does not recurse into nested submodules.
Our platform-infrastructure contains
infrastructure/github-templates as a submodule. It arrives as a bare
gitlink whose mapping in the vendored .gitmodules is relative to
platform-infrastructure's root, so it resolves to the wrong path in the host repo and
breaks git outright:
fatal: no submodule mapping found in .gitmodules for path
'infrastructure/common/infrastructure/github-templates'
That failure also hits the shared workflow's submodules: recursive
checkout, so it is not cosmetic β it fails CI. Find and drop the leftover gitlink:
$ git ls-files -s | grep ^160000
$ git rm --cached infrastructure/common/infrastructure/github-templates
$ rmdir infrastructure/common/infrastructure/github-templates
$ git submodule status # must exit 0 and print nothing
Leave the vendored infrastructure/common/.gitmodules file itself in
place β git only reads the top-level one, so it is inert, and keeping it avoids a spurious
conflict on the next subtree pull. Only drop a nested gitlink where it is genuinely unused;
in our case those templates are referenced solely by lambda-layer Makefiles the repo never runs.
Wiring the sync into the Makefile, with guards
Nobody should have to remember the subtree incantation. Put it behind
make, with the two guards that stop the common failures:
# Shared code is vendored with git subtree, not submodules.
COMMON_PREFIX := src/common
COMMON_REPO ?= git@github.com:junctionnet/python-lambda-common.git
INFRA_COMMON_PREFIX := infrastructure/common
INFRA_COMMON_REPO ?= git@github.com:junctionnet/platform-infrastructure.git
SUBTREE_BRANCH ?= main
check-clean-tree:
@if [ -n "$$(git status --porcelain)" ]; then \
echo "β Working tree is dirty β commit or stash before pulling subtrees"; exit 1; \
fi
@if [ -f .git/shallow ]; then \
echo "β Shallow clone β run 'git fetch --unshallow' before pulling subtrees"; exit 1; \
fi
subtree-pull-common: check-clean-tree
git subtree pull --prefix=$(COMMON_PREFIX) $(COMMON_REPO) $(SUBTREE_BRANCH) --squash
subtree-pull-infra: check-clean-tree
git subtree pull --prefix=$(INFRA_COMMON_PREFIX) $(INFRA_COMMON_REPO) $(SUBTREE_BRANCH) --squash
subtrees-pull: subtree-pull-common subtree-pull-infra
# Back-compat alias for muscle memory.
git-pull: subtrees-pull
Both guards are earned. git subtree pull merges into the working
tree, so it needs a clean one. And it walks history, so it fails on a shallow clone β which is
exactly what CI gives you by default.
Verifying the migration β seven checks
- Content identical.
diff -r /tmp/baseline/src-common src/commonmust be empty. This is the main guard that the PR is mechanism-only. - No submodules.
.gitmodulesgone,git submodule statussilent,git ls-files -s | grep ^160000empty. - A cold clone is self-sufficient. Clone the branch fresh and run
make -n installwith no submodule command. This is the regression that motivated the whole change. - Tests.
pytest. - Build.
sam build --template-file template.yaml --parallel, then confirmsrc/commonis present under.aws-sam/build/<Function>/src/common. - Round-trip the sync path. Run
make subtrees-pullon the branch; it must no-op or produce a clean diff. This proves the--squashmarkers are discoverable β the one thing that cannot be fixed later without redoing the migration. - Full CI. Open the PR and let it run end to end, including the shared
workflow's
submodules: recursivecheckout against a repo that no longer has any.
Living with subtrees: four rules
- Pull upstream with
make subtrees-pullon a clean tree, and review the diff like any other. - Do not push a fix upstream from the consumer. Change it in
python-lambda-common, merge there, then pull.git subtree pushworks but bypasses the upstream repo's review. - On long-lived branches that also pulled a subtree: prefer upstream on conflict, then re-pull.
- Never squash-merge a PR containing a subtree commit. Say it twice; it is the only unrecoverable mistake here.
Do not leave the old .gitmodules lying around anywhere. We still have a stale one at an umbrella working-copy level pointing src/common at python-lambda-common. It is inert where it sits, but a stale submodule declaration is exactly the kind of thing that sends someone down a two-hour path. Delete them as you migrate each repo.
Fifteen API repos: how the platform actually ships
The JunctionNet platform is the opposite shape to the apps monorepo, and for good reasons. Each service owns its own database schema, its own AWS stack and its own release cadence:
junctionnet/
admin-api catalogs-manager-api
audit-api documents-manager-api
order-entry-api connector-cargowise-api
invoice-organizer-bot-api connector-organization-api
blueprint-serverless-api β¦
portals-ui # Nx monorepo: 5 Vue portals
python-lambda-common # vendored into every API via subtree
platform-infrastructure # vendored into every API via subtree
.github # shared reusable workflows
Apply the question again. Does audit-api have to change in the same
commit as order-entry-api? No β they talk over EventBridge, not by
importing each other. Separate repos are correct. What they do share is the shared
library, the Terraform modules, and the pipeline β and each of those has its own mechanism:
subtree, subtree, and reusable workflows.
The shared-workflow pattern: one pipeline, many repos
The failure mode of polyrepo is that fifteen pipelines drift into fifteen dialects. The fix is
that no repo writes its own pipeline β each one is a thin caller of a workflow that lives in
junctionnet/.github:
# documents-manager-api/.github/workflows/ci.yml β the whole file
name: Ticket Deploy (Dev)
on:
pull_request:
branches: [ main ]
types: [ opened, synchronize ]
paths:
- 'src/**'
- 'alembic/**'
- 'infrastructure/**'
- 'template.yaml'
- 'Makefile'
jobs:
ci:
uses: junctionnet/.github/.github/workflows/api-ci.yml@main
secrets: inherit
Each repo keeps a handful of these β ci.yml,
api-main.yml, db-migrations.yml,
infrastructure.yml, release-tag-create.yml,
production-tech.yml, remove-env.yml β and
every one is a uses: line plus a path filter. Three consequences
worth naming:
- The contract is Make, not YAML. The reusable workflow invokes
make install,make deploy,make dev,make production-cloud,make alembic-db-migrations. A repo conforms by implementing those targets, and can do whatever it likes behind them. That is what makes fifteen repos feel like one platform. - Renaming a Make target is a breaking change across every repo. This is why "confirm no CI job calls a target you are about to rename" is a precondition of the subtree migration, not an afterthought.
- Change the shared workflow carefully, and never mid-rollout. Ours sets
submodules: recursiveon checkout. For a migrated repo that is a harmless no-op β so we left it, rather than break the repos that had not migrated yet.
The frontend counter-example: one Nx monorepo, five portals
The five Vue portals are the opposite call from the APIs, and it is the same question giving a different answer. They share a design system, component libraries and a router shell, so they do change together:
portals-ui/ # Nx Β· Vue 3 Β· PrimeVue Β· Vite Β· yarn
βββ apps/
β βββ admin-portal/ βββ organizer-portal/
β βββ entrywriter-portal/ βββ pricing-portal/
β βββ udm-portal/ βββ workspace-portal/
βββ libs/
βββ shared/ # design system + components
βββ utils/
One repo, one dependency graph β and crucially, each portal still ships on its own
clock. Release tags are per portal: organizer-v1.31.0,
admin-vX, entrywriter-vX,
udm-vX, workspace-vX. Never tag the
whole repo against one version.
Monorepo β monolithic release. This is the point people miss. Nx computes the affected graph and builds only what a change touched; per-portal tags mean the release train for one portal never blocks another. A monorepo with a single global version number gives you all the coupling and none of the benefit.
Choosing, in one table
| If⦠| Then | Our example |
|---|---|---|
| They share a database or a migration timeline | Same repo. Non-negotiable. | tracker + quizhub |
| They share a design system and ship as a family | Monorepo with per-app release tags and affected-only builds | portals-ui (Nx) |
| They only share a deploy story, nothing technical | Same repo is fine β but be honest that it is convenience | junctionnet-nl |
| They communicate over events and own their own data | Separate repos, one shared pipeline | the ~15 API repos |
| They share a library that changes weekly | Separate repos + git subtree | python-lambda-common |
| They share a library that is stable and versioned | Publish a package; do not vendor | anything with real semver |
| Different auth, different cloud, different team | Separate repo. Do not force the marriage. | redlie (Cognito, no shared DB) |
Best practices, condensed
- Let the data decide. Shared schema β shared repo. Shared nothing β separate repos. Everything else is preference, and preference should be cheap to reverse.
- One deployable, one project. On Vercel that means one project per app with its own Root Directory, its own env vars and its own rollback history.
- Confirm the platform features you are designing around actually exist for your plan and your repo's ownership. Push-to-deploy, previews and affected-only builds all turned out to be unavailable to us, and every one of them was load-bearing in the first draft of this workshop.
- Treat a restructure as a config migration, not a code migration. The build passing proves nothing about root directories, domain attachments or environment variables β none of which live in the repo. Curl your domains.
- Separate "one repo" from "one origin". A monorepo unifies what the build sees; the browser only cares about origins. If apps must share a session, that is a domain decision, and cookie scope stops dead at the registrable domain.
- Shared packages take stateful libraries as peers and contain no UI. Otherwise the package quietly re-couples the versions you deliberately kept apart.
- Fail loudly on missing configuration. A hardcoded fallback that makes an unconfigured deploy talk to production is worse than a crash, because it looks like it worked.
- Never let a monorepo imply a global version. Tag per deployable; build only what changed.
- Pin the workspace root, but guard the pin. Probe for a file that only exists at the root, so a subdirectory upload degrades gracefully instead of failing after a green build.
- Centralise pipelines, not repos. Reusable workflows give you polyrepo independence with monorepo consistency. Make the contract a Make target, not a YAML file.
- Prefer a published package; vendor with subtree when you cannot. Reach for submodules only when you actively want the histories apart and the extra clone step.
- Put every multi-step operation behind
make, with the guards baked in.make subtrees-pullis one word; the raw command is four flags and two failure modes. - Write the constraints down in the repo, next to the thing they constrain.
Every non-obvious decision here β the un-hoisted deps, the
existsSyncguard, the shared migration directory β exists because someone hit the failure and left a comment.
Next: you now know where the code lives and how it reaches a platform. Part 4 goes underneath it β the AWS base layer (VPC, S3, EventBridge, IAM, RDS) that the backend parts plug into, and which pieces belong in Terraform versus per-service SAM.