Twelve Projects and One Star
Reading a repository one project at a time hides everything that lives in the edges between them - and in this estate the edges are where the interesting failures are.
Open a repository, open the biggest source file, and start reading. It is what everybody does, and for a single-project library it is usually enough. It stops being enough the moment a repository contains more than one deployable, because the most expensive defects in a multi-project estate are not inside any file — they live in the edges: which project references which, in which build configuration, under which pipeline, packed into which artefact.
Ntrada — the open-source, configuration-driven API gateway by Piotr Gankiewicz and DevMentors at github.com/snatch-dev/Ntrada — is 110 C# files and 4,511 lines, which is small. It is also twelve projects, and the twelve are arranged in a shape that decides things no individual file admits to. This series reads the shape.
To be clear up front: I did not write Ntrada. I am a source-reader working through someone else's MIT-licensed repository, and the register matters because much of what follows is criticism. Where the implementation, the .csproj, the Dockerfile or the git history is the story I will quote it, attributed; everything else is fresh illustrative code. This is a 2018–2020 codebase maintained by one person at a teaching organisation, and I will judge it against what was idiomatic then, not against what a 2026 template scaffolds for you.
Twelve projects, and where each one sits
Here is the whole estate, taken from Ntrada.sln and the twelve .csproj files:
| Tier | Projects |
|---|---|
| Core | src/Ntrada |
| Extensions | Ntrada.Extensions. Cors, CustomErrors, Jwt, RabbitMq, Swagger, Tracing |
| Composition roots | src/Ntrada.Host, samples/Ntrada.Samples.Api |
| Tests | tests/Ntrada.Tests.Unit, tests/Ntrada.Tests.Integration |
| Fixture | samples/Ntrada.Samples.Services.Orders |
One core. Six leaves. Two hosts. Two test projects. One downstream service that exists so the gateway has something to gateway to.
Now the edges, which are the actual subject. Every one of the six extensions references exactly one thing — the core — and no extension references any other extension. That is the star in the title: a strict hub-and-spoke, no lateral coupling, no diamond, no cycle. In a plugin estate this is the good outcome and it did not happen by luck; it happened because the plugin contract, which part 2 takes apart, gives an extension no way to name another extension.
The two hosts diverge, and the divergence is the first thing an estate read finds that a file read cannot. Ntrada.Host.csproj references five extensions:
<ItemGroup>
<ProjectReference Include="..\Ntrada\Ntrada.csproj" />
<ProjectReference Include="..\..\extensions\Ntrada.Extensions.Cors\Ntrada.Extensions.Cors.csproj" />
<ProjectReference Include="..\..\extensions\Ntrada.Extensions.CustomErrors\Ntrada.Extensions.CustomErrors.csproj" />
<ProjectReference Include="..\..\extensions\Ntrada.Extensions.Jwt\Ntrada.Extensions.Jwt.csproj" />
<ProjectReference Include="..\..\extensions\Ntrada.Extensions.RabbitMq\Ntrada.Extensions.RabbitMq.csproj" />
<ProjectReference Include="..\..\extensions\Ntrada.Extensions.Tracing\Ntrada.Extensions.Tracing.csproj" />
</ItemGroup>
— src\Ntrada.Host\Ntrada.Host.csproj:9-16
Ntrada.Samples.Api.csproj:8-16 references six: the same five plus Swagger. And Ntrada.Host is the project the Dockerfile publishes. So the container the README leads with ships five of the six extensions, and the sixth — Swagger, the last feature the author ever wrote — cannot be enabled in it at all. Not with a config flag, not with an environment variable, not with a bind mount. It is not in the image.
A missing project reference is invisible in every file you would think to open. Nothing in SwaggerExtension.cs says “I am absent from the container.” Nothing in the Dockerfile says “five of six.” The fact exists only as an absent line in a .csproj and is discoverable only by comparing two composition roots side by side.
The node with no edges
samples/Ntrada.Samples.Services.Orders references nothing in the estate. Its entire .csproj is a target framework and one package:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="3.1.0" />
</ItemGroup>
</Project>
— samples\Ntrada.Samples.Services.Orders\Ntrada.Samples.Services.Orders.csproj
That isolation is correct and deliberate. A gateway's test fixture must not know it is behind a gateway; the moment it takes a dependency on Ntrada, the sample stops demonstrating that Ntrada works with services that have never heard of it. The unconnected node in the graph is a design statement, and it is one of several places in this estate where the structure is better than the surrounding execution.
It also creates an operational trap that is pure estate-lens material. The orders service is expected on port 5001 (samples\Ntrada.Samples.Api\ntrada.yml:133, localUrl: localhost:5001). Ntrada.Host has no launchSettings.json at all, so dotnet run binds Kestrel's .NET Core 3.1 defaults — 5000 for HTTP and 5001 for HTTPS. Run the documented quick-start host and the documented downstream service together and they fight over the same port. Neither project file is wrong. The collision exists only between them.
What the extensions do to the build graph
The single most consequential line in the estate is repeated, byte for byte, six times. Every extension .csproj ends like this:
<ItemGroup Condition="'$(Configuration)' == 'Debug'">
<ProjectReference Include="..\..\src\Ntrada\Ntrada.csproj" />
</ItemGroup>
<ItemGroup Condition="'$(Configuration)' == 'Release'">
<PackageReference Include="Ntrada" Version="0.4.*" />
</ItemGroup>
— extensions\Ntrada.Extensions.Cors\Ntrada.Extensions.Cors.csproj:8-14, and five identical siblings
Read it slowly, because it is genuinely clever and genuinely dangerous in the same six lines.
Debuggives you a monorepo. Edit the core, hit F5 on the sample, the extension picks up your change immediately. This is exactly what you want while developing.Releasegives you a package. Pack an extension and its.nuspecdeclares a real dependency onNtrada, rather than swallowing the core's assembly into the extension package. In 2019, withoutDirectory.Build.propsbeing reflexive practice, this was the honest way to express “this extension ships as a package that depends on a package.”0.4.*is a floating version. Restore resolves whatever 0.4.x nuget.org happens to be serving today.- And the build configuration is now a semantic switch. Two developers on the same commit, one running
dotnet buildand one runningdotnet build -c Release, compile against two different copies of the core. Nothing warns either of them.
That last consequence is the spine of part 9, and it is the clearest demonstration I know of why estate reading finds things project reading cannot. Everything above is true, and not one word of it appears in a .cs file.
Where this sits in the lineage
Ntrada is not an orphan. It is the gateway that the DevMentors Pacco reference architecture consumes — the Pacco read covers that deployment — it is the successor to the code-first gateway in DShop, and it is a sibling of Convey, whose own retrospective covers the framework half of the same house style. That house style is visible in every file here: internal sealed implementations, one interface per file, options POCOs with no validation, and a config vocabulary designed with more care than the code that reads it.
The README's headline claim is that Ntrada “requires no coding whatsoever.” A companion series, starting at No Coding Whatsoever, tests that claim against the gateway's own source. This series tests it against everything around the source: the six packages, the two hosts, the pipeline, the image, and the fourteen months of git history that produced them.
Where the series goes
- Twelve projects and one star — this post.
- Four members are the whole plugin contract — what
IExtensionasks for, and the much longer list of what it does not. - Discovery that works by accident — reflective plugin loading that succeeds because an unrelated subsystem force-loads the assemblies first.
- Load order decides what your traces see — an
int?sort, aHashSet, and the reason your production errors may be invisible. - What does a 202 actually promise — Quick Acknowledgment composed with Fire-and-Forget, and why that composition is unsound.
- The AMQP property the broker vetoed — a feature born and killed in four days, and what it teaches about identity at the edge.
- The rename that silently unbound a signing key — a correct refactor propagated to one of three files.
- The package identity that stranded sixteen releases — reclaiming a clean package id by deleting the version.
- The empty test project is load-bearing — delete the obvious dead weight and the estate's only real gate goes with it.
- The container you cannot configure — a YAML product shipped in an image that YAML cannot enter.
- Maintained for fourteen months, documented for two — the honest retrospective.
If you maintain a repository with more than one .csproj in it, the question this series keeps asking is the one worth borrowing: what does my build graph do that no file in it admits to? We start with the interface that makes six packages possible in under a thousand lines.