TraxQuery & TraxMutation Attributes
Trax uses two separate attributes to opt trains into the auto-generated GraphQL schema. Only trains decorated with one of these attributes get typed fields generated by the TrainTypeModule. Trains without either attribute are not exposed as GraphQL endpoints.
[TraxQuery]— exposes the train as a query field underquery { discover { ... } }[TraxMutation]— exposes the train as a mutation field undermutation { dispatch { ... } }
These are mutually exclusive: a train cannot be both a query and a mutation.
TraxQuery Definition
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)]
public class TraxQueryAttribute : Attribute
{
public string? Name { get; init; }
public string? Description { get; init; }
public string? DeprecationReason { get; init; }
public string? Namespace { get; init; }
}Query trains always execute synchronously via RunAsync. There are no operation parameters — queries cannot be queued.
Properties
| Property | Type | Default | Description |
|---|---|---|---|
Name | string? | null | Overrides the auto-derived GraphQL field name. When null, the name is derived by stripping the I prefix and Train suffix from the service type name. |
Description | string? | null | Human-readable description that appears in the GraphQL schema documentation. |
DeprecationReason | string? | null | Marks the generated field as deprecated in the schema. |
Namespace | string? | null | Groups this field under a sub-namespace. When set, the field appears under discover { namespace { field } } instead of directly under discover. |
TraxMutation Definition
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)]
public class TraxMutationAttribute : Attribute
{
public TraxMutationAttribute(params GraphQLOperation[] operations) { ... }
public string? Name { get; init; }
public string? Description { get; init; }
public string? DeprecationReason { get; init; }
public string? Namespace { get; init; }
public GraphQLOperation Operations { get; } // computed from constructor params
}The constructor accepts params GraphQLOperation[]. When no operations are passed, both Run and Queue are enabled (the default). The Operations property is read-only and computed from the constructor arguments.
Properties
| Property | Type | Default | Description |
|---|---|---|---|
Name | string? | null | Overrides the auto-derived GraphQL field name. When null, the name is derived by stripping the I prefix and Train suffix from the service type name. |
Description | string? | null | Human-readable description that appears in the GraphQL schema documentation. |
DeprecationReason | string? | null | Marks the generated fields as deprecated in the schema. |
Namespace | string? | null | Groups this field under a sub-namespace. When set, the field appears under dispatch { namespace { field } } instead of directly under dispatch. |
Operations | GraphQLOperation | Run | Queue | Read-only. Flags value computed from constructor params. When both Run and Queue are set, a mode parameter is exposed; Run alone is synchronous-only; Queue alone is async-only. |
GraphQLOperation Enum
[Flags]
public enum GraphQLOperation
{
Run = 1, // Always runs synchronously (no mode param)
Queue = 2, // Always queues (no mode param, has priority)
}When no operations are passed to the TraxMutationAttribute constructor, both Run and Queue are enabled — the generated mutation accepts an optional mode: ExecutionMode parameter (default RUN) and an optional priority: Int. To restrict a mutation to only one execution mode, pass just GraphQLOperation.Run or GraphQLOperation.Queue to the constructor.
Input Type Requirements
Every train annotated with [TraxQuery] or [TraxMutation] must have a dedicated input record — LanguageExt.Unit is not allowed as an input type. Attempting to register a Unit-input train will throw InvalidOperationException at startup.
This requirement exists because:
- The mediator bus routes by input type (
input.GetType()), soUnitinputs would collide across trains - HotChocolate cannot create an
InputObjectTypefor types with no properties
Empty records are valid — they provide a unique type for routing while the GraphQL layer skips the input argument:
// Valid: empty record gives this train a unique input type
public record RefreshCacheInput;
[TraxMutation(GraphQLOperation.Run)]
public class RefreshCacheTrain : ServiceTrain<RefreshCacheInput, Unit>, IRefreshCacheTrain
{
protected override async Task<Either<Exception, Unit>> RunInternal(RefreshCacheInput input) =>
Activate(input).Chain<RefreshCacheJunction>().Resolve();
}When using trax-cli to generate trains from OpenAPI or GraphQL schemas, operations with no input parameters automatically get an empty input record.
Naming Convention
When Name is null (the default), the field name is derived from the train's service interface:
- Strip the
Iprefix - Strip the
Trainsuffix - Use the result as the field name (camelCase, no prefix)
| Service Interface | Query Field | Mutation Field |
|---|---|---|
ILookupPlayerTrain | lookupPlayer | lookupPlayer |
IBanPlayerTrain | banPlayer | banPlayer |
When Name is set, it's used directly: [TraxMutation(Name = "BanUser")] produces banUser.
Namespaces
Use the Namespace property to group related trains under a sub-namespace in the GraphQL schema. This keeps the schema organized as the number of trains grows.
[TraxQuery(Namespace = "players", Description = "Looks up a player profile")]
public class LookupPlayerTrain : ServiceTrain<LookupPlayerInput, PlayerProfile>, ILookupPlayerTrain { ... }
[TraxQuery(Namespace = "players", Description = "Searches for players")]
public class SearchPlayersTrain : ServiceTrain<SearchPlayersInput, SearchResult>, ISearchPlayersTrain { ... }
[TraxMutation(Namespace = "alerts", Description = "Creates a new alert")]
public class CreateAlertTrain : ServiceTrain<CreateAlertInput, Alert>, ICreateAlertTrain { ... }This produces the following schema structure:
query {
discover {
players {
lookupPlayer(input: { playerId: "player-42" }) { ... }
searchPlayers(input: { query: "ace" }) { ... }
}
}
}
mutation {
dispatch {
alerts {
createAlert(input: { message: "Server overloaded" }) { ... }
}
}
}Trains without Namespace remain at the root level of discover or dispatch. Both namespaced and non-namespaced trains can coexist. The [TraxQueryModel] attribute also supports Namespace for entity queries.
Examples
Query train (read-only, synchronous)
[TraxQuery(Description = "Looks up a player profile")]
public class LookupPlayerTrain
: ServiceTrain<LookupPlayerInput, LookupPlayerOutput>, ILookupPlayerTrain
{
protected override async Task<Either<Exception, LookupPlayerOutput>> RunInternal(
LookupPlayerInput input
) => Activate(input).Chain<FetchPlayerJunction>().Resolve();
}Generates the query field lookupPlayer under discover. Returns LookupPlayerOutput directly:
query {
discover {
lookupPlayer(input: { playerId: "player-42" }) {
playerId
rank
wins
losses
rating
}
}
}Default mutation (Run + Queue)
[TraxAuthorize("Admin")]
[TraxMutation(Description = "Bans a player (admin only)")]
public class BanPlayerTrain : ServiceTrain<BanPlayerInput, Unit>, IBanPlayerTrain
{
protected override async Task<Either<Exception, Unit>> RunInternal(BanPlayerInput input) =>
Activate(input).Chain<ApplyBanJunction>().Resolve();
}With no operations passed, both Run and Queue are enabled. Generates banPlayer under dispatch with an optional mode: ExecutionMode parameter (default RUN):
# Run synchronously (default)
mutation {
dispatch {
banPlayer(input: { playerId: "player-42", reason: "cheating" }) {
externalId
metadataId
}
}
}
# Queue for async execution
mutation {
dispatch {
banPlayer(
input: { playerId: "player-42", reason: "cheating" }
mode: QUEUE
priority: 10
) {
externalId
workQueueId
}
}
}Run-only mutation (lightweight, synchronous)
[TraxMutation(GraphQLOperation.Run, Description = "Pings the server")]
public class PingTrain : ServiceTrain<PingInput, PongOutput>, IPingTrain
{
protected override async Task<Either<Exception, PongOutput>> RunInternal(PingInput input) =>
Activate(input).Resolve();
}Generates only ping under dispatch. No mode or priority parameters.
Queue-only mutation (heavy, asynchronous)
[TraxMutation(GraphQLOperation.Queue, Description = "Recalculates the leaderboard")]
public class RecalculateLeaderboardTrain
: ServiceTrain<RecalculateLeaderboardInput, Unit>, IRecalculateLeaderboardTrain
{
protected override async Task<Either<Exception, Unit>> RunInternal(
RecalculateLeaderboardInput input
) => Activate(input).Chain<AggregateScoresJunction>().Chain<RankPlayersJunction>().Resolve();
}Generates only recalculateLeaderboard under dispatch. Has priority but no mode parameter.
Both execution modes (explicit)
[TraxMutation(GraphQLOperation.Run, GraphQLOperation.Queue, Description = "Processes a completed match result")]
public class ProcessMatchResultTrain
: ServiceTrain<ProcessMatchResultInput, ProcessMatchResultOutput>, IProcessMatchResultTrain { ... }This is equivalent to [TraxMutation] with no operations — both forms enable both modes. Generates processMatchResult under dispatch with mode: ExecutionMode parameter.
Deprecating a field
[TraxMutation(DeprecationReason = "Use BanPlayerV2Train instead")]
public class BanPlayerTrain : ServiceTrain<BanPlayerInput, Unit>, IBanPlayerTrain { ... }The generated mutation will show a deprecation warning in GraphQL introspection.
Discovery
The attribute metadata is available through ITrainDiscoveryService. Each TrainRegistration includes:
IsQuery— whether the train has[TraxQuery]IsMutation— whether the train has[TraxMutation]GraphQLName— the name override (null if auto-derived)GraphQLNamespace— the namespace grouping (null if at root level)
The trains GraphQL query also exposes these fields.
Package
dotnet add package Trax.Effect
The attributes are defined in Trax.Effect so train classes can use them without depending on Trax.Api.GraphQL.