Analysed at main @ a17c22b (2026-09-07)
A LINQ-to-GraphQL client for .NET, shipped as three NuGet artifacts:
| Artifact | Kind | Role |
|---|---|---|
Linq2GraphQL.Generator |
dotnet tool (Linq2GraphQL) |
Introspects a GraphQL endpoint, emits a strongly-typed C# client via T4 templates |
Linq2GraphQL.Client |
library | Runtime: turns Include/Select lambdas into a query tree, executes, deserializes |
Linq2GraphQL.Client.Subscriptions |
library | Optional subscriptions transport (graphql-ws + SSE) |
Everything else in the tree — test/, docs/, StartGG/ — exists to exercise or demonstrate those three.
Hand-written C# (excluding bin/obj):
src/Linq2GraphQL.Generator 6 701 lines (32 files) <- mostly T4 output
src/Linq2GraphQL.Client 2 584 lines (45 files)
src/Linq2GraphQL.Client.Subscriptions 383 lines ( 9 files)
test/Linq2GraphQL.Tests 2 190 lines (19 files, 126 [Fact]/[Theory])
test/Linq2GraphQL.TestClient* 6 523 lines (checked-in generated output)
docs/ + StartGG/ 15 465 lines (Blazor site + sample clients)
The product itself is small — under 3 000 lines of runtime code. That is the headline: a compact, focused library where the generator's line count is inflated by machine-generated template partials, not by complexity.
Largest runtime files: Converters/EnumConverter.cs (343), Visitors/QueryExpressionVisitor.cs (332), QueryNode.cs (244), Utilities.cs (185).
Three stages, and understanding them is usually the whole job:
- Build — generated
QueryMethods/MutationMethodsreturnGraphQuery<T>seeded with a rootQueryNode(field name +ArgumentValues). - Parse —
Include(...)/Select(...)lambdas go throughUtilities.ParseExpression->QueryExpressionVisitor. Two modes:ResolvePathfor expressions that name a field (member chains,[GraphQLMember]methods, LINQ operators), and plainExpressionVisitorwalking for everything else so every mentioned field still lands in the query. Lambda parameters bind to nodes keyed byParameterExpressionreference, so nested lambdas reusing a name stay distinct. - Execute —
GraphBaseExecutelazily assigns unique variable names and auto-adds primitive children, renders query text from theQueryNodetree;QueryExecutor<T>POSTs and unwrapsdata/errors/extensions.
QueryNode is the entire intermediate representation. Everything interesting happens between it and the visitor.
- The test strategy is unusually strong for a library this size. 126 tests, and most are genuinely end-to-end:
WebApplicationFactory<Program>boots a real HotChocolate server in-process (Linq2GraphQL.TestServer) and the checked-in generated client queries it. A passing suite proves the generator, the parser, the query text and the deserializer all agree — not just that a unit returns the expected string. - The nullable variant is a first-class citizen, not an afterthought: a parallel
TestServerNullable+TestClientNullable+ fixture pair. - Build hygiene is modern and tight. .NET 10 throughout, central package management (
Directory.Packages.props),RestorePackagesWithLockFilewith--locked-modein CI, Nerdbank.GitVersioning driving versions from git, and solution filters splitting CI (Linq2GraphQL.CI.slnf) from pack/publish (Linq2GraphQL.Release.slnf). No hand-edited version numbers anywhere. - Release is one button.
release.ymlisworkflow_dispatch-only: nbgv -> pack -> push to NuGet -> GitHub release. Low ceremony, hard to trigger by accident. - Deliberate design details that show maturity: argument-hash-derived GraphQL aliases so the same field can be requested twice with different arguments; opt-in "safe mode" that validates auto-included primitives against a cached introspection result; two error surfaces (
ExecuteAsyncthrows,ExecuteWithResultAsyncreturnsGraphResult<T>) funnelled through a singleProcessResponseFull. - Generated files are normalised to LF (
ReplaceLineEndings("\n")inProgram.cs) — a small thing that prevents a lot of cross-platform diff noise.
T4 is the sharpest edge in the repo.Fixed. Templates are now preprocessed at build time by the pinneddotnet-t4local tool into gitignoredX.g.csfiles, so a.ttedit takes effect on the nextdotnet buildon any platform and stale template logic cannot be built. (Switching it on revealed one already-dormant edit:ScalarTemplate.ttreferenced a non-existentGraphqlType.ScalarTypeNameand had never been regenerated.)Generated test clients are checked in but never regenerated by the build.Fixed../scripts/regenerate-test-clients.ps1boots both test servers over plain HTTP and regeneratesTestClient/TestClientNullable, and thegenerated-clientsCI job runs it with-Checkso committed output that no longer matches the generator fails the build. Refreshing the output picked up three pieces of accumulated drift: the newly active template edits, the signedBytescalar mapping (byte→sbyte), and theraiseError/raiseAuthErrorquery methods.- Coupling between alias hashing and deserialization.
Utilities.GetArgumentsIdmust produce the same hash at write time and read time. Any change to argument hashing breaks reads as well as writes, and the failure mode is a silently missing field rather than an exception. - Ambient static generator state.
GeneratorSettings.Currentis read from inside templates, so nullable/non-nullable output depends on global mutable state rather than a passed parameter. Fine today; awkward if generation ever needs to run concurrently. docs/andStartGG/are outside the CI filter and can drift. They are inLinq2GraphQL.slnbut notLinq2GraphQL.CI.slnf, so nothing verifies they still compile — roughly 15k lines CI never touches.- Subscriptions are only partly covered. Two transports exist (
WSClientfor graphql-ws,SSEClientfor SSE), but only SSE works under the test host — and there is exactly 1 subscription test. The WebSocket path is effectively untested. - Minor tidy-ups:
nuget.config.backupis committed alongsidenuget.config; a stray root-levelStarWars.Client/containing onlyobj/shadows the realdocs/StarWars.Client; the README options list has a few typos (--nullabel, "Exprimental").
- 255 commits, active since 2023: 110 (2023), 60 (2024), 73 (2025), 12 (2026 YTD).
- Concentrated ownership. Joakim Dangården holds 180 of 255 commits across two identities; Magnus Ahlberg 62; a long tail of 4 outside contributors with ~19 between them. Bus factor is effectively one.
- Recent work is squarely in the core. The last six months of churn lands on
src/Linq2GraphQL.Client(10 touches, 5 of them inVisitors/), Subscriptions (7), and the tests (6). Notable recent commits: the expression parser rewrite (#92), the .NET 10 upgrade with CVE/warning cleanup (#90), and structured GraphQL error handling contributed externally (#88). - PR-driven workflow. Nearly every change lands as a merge commit from a named branch — the history reads cleanly.
Wire up CLI T4 regeneration— done:dotnet-t4runs from the generator's csproj on every build, and the preprocessed output is no longer checked in.Add a CI job that regenerates the test clients and diffs them— done:scripts/regenerate-test-clients.ps1 -Check, run by thegenerated-clientsjob.- Pull
docs/andStartGG/into a build-only CI job so they cannot rot unnoticed. - Get the WebSocket subscription transport under test, even against a standalone host outside
WebApplicationFactory. - Delete
nuget.config.backupand the stray rootStarWars.Client/.
# What CI runs
dotnet restore Linq2GraphQL.CI.slnf --locked-mode
dotnet build Linq2GraphQL.CI.slnf --no-restore
dotnet test Linq2GraphQL.CI.slnf --no-build
# Generate a client against a live endpoint
dotnet run --project src/Linq2GraphQL.Generator -- <endpoint> -c=ClientName -n=Namespace -o=Generated