Posted on ::

Headcode started as one repository called headcode.dev/. That was a sensible place to start. The API, dashboard, ingestion workers, deployment files, documentation, and homepage could all be changed in one working copy.

This is the next stage of the project I described in Headcode started as a project to learn UK rail data. I am now splitting it into several independent repositories under one parent directory. I call that directory a workspace, although it is not a Jujutsu jj workspace. The extraction is about halfway done, with the most complicated runtime pieces still in the original repository.

This is not a claim that polyrepos are always better. It is a response to how I work on Headcode and other projects that span multiple repositories with coding agents: using Jujutsu (jj) for small, focused changes, and keeping repository boundaries aligned with task boundaries.

The workspace

The workspace lives at ~/code/aranw/headcode. It currently looks like this:

RepositoryWhat it contains
contracts/CUE schemas, OpenAPI definitions, ConnectRPC protocols, and Go spec validators
docs/Astro/Starlight documentation, architecture notes, PRDs, and research
headcode-home/The marketing homepage and its standalone build
headcode-plans/Agent work plans, dependency graphs, and execution records
headcode-platform/Go API server, dashboard, accounts, billing, and API keys
ops/Terraform, Caddy, systemd units, secrets, and deployment scripts
headcode.dev/The original application and the remaining data pipeline

Each row is a real repository. I can check that rather than relying on the directory name:

jj -R contracts root
jj -R headcode-platform root
jj -R ops root

Each command reports that repository's own directory as its root. The workspace directory itself is not a repository containing all the others.

Why split it?

The first reason is Jujutsu. I use jj for version control, and I like having small repositories with a clear working copy and focused history. The monorepo made it too easy for a simple change to include unrelated generated files or deployment configuration.

The second reason is agent-driven development. I often give an agent a bounded task such as updating an API contract, fixing a dashboard handler, or changing a deployment script. An isolated repository gives it less unrelated context and fewer paths it can accidentally touch. It also makes review simpler: what belongs here, and what does not?

Each repository can have its own CI and build commands. headcode-platform/ has a Go justfile for generation, scoped tests, and building the server. headcode-home/ has a smaller one for Zola checks and builds. ops/ can validate shell scripts and Terraform without compiling an application.

The workspace root currently provides a shared Nix development environment with Go tooling, buf, hurl, age, jq, yq, and Node.js, plus local database defaults. I use direnv at the root so child repositories inherit the environment. Those root-level environment files are currently untracked and local-only. That is deliberate: they are tailored to how I work across these projects, and I am not trying to make the setup shareable yet. If I ever share it, I will need to decide how to version and distribute the setup rather than assuming that my local configuration is a suitable starting point for somebody else. Shared tools are useful. Shared ownership of every file is not always useful.

The extraction rules

I wrote down these rules to stop myself tidying too aggressively:

  • Copy and recreate the component in its destination repository.
  • Make the destination self-contained with its own dependencies and justfile.
  • Update consumers to use the new repository and its paths.
  • Keep headcode.dev/ intact until consumers have moved and the replacement works.

The last line matters most. For a while, extraction means keeping two copies while consumers move across.

For example, contracts/ was extracted from headcode.dev/api/. It now holds the CUE sources and generated contract documents, but the old api/ directory remains in both headcode.dev/ and headcode-platform/ as transitional input. The final wiring that makes the implementation repositories consume versioned contract artefacts is not finished yet. I am still figuring out whether consumers should use tagged releases from contracts/, a Go module for the validators, or published generated artefacts. If the repository remains private, those downloads will also need GitHub authentication.

That duplication is uncomfortable. It is also safer than deleting the old input while the consumers still depend on it.

What has moved

The easier extractions were the pieces with a clear product or operational boundary.

The homepage became headcode-home/, with its own Zola source, templates, build output, and commands. The documentation site moved to docs/, where Astro and Starlight can be developed without pulling the application repository into the same build. You can browse the resulting Headcode API documentation separately.

The platform code moved to headcode-platform/. This is the customer-facing Go application: the HTTP API, dashboard, accounts, Stripe billing, API keys, database migrations, and related handlers. Its justfile handles generation, scoped tests, and Linux builds.

The deployment material moved to ops/. Terraform, cloud-init, Caddy, systemd services, and deployment scripts belong together because they describe how the running system is operated. This is also where the encrypted deployment secrets live, as I described in an earlier Headcode post.

