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 under query { discover { ... } }
  • [TraxMutation] — exposes the train as a mutation field under mutation { 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

PropertyTypeDefaultDescription
Namestring?nullOverrides 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.
Descriptionstring?nullHuman-readable description that appears in the GraphQL schema documentation.
DeprecationReasonstring?nullMarks the generated field as deprecated in the schema.
Namespacestring?nullGroups 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

PropertyTypeDefaultDescription
Namestring?nullOverrides 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.
Descriptionstring?nullHuman-readable description that appears in the GraphQL schema documentation.
DeprecationReasonstring?nullMarks the generated fields as deprecated in the schema.
Namespacestring?nullGroups this field under a sub-namespace. When set, the field appears under dispatch { namespace { field } } instead of directly under dispatch.
OperationsGraphQLOperationRun | QueueRead-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:

  1. The mediator bus routes by input type (input.GetType()), so Unit inputs would collide across trains
  2. HotChocolate cannot create an InputObjectType for 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:

  1. Strip the I prefix
  2. Strip the Train suffix
  3. Use the result as the field name (camelCase, no prefix)
Service InterfaceQuery FieldMutation Field
ILookupPlayerTrainlookupPlayerlookupPlayer
IBanPlayerTrainbanPlayerbanPlayer

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.