Architecture Guards
Trax ships per-concern "guard" packages that let any repo enforce the same architectural conventions the Trax samples follow: one project / one schema / one context, cross-schema reads that never leak the model graph, cross-schema GraphQL edges that batch, trains that each have their own train interface, and basic test hygiene. The rules are framework-agnostic checkers that return an offender list; you assert on them with your own test framework.
Packages
Each package lives in the repo that owns the concern it checks. Each depends on Trax.Core.Testing and NUnit; Trax.Effect.Data.Testing also brings in Trax.Effect.Data, Roslyn and the Npgsql EF Core provider, and Trax.Api.GraphQL.Testing brings in Trax.Api.GraphQL:
| Package | Owns | Guards |
|---|---|---|
Trax.Core.Testing | Infrastructure + hygiene | RepoRoot / SourceFiles / SourceText, ArchitectureGuardOptions, GuardResult; HygieneGuards (no [Ignore] in any form, including a qualified or suffixed name, an attribute list split across lines, and Ignore = or IgnoreReason = on a TestCase; no legacy asserts, including ClassicAssert, CollectionAssert and StringAssert; no fixed delays); RepoConventionGuards (Directory.Build.props version; cross-repo Trax refs centrally managed via Directory.Packages.props with no inline Version or VersionOverride); VocabularyGuards (a listed third-party attribute, found wherever it sits in an attribute list, with or without its Attribute suffix, including in files that see its library only through a global or project-level using) |
Trax.Effect.Data.Testing | Data layer | DomainContextsDeriveBase, CompanionInterfaces, OneSchemaPerContext, NoPendingModelChanges, OwnerScopeCompleteness, OwnerScopeFilterBypasses |
Trax.Api.GraphQL.Testing | GraphQL | EdgeManifestIsValid, EdgeResolversUseLoader |
Trax.Mediator.Testing | Trains | EveryTrainHasInterface |
A checker returns a GuardResult with the offenders it found, how many items it inspected, and a ready-to-use failure message. The Trax.Core.Testing fixtures, the two source guards in DomainDataLayerGuardFixture, and TrainGuardFixture (when its TrainAssemblies hold no train) also fail when a guard inspected nothing: scan roots that point at the wrong directory would otherwise pass every check. Assert Inspected > 0 yourself if you call a checker directly. The data-layer source checkers (DomainContextsDeriveBase, CompanionInterfaces, OwnerScopeFilterBypasses) also report a scan root that does not exist as an offender.
DomainContextsDeriveBase and CompanionInterfaces parse each file with Roslyn and judge every class declaration by its own base list. A context counts whether it has a primary or an ordinary constructor, a file holding several contexts has each one checked, and the base named anywhere else in the file does not excuse a context that does not derive it.
Most guards scan source on disk. NoPendingModelChanges and OwnerScopeCompleteness are the exceptions. NoPendingModelChanges builds each migration-based context offline (no database) and asserts its EF model matches the latest migration snapshot, catching a model edit that shipped without dotnet ef migrations add before it trips PendingModelChangesWarning at host startup.
Consuming the guards
Each package ships abstract NUnit base fixtures with the [Test] methods already written. Reference the packages in a test project, subclass the fixtures you want, supply your configuration, and run dotnet test. You write no test bodies, only configuration:
[TestFixture]
public sealed class MyDataLayerGuards : DomainDataLayerGuardFixture
{
protected override ArchitectureGuardOptions Options => new() { SourceScanRoots = ["libs", "apps"] };
protected override IReadOnlyList<Type> DomainContexts => [typeof(CatalogDbContext), typeof(LendingDbContext)];
// Only contexts that use EF migrations; omit any bootstrapped with EnsureSchemaCreatedAsync.
protected override IReadOnlyList<Type> MigrationContexts => [typeof(CatalogDbContext)];
}
[TestFixture]
public sealed class MyCrossSchemaGuards : CrossSchemaGuardFixture
{
protected override ArchitectureGuardOptions Options => new() { SourceScanRoots = ["libs"] };
protected override IReadOnlyList<CrossSchemaEdge> Edges => MyCrossSchemaEdges.All;
}
[TestFixture]
public sealed class MyTrainGuards : TrainGuardFixture
{
protected override IReadOnlyList<Assembly> TrainAssemblies => [typeof(MyAssemblyMarker).Assembly];
}That is the whole test project. dotnet test discovers the inherited [Test] methods through your subclasses. Subclass only the fixtures for concerns you have; the optional type-list members (DomainContexts, MigrationContexts, OwnerScopedModels, Edges) default to empty, so a guard you do not configure passes vacuously. TrainAssemblies is abstract on TrainGuardFixture, as Options is on DomainDataLayerGuardFixture: a subclass must supply it, and an empty list fails rather than passing. The source-scanning guards are the exception: CrossSchemaGuardFixture fails when its scan roots hold no cross-schema [ExtendObjectType] resolver, or no [Parent] resolver, naming the roots it scanned, because a scan pointed at the wrong folder would otherwise pass on nothing. A repo that really has none overrides ExpectsCrossSchemaResolvers or ExpectsParentResolvers to return false. The [TestFixture] attribute on each subclass is required for the runner to discover the inherited tests.
ArchitectureGuardOptions carries the per-repo configuration: scan roots, allowlists, and the expected versions. Allowlist entries are repo-relative paths; the source guards walk up from the test assembly to the nearest *.slnx to find the repo root.
If you prefer not to use NUnit, the same checks are available as framework-agnostic methods (DataLayerGuards.*, CrossSchemaGuards.*, TrainGuards.*, HygieneGuards.*) that return a GuardResult you assert on however you like.
EveryTrainHasInterface applies the rule the train registry applies when it scans. A train is any concrete class that derives from ServiceTrain<,> or implements IServiceTrain<,> directly, and each one needs exactly one most-derived non-generic interface deriving IServiceTrain<TIn, TOut>, whose FullName is the train's canonical name. The interface is conventionally named I{Name}, but any name passes. The guard flags a train with no such interface, which the registry would list only under the shared IServiceTrain<TIn, TOut>, and a train with two train interfaces neither of which extends the other, which the registry refuses.
The owner-scope census
[TraxAuthorize] gates a type, not its rows. Per-user data is narrowed to its owner by an EF query filter that reads the current principal, and Trax's authorization never sees that filter, so an entity that is correctly gated but has no filter passes every other check and serves every user's rows to any authenticated caller. OwnerScopeCompleteness reads the EF model and finds those entities.
An entity holds per-user data when the model gives it an owner key (a foreign key to your owner type, a property you name in OwnerIdProperties, or it is the owner type itself), or when it has a query filter that reads your principal accessor. For each one the census requires:
- a query filter whose expression references the accessor's type. A soft-delete or visibility filter reads no principal and does not count, so it neither satisfies the check nor pulls a shared entity into it. EF declares filters on a hierarchy's root, so a derived type is judged by its root's filter;
- if it is a
[TraxQueryModel], a bare[TraxAuthorize].[TraxAllowAnonymous]exposes owners' rows to anonymous callers, and a role or policy gate can lock owners out of their own rows. The filter is the access control. When a gate is deliberate (a per-user entity only premium users may read, say), list the entity inGatedwith a reason. The gate is added to the filter, never used in place of it: a gated entity still needs its principal-reading filter, still cannot be[TraxAllowAnonymous]or undeclared, and cannot also be exempted.
An entity reaching its owner only through a navigation, such as an answer whose poll holds the owner, has no owner key, so its filter is the only thing marking it as per-user. Declare it in NavigationScoped with the navigation it goes through: the census then fails if the filter disappears, and fails if a filtered entity with no owner key is not declared. Exemptions leaves an entity out and needs a written reason; an exemption naming an entity the census would not flag is reported too. A Gated entry without a reason, or one naming an entity that is not a per-user [TraxQueryModel] with a role or policy on its [TraxAuthorize], is reported the same way.
A second entity type mapped to the same table or view as a per-user entity, such as a reporting view over the same rows, reads those rows too. The census treats it as per-user and requires its filter, whatever gate it carries.
The census takes the model rather than a context type, because an owner-scoped context usually takes its principal accessor through its constructor. Building the model needs no database:
[TestFixture]
public sealed class MyDataLayerGuards : DomainDataLayerGuardFixture
{
protected override ArchitectureGuardOptions Options => new() { SourceScanRoots = ["libs", "apps"] };
protected override IReadOnlyList<IReadOnlyModel> OwnerScopedModels
{
get
{
var options = new DbContextOptionsBuilder<ClientDbContext>()
.UseNpgsql("Host=localhost;Database=model_only")
.Options;
using var context = new ClientDbContext(options, new SystemPrincipal());
return [context.Model];
}
}
protected override OwnerScopeCensusOptions OwnerScope => new()
{
OwnerType = typeof(UserProfile),
PrincipalAccessorType = typeof(IPrincipalAccessor),
NavigationScoped = new Dictionary<Type, string>
{
[typeof(PollQuestionAnswer)] = "belongs to the PollAnswer that holds the UserId",
},
};
}Filters switched off in query code
The model says a filter exists; it cannot say that a resolver or a train switches it off. So the fixture also scans the source under your scan roots (DataLayerGuards.OwnerScopeFilterBypasses) and fails on:
IgnoreQueryFilters()on a per-user set. It switches every filter off, the owner scope included;- an EF10 named-filter disable,
IgnoreQueryFilters(["Owner"]), that names an owner-scope filter (a named filter reading your principal accessor), or whose names are not string literals the scan can read. Disabling only a soft-delete or visibility filter by name is fine; - either call on a set the scan cannot name, such as a query passed into a helper, or a generic repository's
Set<T>()over a type parameter, because it may be a per-user one.
The set is read from the receiver the call chain starts from: a DbSet<T> or IQueryable<T> property declared under the scan roots, or Set<T>() over a mapped entity. A call on a set of shared rows (a soft-deleted article archive, say) passes. A shared set mentioned elsewhere in the statement, inside a lambda for instance, does not make the receiver shared, and a per-user set mentioned anywhere in the statement is reported, because the call switches the filter off for its subqueries too. When a file switches the owner scope off on purpose, list it with the reason:
protected override OwnerScopeCensusOptions OwnerScope => new()
{
OwnerType = typeof(UserProfile),
PrincipalAccessorType = typeof(IPrincipalAccessor),
FilterBypassAllowlist = new Dictionary<string, string>
{
["apps/Worker/Trains/EraseAccount/EraseAccountTrain.cs"] =
"erasing an account deletes that owner's rows from a system context",
},
};An entry with a blank reason, or one whose file no longer switches an owner-scope filter off, is reported. Override ScanForOwnerScopeFilterBypasses to false only if your scan roots do not contain the code that queries those models.
The scan reads text, not compiled code. It does not follow a query built in one statement and filtered in a later one, and it cannot tell two contexts' Notes sets apart.
What neither check can see
The census proves a principal-reading filter exists, not that it compares the right column, and not what a bypass branch inside it allows: a filter reading principal.IsAdmin || e.OwnerId == principal.Id under an admin gate shows admins every owner's rows. Entities mapped with ToSqlQuery or to a function over per-user tables, and a per-user entity reached through a navigation from a type that is exposed, are outside both checks too.
What catches those is a cross-user behavioural test: sign in as one user, create a row, sign in as a second user, and assert that every surface the row can be read through (each GraphQL query and model, each train that returns it) gives the second user nothing. Write one per per-user entity against the running API, not the model; it is the only check that exercises the filter as it actually runs.
The patterns the guards enforce
The guards check first-class Trax types, so adopting them goes hand in hand with adopting the patterns:
DomainDataContext<TSelf>(Trax.Effect.Data) is the base for a domain data context. It applies the default schema on PostgreSQL, a UTC datetime converter, and sealsOnModelCreating(you overrideSchemaandConfigureModel). It is separate from Trax's own metadataDataContext<T>. Register it withAddDomainDataContext<TInterface, TContext>and create its schema withEnsureSchemaCreatedAsync<TContext>.IEntityReferencemarks a scalar-only projection of an entity owned by another schema, for cross-schema reads.CrossSchemaLoader<TContext, TEntity>andCrossSchemaEdge(Trax.Api.GraphQL) back cross-schema GraphQL edges: a batched loader collapses every cross-context lookup in a request into oneWHERE id IN (...), and the edge manifest is the single source of truth the guards check.[Parent(requires: ...)]on a resolver declares which columns of its parent it reads. Trax adds a query model's key to the projection automatically, soExtension_resolvers_declare_what_they_read_off_their_parentonly fires on the properties it cannot infer: foreign keys, and anything else a resolver touches. Without the declaration the property arrives as0ornulland the field silently returns nothing.
The Bookworm sample is the reference consumer of every package and pattern above.
SDK Reference
DomainDataContext | Cross-schema data loaders | Query models