Add a subscription field
The lifecycle fields stream train events. A domain feed of your own, such as a chat room's messages, is a field you add to the same root and feed from a lifecycle hook. The Chat Service sample is a complete one.
Add the field
Trax's subscription root is named LifecycleSubscriptions, so a type extension targets that name:
using HotChocolate;
using HotChocolate.Execution;
using HotChocolate.Subscriptions;
using HotChocolate.Types;
using Microsoft.EntityFrameworkCore;
using Trax.Api.Auth;
using Trax.Effect.Attributes;
[ExtendObjectType("LifecycleSubscriptions")]
public class ChatSubscriptions
{
[TraxAuthorize(Roles = "User")]
[Subscribe(With = nameof(SubscribeToChatEventAsync))]
public ChatEvent OnChatEvent(Guid chatRoomId, [EventMessage] ChatEvent message) => message;
public async ValueTask<ISourceStream<ChatEvent>> SubscribeToChatEventAsync(
Guid chatRoomId,
TraxCaller caller,
ChatDbContext db,
ITopicEventReceiver receiver,
CancellationToken cancellationToken)
{
var userId = caller.Principal?.Id;
if (userId is null || !await db.Participants.AnyAsync(
p => p.RoomId == chatRoomId && p.UserId == userId, cancellationToken))
throw new GraphQLException(ErrorBuilder.New()
.SetMessage("Not authorized.").SetCode("TRAX_AUTHORIZATION").Build());
return await receiver.SubscribeAsync<ChatEvent>($"ChatRoom:{chatRoomId}", cancellationToken);
}
}Register the extension on the GraphQL builder:
using Trax.Api.GraphQL.Extensions;
builder.Services.AddTraxGraphQL(graphql => graphql.AddTypeExtension<ChatSubscriptions>());- Target
"LifecycleSubscriptions", notOperationTypeNames.Subscription. The schema has no type namedSubscription, so the host refuses to start, naming the extension class andLifecycleSubscriptionsas the root it meant. HotChocolate on its own would drop the extension without an error; Trax refuses any class-declared extension whose target type is not in the schema. - Declare a posture on the field. A field on a root type inherits no gate, so the host refuses
to start when the field carries neither
[TraxAuthorize]nor[TraxAllowAnonymous](see Fields Added by a Type Extension). - Check the record in the subscribe resolver.
[TraxAuthorize]decides who may use the field; which topic a caller may listen to (their own room, their own orders) is the resolver's job. It runs once, when the client subscribes, and a refusal reaches the client as anerrormessage for that subscription. The method named bySubscribe(With = ...)is not exposed as a field. A resolver parameter of a service type, such asTraxCalleror aDbContext, is resolved from the container.
Publish to it
Send to the topic from an ITrainLifecycleHook
when the train whose result the feed carries completes:
using System.Text.Json;
using HotChocolate.Subscriptions;
using Trax.Effect.Models.Metadata;
using Trax.Effect.Services.TrainLifecycleHook;
using Trax.Effect.Services.TrainLifecycleHookFactory;
public class ChatLifecycleHook(ITopicEventSender eventSender) : ITrainLifecycleHook
{
public async Task OnCompleted(Metadata metadata, CancellationToken ct)
{
if (metadata.Name != typeof(ISendMessageTrain).FullName || metadata.Output is null)
return;
using var doc = JsonDocument.Parse(metadata.Output);
var roomId = doc.RootElement.GetProperty("chatRoomId").GetGuid();
await eventSender.SendAsync($"ChatRoom:{roomId}", new ChatEvent(roomId, metadata.Output), ct);
}
}
public class ChatLifecycleHookFactory(IServiceProvider services) : ITrainLifecycleHookFactory
{
public ITrainLifecycleHook Create() => ActivatorUtilities.CreateInstance<ChatLifecycleHook>(services);
}Register the hook with the effects:
using Trax.Effect.Data.Sqlite.Extensions;
using Trax.Effect.Extensions;
using Trax.Effect.Provider.Json.Extensions;
using Trax.Mediator.Extensions;
builder.Services.AddTrax(trax => trax
.AddEffects(effects => effects.UseSqlite(connectionString).AddJson().AddLifecycleHook<ChatLifecycleHookFactory>())
.AddMediator(typeof(ChatLifecycleHook).Assembly));metadata.Name is the train's canonical name, its service interface's FullName.
metadata.Output is the output serialized as camelCase JSON, and it begins with a "$id"
property; read the fields you need rather than forwarding it as your schema. A hook fires for every
train, so it filters by name itself; [TraxBroadcast] on the train is not needed for your own hook,
only for the lifecycle fields. Events your code sends through ITopicEventSender carry no
sequence and never cause a skip in the lifecycle fields' numbering.
The in-memory transport reaches subscribers on the process that sent the event. A hook that runs on
a worker process reaches no API node's subscribers; bridge it the way the lifecycle fields are
bridged, with UseBroadcaster(), or keep the
train on the API node.
SDK Reference
Subscriptions | AddTraxGraphQL | AddLifecycleHook | TraxAuthorize