What is still in the monorepo?

The difficult part is the data pipeline.

headcode.dev/ still contains the pipeline that consumes Darwin, the real-time push feed for the UK rail network that I wrote about in consuming a real-time feed reliably. That includes the Kafka consumer, message normalisation, schedule matching, and database persistence. It also contains the performance service and its historical rollups, background ingesters for reference data, the Darwin archive path to Google Cloud Storage, and the related command runners and systemd services. The old deployment scripts are still there too, while ops/ is the extracted home being wired up.

These components share a database and depend on each other in ways that are less tidy than a static site or an API contract. Splitting them means deciding where the data model belongs and how separate workers are built and restarted.

I am not deleting them from headcode.dev/ while those questions are open. The repository remains the working source of truth for the pipeline. Deleting the old code because a copy exists elsewhere would create a half-migration with no reliable rollback.

That is deliberate. The extraction has not finished.

The costs are real

The obvious cost is cross-repository change. A change to an API shape may involve contracts/, headcode-platform/, docs/, and ops/. In a monorepo I could make that change in one commit and run one top-level check. Now I need a sequence of changes, and sometimes a temporary compatibility layer while each repository catches up.

For example, adding a field to an API response means changing and versioning the contract, updating the platform consumer, regenerating the documentation, and checking whether the deployment configuration needs to change. Those updates can no longer travel as one atomic commit.

The less obvious cost is fragmentation. A monorepo makes the whole system easy to discover. Search once and you can find the API, its consumer, the migration, deployment, and documentation in one history. Cross-cutting changes feel like one operation.

A polyrepo breaks that proximity. The contract may be in contracts/, its implementation in headcode-platform/, deployment in ops/, and the explanation in docs/. The boundaries are clearer, but the map is worse. New contributors and agents need to know where to start, and a checkout no longer tells them whether a sibling contains the dependency they need.

A conversation with a colleague who used to work at Google made me think about this differently. Engineers who have worked in a well-supported monorepo often like it. Everything is close together, but the repository is only part of the arrangement: the useful experience depends on tooling for builds, dependency boundaries, ownership, search, testing, and finding which projects a change affects.

I do not have that tooling for Headcode. Splitting the workspace is partly a response to that gap. It gives me smaller repositories and clearer contexts for coding agents, at the cost of losing atomic changes across the whole system. Maybe it is a coping mechanism for not having a proper monorepo setup. More charitably, it is a stopgap.

I compensate with READMEs, workspace documentation, versioned contract artefacts, and repository-local checks. These help, but fragmentation remains. Sometimes a monorepo is better documentation simply because related things are close together.

History is disjoint too. Because I used a copy-first extraction, the new repositories do not retain one continuous file history. I could have migrated selected history into each repository, but that would have made the split more involved. Commit messages and documentation can preserve context, but the split remains visible.

I also made a practical mistake early on: I treated copying a directory as the extraction. It was not. Old justfiles contained paths such as docs/home and deploy/terraform, and the copied projects carried assumptions about their neighbours. A repository needs its own build, dependencies, configuration, and consumer paths.

The rule I now repeat to myself is: do not assume sibling paths. If headcode-platform/ needs something from contracts/, that dependency needs to be explicit. A command that only works because it was run from the old monorepo is unfinished.

Next steps and reflections

Next I need to extract the remaining Darwin and ingestion services without losing a working deployment: the Kafka consumers, normalisation workers, archive and background ingesters, and performance rollups. I also need to replace the local transitional contract copies with the independent repository.

If I were starting this extraction again, I would define repository boundaries and contract versions before copying code. I would also write the self-contained checks earlier, rather than discovering old path assumptions afterwards.

The current shape is already better for the way I work. The homepage can be built on its own, and ops/ can validate Terraform independently. An agent working on billing does not need to understand the Darwin Kafka pipeline before making a small change.

I have started using the same pattern at work, where a shared frontend sits alongside several backend services that we call workbenches. The larger workspace makes the same trade-off more obvious: agents get a narrower domain to work in, but I need better tooling to see what is happening across all the repositories. I wrote a /sync-workspace skill that syncs each jj repository and reports the changes. It does not make cross-repository changes atomic, but it makes the workspace easier to manage.

Back in Headcode, keeping headcode.dev/ intact is the right kind of unfinished: the boundaries are moving, but the running system has not been asked to pretend that the extraction is complete.