Skip to main content
Dejan Demonjić brand logoDejan Demonjić
Decision log
Work
About
Contact

Decision log / Decision record

One repository, several solutions

Splitting a product line into repositories is a permissions decision wearing an architecture costume. Azure Repos permissions stop at the repository, so if nobody has to be kept from reading part of the code, one repository with several solution files keeps the compile-time safety that packages trade for version drift. The decision record, the reference matrix, and the test that holds it.

Filed
2026-10-16
Topic
Multi-tenant platforms
Reading
9 min
Tags
#architecture#dotnet#azure-devops#testing

Part 3 of 3 Multi-tenant platforms

  1. The tenant comes from the token, not from the header
  2. A schema per tenant: the database owns the name
  3. One repository, several solutions
Previous A schema per tenant: the database owns the name

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:

  1. It reads source, not reflection. Every src/**/*.csproj is read from disk and its ProjectReference items 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.
  2. 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.
  3. 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.
  4. Every exemption must still be needed. An exclusion that matches nothing fails, so the list only shrinks.
  5. 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.

DD

Dejan Demonjić

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.

  • Email
  • LinkedIn
  • GitHub
Previous The quality gate that could not see async codeA gate is a measurement, and a measurement has blind spots you must find before you trust it. Our CRAP gate took complexity from source and coverage from a Cobertura report, and the two never met for an async method, because the compiler had moved its body into a state machine. Of 1,946 async methods, two showed any coverage. What that meant for the gate, why the measurement was left unfixed, and the two rules adopted instead.Next A Content Security Policy for an identity serverThe strictest policy a sign-in page can carry, which directive is left out and why, what base-uri 'none' does to a Blazor page, and why the policy is chosen from the endpoint. A design note from building Hexavera's identity host.

In this entry

  1. The claim
  2. Context
  3. Two measurements decided it
  4. The decision
  5. The reference matrix, and the test that holds it
  6. Options not taken
  7. The convention that makes sharing safe
  8. What it cost
  9. What a team can take from this
Dejan Demonjić

Designing and building secure, multi-tenant .NET platforms, and writing down the decisions behind them.

Read

  • Decision log
  • Work
  • RSS feed

Contact

  • dejan@demonjic.com
  • Write to me
  • About

Elsewhere

© Dejan Demonjić. Written in Belgrade.