← Workshops Β· Part 3 Β· Repo topology

One Repo or Many? Monorepos, Subtrees & Shipping Many Services

You have an app and somewhere to deploy it. The next decision is structural: how many repositories, what lives together, how shared code travels between them, and how each piece reaches production. All three answers, worked through on our own codebase.

πŸ“ Test yourself ↓
20 min readRepo topologyRead or present
The question

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.

ShapeUse whenWhat 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.

The monorepo

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:

trackerquizhubjunctionnet-nl
Next.js151516
Tailwindv3 (tailwind.config.js)v4 (CSS-first)v4 (CSS-first)
ChartsRecharts v2 + @tremor/react v3Recharts v3β€”
LanguageJavaScriptJavaScriptTypeScript

@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?

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.

Vercel

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 projectRoot DirectoryDomain
trackerapps/trackertracker.junctionnet.nl
quizhubapps/quizhubquizhub
junctionnet-nlapps/junctionnet-nljunctionnet.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.

  1. The CLI resolves Root Directory relative to its own working directory, not to the upload. With Root Directory set to apps/tracker, running vercel --cwd apps/tracker makes it look for apps/tracker/apps/tracker and die before it uploads anything:
    Error: The provided path β€œ~/GitHub/jnet-apps-factory/apps/junctionnet-nl/nextjs”
    does not exist.
    The path in that message is <cwd>/<rootDirectory> concatenated. Read it that way and the fix is obvious: stay at the root and let Root Directory do its job.
  2. The build needs the workspace root anyway. pnpm's real dependency tree lives in <root>/node_modules/.pnpm with symlinks into it, and the outputFileTracingRoot guard in the next section keys off <root>/pnpm-workspace.yaml existing.
  3. 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 loseConsequence
Push-to-deployThe 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 PRNo automatic per-branch URL. You deploy previews by hand or not at all.
Ignored Build StepNeeds 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 --repoThe 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 breaksHow it shows upCheck
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.

What a monorepo doesn't give you

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:

ApproachWhat it costs
Subdomains of one registrable domain + a cookie sessionNearly 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 deploymentsA 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 appThe 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

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.

MechanismWhat lands in the consumerBest 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:

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:

πŸ’Ύ

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.

Migration

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

  1. Squash-merge must be disabled. This is the one that cannot be fixed after the fact. Details below β€” read it before anything else.
  2. 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.
  3. Confirm no CI job calls a Make target you are about to rename. Grep the shared reusable workflows for make <target> invocations.
  4. Check for .gitignore collisions. 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

  1. Content identical. diff -r /tmp/baseline/src-common src/common must be empty. This is the main guard that the PR is mechanism-only.
  2. No submodules. .gitmodules gone, git submodule status silent, git ls-files -s | grep ^160000 empty.
  3. A cold clone is self-sufficient. Clone the branch fresh and run make -n install with no submodule command. This is the regression that motivated the whole change.
  4. Tests. pytest.
  5. Build. sam build --template-file template.yaml --parallel, then confirm src/common is present under .aws-sam/build/<Function>/src/common.
  6. Round-trip the sync path. Run make subtrees-pull on the branch; it must no-op or produce a clean diff. This proves the --squash markers are discoverable β€” the one thing that cannot be fixed later without redoing the migration.
  7. Full CI. Open the PR and let it run end to end, including the shared workflow's submodules: recursive checkout against a repo that no longer has any.

Living with subtrees: four rules

⚠️

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.

Many services

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 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…ThenOur example
They share a database or a migration timelineSame repo. Non-negotiable.tracker + quizhub
They share a design system and ship as a familyMonorepo with per-app release tags and affected-only buildsportals-ui (Nx)
They only share a deploy story, nothing technicalSame repo is fine β€” but be honest that it is conveniencejunctionnet-nl
They communicate over events and own their own dataSeparate repos, one shared pipelinethe ~15 API repos
They share a library that changes weeklySeparate repos + git subtreepython-lambda-common
They share a library that is stable and versionedPublish a package; do not vendoranything with real semver
Different auth, different cloud, different teamSeparate repo. Do not force the marriage.redlie (Cognito, no shared DB)

Best practices, condensed

Check yourself

Quiz β€” 8 questions

Answer every question, then submit to see your score and the correct answers.

Untangling a repo layout?

Splitting a monolith, merging repos that should never have been apart, or stuck between submodules and subtrees β€” happy to look at the actual graph with you and write the runbook.

βœ‰οΈ Get in touch ← Back to workshops