The generator lives in Scry.SourceGenerator. It is not a standalone package — it is packed inside Scry.Client as an analyzer, so a client project that references Scry.Client already has it.
The generator is a Roslyn generator, so only a C# project runs it. A client written in another language references a C# project that hosts the output and nothing else. See F# for the F# client in the sample.
The generator reads the server model's built DLL from disk using System.Reflection.Metadata. The assembly is never referenced by the client project, never loaded into the compiler, and never executed. Only the allow-listed surface is extracted from its metadata tables.
That is what lets any .NET client — Blazor WebAssembly, WPF, Windows Forms, a console tool, a service — be strongly typed against a server-side EF Core model without dragging EF Core, connection strings, or the non-allow-listed members of the model into the client's dependency graph or its shipped output. The wiring below is the same whichever of those the client is; see Client hosts.
Two things are needed in the client project. First, the path the generator reads:
<!-- The server model, pointed at by path. NOT referenced. -->
<ScryModelDll>$(MSBuildThisFileDirectory)..\Sample.Model\bin\$(Configuration)\net10.0\Sample.Model.dll</ScryModelDll>A relative path is resolved against the project directory before the compiler sees it. The generator runs inside the compiler process, whose working directory is not the project's, so one that reached it unresolved would name a file that does not exist; it reports SCRY001 rather than generating nothing.
Second, a project reference that exists purely for build ordering:
<ProjectReference Include="..\Sample.Model\Sample.Model.csproj" ReferenceOutputAssembly="false" />ReferenceOutputAssembly="false" means no assembly reference is added — only the ordering constraint. Without it the generator races the model build and reads a stale or missing DLL.
A model shipped as a NuGet package is pointed at the same way, with the path built from where restore put the package, and needs no ordering reference: see The model as a package.
Everything else is supplied by the buildTransitive/Scry.Client.targets file that ships in the Scry.Client package:
<!--
Resolved against the project before the compiler sees it. The generator runs inside the compiler
process, whose working directory is not the project's, so a relative path that passes the Exists
checks below would reach it as a file that does not exist.
-->
<PropertyGroup Condition="'$(ScryModelDll)' != ''">
<ScryModelDll>$([MSBuild]::NormalizePath('$(MSBuildProjectDirectory)', '$(ScryModelDll)'))</ScryModelDll>
</PropertyGroup>
<ItemGroup>
<CompilerVisibleProperty Include="ScryModelDll" />
<CompilerVisibleProperty Include="ScryModelStamp" />
</ItemGroup>
<Target Name="ComputeScryStamp"
AfterTargets="ResolveProjectReferences"
BeforeTargets="GenerateMSBuildEditorConfigFile;CoreCompile"
Condition="'$(ScryModelDll)' != '' and Exists('$(ScryModelDll)')">
<GetFileHash Files="$(ScryModelDll)" Algorithm="SHA256">
<Output TaskParameter="Hash" PropertyName="ScryModelStamp" />
</GetFileHash>
</Target>
<Target Name="EnsureScryModel"
BeforeTargets="CoreCompile"
Condition="'$(ScryModelDll)' != '' and !Exists('$(ScryModelDll)')">
<Error Text="Scry: the model assembly '$(ScryModelDll)' was not found.
A model project: reference it with ReferenceOutputAssembly="false" so it builds first.
A model package: reference it with GeneratePathProperty="true", and build the path from the
Pkg property that generates (see obj/*.nuget.g.props).
See https://github.com/Papyrine/Scry/blob/main/docs/model-package.md" />
</Target>Roslyn's incremental pipeline can only see inputs that are declared to it. The DLL is read out of band, so from Roslyn's point of view the input is only a path string — which does not change when the file's contents do. A build that changes the model but not its location would leave the generator's cached output in place.
ComputeScryStamp hashes the DLL and surfaces the hash as a second compiler-visible property. The generator combines path and stamp into one pipeline input, so the model is re-read exactly when its contents change, and not otherwise.
The extracted model is then compared structurally (via EquatableArray<T>), so a change to the model assembly that leaves the queryable surface untouched — an unrelated method, a private field — does not trigger regeneration downstream.
Two gates therefore stand between a model build and a regenerated client — a content stamp and a structural comparison — and only a change that clears both re-emits code:
flowchart TD
A[Model project builds first<br/>ReferenceOutputAssembly=false] --> B[ComputeScryStamp<br/>hashes the dll, SHA-256]
B --> C[path + stamp<br/>= one Roslyn pipeline input]
C --> D{Stamp changed<br/>since last build?}
D -- No --> Z[Serve cached output<br/>generator does not re-run]
D -- Yes --> E[Re-read metadata,<br/>extract queryable surface]
E --> F{Queryable surface changed?<br/>EquatableArray compare}
F -- No --> Z
F -- Yes --> G[Regenerate client code]
When referencing the projects directly (as the sample and integration tests do), the targets file is not imported, so the wiring is written out explicitly:
<ItemGroup>
<ProjectReference Include="..\..\src\Scry.SourceGenerator\Scry.SourceGenerator.csproj"
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
<ProjectReference Include="..\Sample.Model\Sample.Model.csproj" ReferenceOutputAssembly="false" />
<CompilerVisibleProperty Include="ScryModelDll" />
<CompilerVisibleProperty Include="ScryModelStamp" />
</ItemGroup>
<!-- Resolved against the project before the compiler sees it, as the shipped targets do. -->
<PropertyGroup Condition="'$(ScryModelDll)' != ''">
<ScryModelDll>$([MSBuild]::NormalizePath('$(MSBuildProjectDirectory)', '$(ScryModelDll)'))</ScryModelDll>
</PropertyGroup>
<Target Name="ComputeScryStamp"
AfterTargets="ResolveProjectReferences"
BeforeTargets="GenerateMSBuildEditorConfigFile;CoreCompile"
Condition="Exists('$(ScryModelDll)')">
<GetFileHash Files="$(ScryModelDll)" Algorithm="SHA256">
<Output TaskParameter="Hash" PropertyName="ScryModelStamp" />
</GetFileHash>
</Target>Everything lands in the Scry.Generated namespace.
{TypeName}QueryModel.g.cs:
//HintName: EmployeeQueryModel.g.cs
// <auto-generated/>
#nullable enable
#pragma warning disable CS0612, CS0618
namespace Scry.Generated;
/// <summary>Client query model for the 'Employee' entity source.</summary>
[global::Scry.ScryModel("Employee", "Id", "Name", "Status", "Active", "ManagerId", "Avatar")]
public class EmployeeQueryModel
{
public int Id { get; init; }
public string Name { get; init; } = null!;
public Status Status { get; init; }
public bool Active { get; init; }
public int? ManagerId { get; init; }
public EmployeeQueryModel? Manager { get; init; }
public byte[] Avatar { get; init; } = null!;
}Note what is absent: Salary is [QueryIgnore], and Department/DepartmentId appear only if Department is itself opted in.
Properties are init-only. A reference navigation is emitted as a nullable reference to the other query model, so e.Manager!.Name type-checks and traverses. A non-nullable string gets = null!; to satisfy nullable analysis.
ScryEnums.g.cs contains every enum of the model reachable from an exposed member, with its members in declaration order (a BCL enum a member is typed as is left out, since every client already has it):
//HintName: ScryEnums.g.cs
// <auto-generated/>
#nullable enable
#pragma warning disable CS0612, CS0618
namespace Scry.Generated;
public enum Status
{
FullTime = 0,
PartTime = 1,
Contractor = 2,
}This is why the client can write e.Status == Status.FullTime without referencing the model.
ScryQuery.g.cs exposes one IQueryable<T> per allow-listed source:
//HintName: ScryQuery.g.cs
// <auto-generated/>
#nullable enable
#pragma warning disable CS0612, CS0618
namespace Scry.Generated;
/// <summary>Entry point for writing LINQ queries against the allow-listed sources.</summary>
public sealed class ScryQuery
{
/// <summary>
/// A hash of the queryable surface this client was generated against. Attached to each
/// request so the server can identify a client generated against a different model.
/// </summary>
public const string SchemaStamp = "zzQQ3191-q-LlvCa";
global::Scry.ScryClient client;
public ScryQuery(global::Scry.ScryClient client)
{
this.client = client;
client.SchemaStamp = SchemaStamp;
}
public global::System.Linq.IQueryable<EmployeeQueryModel> Employee =>
client.Source<EmployeeQueryModel>("Employee", ["Id", "Name", "Status", "Active", "ManagerId", "Avatar"]);
public global::System.Linq.IQueryable<EmployeeSummaryQueryModel> EmployeeSummary =>
client.Source<EmployeeSummaryQueryModel>("EmployeeSummary", ["Department", "Headcount"]);
public global::System.Linq.IQueryable<HolidayQueryModel> Holiday =>
client.Source<HolidayQueryModel>("Holiday", ["Name", "Date"]);
}Each source passes its scalar member names to Source. That is what a query with no Select projects, so the client names its own response keys rather than leaving the server to pick them — which is what lets a server-side member rename round-trip to an already-deployed client (Renaming). Navigations are excluded, matching the server's own default projection.
Register it alongside the client:
var services = builder.Services;
services.AddHttpClient(
"scry",
_ => _.BaseAddress = new(builder.HostEnvironment.BaseAddress));
services.AddScryClient(
"/api/query",
_ => _.GetRequiredService<IHttpClientFactory>().CreateClient("scry"));
services.AddScoped<ScryQuery>();| Model member type | Generated |
|---|---|
bool, char, sbyte, byte, short, ushort, int, uint, long, ulong, float, double |
the C# keyword |
string |
string (with = null!;) |
byte[] |
byte[] (with = null!;) |
decimal |
decimal |
DateTime, DateOnly, TimeOnly, DateTimeOffset, TimeSpan, Guid |
global::System.X |
an enum declared in the model |
the enum name, its members with their values, its underlying type, and [Flags], re-emitted into ScryEnums.g.cs |
DayOfWeek, DateTimeKind, HttpStatusCode |
global::System.X (global::System.Net.HttpStatusCode), not re-emitted: every client already has them (BCL enums) |
| another opted-in type | {Type}QueryModel? |
| a nullable value type | the above with ? |
| anything else | omitted |
| ID | Severity | Meaning |
|---|---|---|
SCRY001 |
Error | Failed to read the Scry model assembly. The message carries the underlying reason. |
SCRY002 |
Error | Two queryable types resolve to the same source name. |
SCRY003 |
Error | A source name cannot be written as a C# property name. |
SCRY018 |
Error | A generic type opts in. |
SCRY019 |
Error | An enum or a result class is named with a reserved keyword. |
SCRY001 is reported when the DLL exists but cannot be parsed — corrupt, truncated, or not a managed assembly — and when the path is relative, which the shipped targets prevent by resolving it against the project. It also carries the refusals the reader makes while walking the model: two enums reachable from the surface sharing a type name, and a new member of a derived opted-in type hiding an inherited member with a different type — the derived model inherits the base's member, so the two sides would read one name as two types. The server refuses both at startup.
SCRY018 refuses [Queryable], [QueryableView], [QueryablePoco] or [QueryableComplex] on an open generic type, which has no members a client could name until its parameters are filled in. The types deriving from it opt in instead: a generic base the model declares is read with the arguments each supplies (Inheritance). The server refuses the same type at startup.
SCRY002 guards two clashes that would otherwise emit a declaration twice and surface as a CS0102 on generated code the user cannot see, and says which it met. Two types resolving to one source name would emit duplicate properties on ScryQuery; give one of them a distinct Name. Two types of one type name — in two namespaces, or nested in two types — would emit one {Type}QueryModel class whatever each is named on the wire, since the model is named for the type rather than the source; Name cannot tell them apart, so one of the types is renamed. The server rejects both clashes at startup.
SCRY003 guards the same axis for names that are not identifiers at all: the entry point exposing the source is a property, and a name it cannot be written as would emit a ScryQuery that does not parse. The server refuses the same set at startup, so which side is built first does not change where the mistake surfaces. It also covers the names ScryQuery already uses for members of its own (SchemaStamp, client, Commands where the model declares commands, and ScryQuery itself), which only the generator refuses, since they clash with nothing on the server.
SCRY019 is the same rule for the types the generator emits under the model's own names: an enum, and a class a command answers with. Each is declared in Scry.Generated as the model spells it, and a reserved keyword cannot be written as a type name. It is refused rather than escaped with @, as a source name is: every member typed as the enum spells its name on both sides, and a prefix the server's description does not carry would move the schema stamp. A member or an enum value named with a keyword is different, and is escaped (Naming a source). A command named client is refused as SCRY011 for the same reason a duplicate is: the ScryCommands facade sends every command through a field of that name, which a method called client would shadow.
The SCRY1xx family is reported by the LINQ analyzer, which is packed in this same assembly and needs no separate reference. It reports queries the closed operator set cannot carry, at the call site rather than at translation time — see LINQ coverage.
Nothing is generated; ScryQuery does not exist. The path in ScryModelDll is empty or does not resolve to an existing file, so the generator produces nothing at all rather than failing the build. Check the path against $(Configuration) and the model's target framework — a Release client pointing at a Debug model path is the usual cause. When consuming the NuGet package, the EnsureScryModel target catches a path that is set but names no file, and fails the build with:
Scry: the model assembly '...' was not found.
A model project: reference it with ReferenceOutputAssembly="false" so it builds first.
A model package: reference it with GeneratePathProperty="true", and build the path from the
Pkg property that generates (see obj/*.nuget.g.props).
See https://github.com/Papyrine/Scry/blob/main/docs/model-package.md
An empty ScryModelDll is not an error: a project can reference Scry.Client without generating anything, as an F# client's does through its query models project (F#).
A source is missing from ScryQuery. The type is not opted in. Add [Queryable], [QueryableView], or [QueryablePoco] — see Annotations.
A property is missing from a query model. It is [QueryIgnore]d, has no public instance getter, or its type is neither a scalar nor another opted-in type. Collection navigations are always omitted.
Generated code is stale after changing the model. This is what the stamp exists to prevent, so first confirm the model project actually rebuilt. In Rider or Visual Studio the analyzer host caches generator assemblies; a restart clears it. The generator's AssemblyVersion tracks the package version specifically so an upgraded package gets a distinct identity rather than serving a cached, frozen generator.
Build order flakiness in CI. Confirm the ReferenceOutputAssembly="false" project reference is present. Without it, nothing orders the model build ahead of the client compile. A solution-level build dependency (<BuildDependency> in .slnx, ProjectDependencies in .sln) is not a substitute — it only applies when the build is entered through that solution, so building the client project directly, or from another solution, loses the ordering.
Add this to the client project to write the generated files to disk:
<PropertyGroup>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<CompilerGeneratedFilesOutputPath>$(BaseIntermediateOutputPath)Generated</CompilerGeneratedFilesOutputPath>
</PropertyGroup>