← Lessons |

The Monorepo as an Upgrade-Forcing Function

Why Apps Fall Behind

The failure mode is predictable. You have a shared library — authentication, a component library, a framework layer. Multiple apps depend on it. The library gets updated. Updating each dependent app takes work. That work gets deferred. One quarter becomes two. Now you have apps running on stale versions of shared code, and updating them is a multi-day project because the gap is too large.

This isn’t a discipline problem. It’s a structural one. When updating is optional, it gets treated as optional. The upgrade happens when it becomes painful enough to force it, which is usually the worst possible time.

The monorepo solves this by making “optional” impossible. When the shared library and every app that uses it live in the same repository, a change to the library is atomic — one commit updates the library and the apps simultaneously. There is no window in which apps can fall behind. The code in the repo is always consistent.

How UbixCore Is Structured

UbixCore is a single git repository containing every service in the product suite: PHP REST APIs, SvelteKit frontend apps, shared PHP framework code, a shared Svelte component library, the CLI tool, deployment manifests, and database schema.

The two shared libraries are the load-bearing pieces:

php/Vsm/ — the PHP framework. Controllers, services, repositories, validation, custom PHPCS sniffs. Every PHP API and web app in the repo depends on it. When a framework convention changes — a new validation pattern, a refactored service contract, a new sniff rule — the change lands alongside the update to every consuming app. There’s no version negotiation, no compatibility shim, no app that silently keeps running on the old behavior.

js/Vsm/ — a Svelte component library wired through npm workspaces. Every SvelteKit app in the repo declares "vsm": "*" and imports from it directly. npm install at the repo root creates a symlink from node_modules/vsm to js/Vsm across all apps in a single pass. No per-app install step, no versioned package to publish, no apps pinned to old component versions.

Components shared across more than one JS app belong in js/Vsm/. App-specific components stay in that app’s src/lib/. The rule is enforced by convention, not tooling — but once you’ve felt the alternative (duplicated components drifting apart), the convention needs no enforcement.

The APP_NAME Pattern

On the PHP side, UbixCore takes the monorepo principle further: a single PHP container image runs every API. The shared public/index.php entrypoint reads an APP_NAME environment variable at boot and loads the corresponding app:

APP_NAME=AffiliateApi  → app/AffiliateApi/src/{Dependencies,Middleware,Routes,Theme}.php
APP_NAME=FanClubApi    → app/FanClubApi/src/{Dependencies,Middleware,Routes,Theme}.php
APP_NAME=ProductApi    → app/ProductApi/src/{Dependencies,Middleware,Routes,Theme}.php

One Docker build produces one image. N pods run it with different APP_NAME values. Adding a new PHP API means creating the app directory and wiring the deploy pipeline — there’s no new Dockerfile, no new build step, no new container to maintain. The operational surface stays flat as the app count grows.

Adding a new app to the monorepo is intentionally low-friction: add the directory with the right suffix, follow the conventions, and the pipeline handles the rest. The suffix (*Api, *Js, *Web) tells the build system what runtime to use without per-app configuration switches.

CalVer as a Release Marker

UbixCore uses CalVer (YYYY.MM.MICRO) for monorepo-wide releases. The version format is a date-based release identifier: 2026.03.1 is the first release cut in March 2026, 2026.03.2 is the second.

The semantics matter. This is not a per-app API stability contract. A tag v2026.03.1 means “the repo looked like this on that date” — it doesn’t imply that every app’s public API is stable or that all apps are at the same functional version. Per-app compatibility belongs in that app’s CHANGELOG entries, not in the monorepo version number.

Each app still deploys independently. A bug fix in one API does not require redeploying every other app. The monorepo is the source of truth; the deploy pipeline releases one app at a time. The combination — atomic commits, independent deploys — is what makes the model work at scale. You get the consistency of a monolith’s shared codebase and the flexibility of per-service deployment.

What This Actually Costs

The monorepo isn’t free. The shared libraries are monorepo-wide events when they change — a framework refactor touches every app, not just one. That’s the point, but it also means framework changes need to be right before they land. Sloppy changes to php/Vsm/ break every app simultaneously.

The discipline required: treat shared library changes with more scrutiny than app-specific changes. Write the migration path before landing the break. Test against every consuming app before merging. The cost of this discipline is real, but it’s paid once per shared-library change — not multiplied by the number of apps falling behind on divergent codebases.

The alternative — polyrepo with version negotiation — has lower per-change cost and much higher long-term operational cost. The accumulation of deferred upgrades is the debt that compounds. The monorepo makes the per-change cost explicit and upfront. That tradeoff is the right one for a shared-library architecture.

Tags: Monorepo Architecture UbixCore SvelteKit PHP DevOps