The claim
Splitting a product line into repositories is a permissions decision dressed as an architecture decision. The thing only separate repositories buy is that some people cannot read some code. If nobody needs that today, a split pays for it anyway, in the currency a product line can least afford: the compile-time safety of a shared contract. One repository with several solution files keeps that safety, puts the boundaries in a matrix a test enforces, and records the trigger that would make the split worth its price.
Context
Hexavera is one product line: a cloud, an on-premises access-control runtime, an identity server, a computer-vision service, a website, an assistant, and the shared kernel they compose. In August 2026 the platform architecture plan decomposed it into ten repositories sharing code through NuGet packages on a private feed. It did that to satisfy one requirement: some developers must not be able to see some logic. In Azure Repos only separate repositories satisfy it. Permissions are set "at the All Repositories level to apply them to every Git repository in a project", or "for an individual repository", and branches "inherit a subset of permissions from assignments made at the repository level". The documentation offers no read permission narrower than a repository; the branch permissions it lists govern pushing, policies and locks, not reading.
The question the August requirement had not asked came up on 2026-09-24: is there anyone, by name, who must not read a part of this code today? There was not. The ten-repository layout was paying for a property nobody needed yet, and the plan's own residual-risk note said what it would cost: with most commits spanning two or more tiers, extracting repositories "turns routine changes into multi-repo version dances".
Two measurements decided it
Before the decision, the transitive project-reference closure of every deployable was computed from the project files on disk, 51 of them at the time:
| Product | Deployable roots | Closure |
|---|---|---|
| Access (the site) | 4 | 21 projects, none of them the cloud's domain, data layer or API |
| Cloud | 8 | 28 |
| The assistant | 2 | 17, including the cloud's domain, data layer and attendance processing |
| Vision | 2 | 8 |
| Website | 1 | 2 |
The access product was already separable from the cloud. The assistant was not: its cloud service referenced the cloud's domain, which brought the data layer with it. Any layout had to hold both facts at once.
The decision
One repository with several .slnx solution files. Each solution lists its own projects and the shared projects
it needs, by path. Directory.Build.props, Directory.Packages.props, Directory.Build.targets and global.json
stay single, so every solution builds with the same analyzers, package versions and SDK.
| Solution | Holds |
|---|---|
| Platform | The shared kernel: contracts, shared domain and data types, logging, service defaults, the rules engine, and their unit tests |
| Access | The site runtime, the console, the device service and connectors, and their tests |
| Cloud | The cloud API, application, workers, MCP servers, the cloud domain and data layer, and the assistant, which cannot leave yet |
| Identity | The identity server and its client library (joined in October) |
| Vision | The vision worker, its operator interface and tests |
| Website | The marketing site |
| The full solution | Everything. The only build that proves a change to a shared project broke no product. It stays the CI gate |
Folders mirror the solutions: Hexavera.<Product>/ holds the solution file with src/ and test/ beside it. The
move was done once, with every documentation path rewritten and a check that fails on a link to a path that no
longer exists. Today (2026-10-16) the full solution lists 81 projects with tests; the Cloud solution 47, Access 31,
Platform 19, Identity 15, Vision 9, Website 1.
The .slnx format made this cheap. It is "XML-based and is much more concise than the old format", the dotnet CLI
has built it since .NET SDK 9.0.200 (February 2025), and dotnet sln migrate converts an existing .sln. A solution
file is small enough to carry its own rule:
<Solution>
<!--
The shared platform: what every product composes and what composes no product.
The architecture decision record defines which solution may reference which; the full solution stays the CI gate.
Nothing listed here may reference a project that is only in a product solution.
-->
<Folder Name="/Solution Items/">
<File Path="../Directory.Build.props" />
<File Path="../Directory.Packages.props" />
</Folder>
<Project Path="src/Tridesetri.Hexavera.Shared/Tridesetri.Hexavera.Shared.csproj" />
</Solution>
The reference matrix, and the test that holds it
| From \ To | Platform | Shared UI | Access | Cloud | Vision |
|---|---|---|---|---|---|
| Platform | yes | no | no | no | no |
| Shared UI | yes | yes | no | no | no |
| Access | yes | yes | yes | no | no |
| Cloud (with the assistant) | yes | yes | no | yes | no |
| Vision | yes | no | no | no | yes |
A matrix in a document is a wish. The test that makes it a rule has five properties, each of them learned from a guard that had failed quietly before:
- It reads source, not reflection. Every
src/**/*.csprojis read from disk and itsProjectReferenceitems resolved by file name. A reflection-based architecture test sees only the assemblies the test project happens to reference, and its rules pass vacuously for every assembly it does not load. The project nobody remembered to reference is exactly the one a reflection guard never checks. - Groups come from the solution files. A project listed in the Platform solution is Platform; the shared UI projects, the identity client projects and the orchestration host are named in the test; every other project must be listed in exactly one product solution, and one in no solution, or in two, fails, because its group would be a guess.
- It fails under a recorded floor. The scan prints how many projects it read and fails below a number that is raised when projects are added and never lowered to make a failure go away. A guard that silently reads less than it should reports green over nothing.
- Every exemption must still be needed. An exclusion that matches nothing fails, so the list only shrinks.
- It is shown to fail. One fact feeds the check a forbidden edge and expects a failure. A guard that has never been seen to fail is not known to work.
[Fact]
public void No_project_references_a_group_it_may_not()
{
var projects = ReadProjects(RepositoryRoot()); // every src/**/*.csproj, from disk
projects.Count.ShouldBeGreaterThanOrEqualTo(MinimumProjectsRead);
var forbidden = projects
.SelectMany(project => project.References.Select(target => (project, target)))
.Where(edge => !Allowed[GroupOf(edge.project)].Contains(GroupOf(edge.target)))
.ToList();
forbidden.ShouldBeEmpty();
}
One refinement arrived with the identity server: a product may reference the identity client contracts, never the identity host or its data layer, and the matrix test has a fact for that edge too.
Options not taken
| Option | Why not now |
|---|---|
| Several repositories, shared code as NuGet packages | Compile-time contract breakage becomes version drift. Safe only with public API analyzers, one contract package per wire, and a job that detects drift; and today's coupling would make the assistant's repository depend on the cloud's domain package |
| Several repositories joined by git submodules and project references | Whoever can read a product repository reads the platform source anyway, so the need-to-know requirement is only partly met, and submodules add a second versioning mechanism |
One solution with .slnf filters |
Filters are views of one solution, not boundaries; and .slnf over .slnx is an open issue in the solution persistence library (filed 2025-01-07, still open on 2026-10-16) |
The convention that makes sharing safe
What makes one repository worth keeping is a rule written down a month earlier: one interface, several implementations, one for each side of the wire. A capability is declared once in a shared contracts assembly; the server implements it against the database; every client implements the same interface over HTTP. "The point is not code reuse. It is that a change to the contract fails the build on every consumer, immediately, at the place that has to change." A repository split sells that property back to you as a package version number, and a drift that shows up at runtime, in the field, in front of an operator.
What it cost
- The need-to-know requirement is not met. Everyone who can read the repository reads all of it. Accepted, dated, and given a trigger: when a named person or partner must be kept from a boundary, take the package route for that boundary only, starting with the one whose coupling has fallen furthest.
- Compile-time safety holds only while CI builds the full solution. Per-solution pipelines with path filters may be added for faster feedback after the full build has run green, never instead of it.
- Two test projects crossed products. The integration tests were split on 2026-09-25 into two suites, one per product solution; the architecture suite stays in the full solution only.
- A shared UI group that two products reference and that belongs to neither. It is listed by both and allowed by the matrix.
What a team can take from this
Ask who must not read what, by name. If the answer is nobody, do not split the repository. Split the solutions, write the matrix, make a test enforce it from source, keep the full build as the gate, and record the event that would change the decision. Measure the reference closures before you decide what is separable: the assistant's coupling to the cloud's domain is what kept it in the Cloud solution, and no amount of intent changes a closure.
Principal software engineer, Belgrade
Designs and builds secure, multi-tenant .NET platforms. Questions about this entry, or a different view on it, are welcome by email.