Skip to main content

Overview

The GraphQL layer exposes PriceSignal’s functionality through a type-safe GraphQL API using HotChocolate 14. It provides queries, mutations, and real-time subscriptions for cryptocurrency price tracking and alerts.

Architecture

Type System

PriceSignal uses HotChocolate’s annotation-based approach with explicit field binding:
public class InstrumentType : ObjectType<Instrument>
{
    protected override void Configure(IObjectTypeDescriptor<Instrument> descriptor)
    {
        descriptor.BindFieldsExplicitly();
        descriptor.Field(x => x.EntityId).Type<NonNullType<IdType>>().Name("id");
        descriptor.Field(x => x.Symbol).Type<NonNullType<StringType>>();
        descriptor.Field(x => x.Name).Type<NonNullType<StringType>>();
        descriptor.Field(x => x.Description).Type<StringType>();
        descriptor.Field(x => x.BaseAsset).Type<NonNullType<StringType>>();
        descriptor.Field(x => x.QuoteAsset).Type<NonNullType<StringType>>();
    }
}
src/PriceSignal/GraphQL/Types/Instrument.cs:7 Benefits:
  • Explicit control over exposed fields
  • Rename fields (EntityId → id)
  • Type safety with compile-time checks
  • Hide internal implementation details

Queries

InstrumentQueries

Query instruments (trading pairs) with filtering, sorting, and pagination.
[QueryType]
public class InstrumentQueries
{
    [UsePaging(IncludeTotalCount = true)]
    [UseProjection]
    [UseFiltering]
    [UseSorting]
    public IQueryable<InstrumentPrice> GetInstrumentPrices(AppDbContext dbContext)
    {
        return dbContext.InstrumentPrices.AsQueryable();
    }
    
    [UsePaging(IncludeTotalCount = true)]
    [UseProjection]
    [UseFiltering]
    public IQueryable<Instrument> GetInstruments(AppDbContext dbContext)
    {
        return dbContext.Instruments.AsQueryable();
    }
}
src/PriceSignal/GraphQL/Queries/InstrumentQueries.cs:9 Features:
  • Pagination: Cursor-based with total count
  • Filtering: Dynamic WHERE clauses
  • Sorting: Multi-field ordering
  • Projection: Only fetch requested fields (N+1 optimization)
Example query:
query {
  instruments(
    first: 10
    where: { baseAsset: { eq: "BTC" } }
    order: { name: ASC }
  ) {
    nodes {
      id
      symbol
      name
      baseAsset
      quoteAsset
    }
    totalCount
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}

PriceRuleQueries

Query user’s price alert rules with authentication.
[QueryType]
public class PriceRuleQueries
{
    [UsePaging(IncludeTotalCount = true)]
    [UseProjection]
    [UseFiltering]
    public IQueryable<PriceRule> GetPriceRules(AppDbContext dbContext, [Service] IUser user)
    {
        return dbContext.PriceRules
            .Where(pr => pr.UserId == user.UserIdentifier)
            .AsQueryable();
    }
    
    [UseSingleOrDefault]
    [UseProjection]
    public IQueryable<PriceRule> GetPriceRule(AppDbContext dbContext, Guid id, [Service] IUser user)
    {
        return dbContext.PriceRules
            .Where(pr => pr.EntityId == id && pr.UserId == user.UserIdentifier)
            .AsQueryable();
    }
}
src/PriceSignal/GraphQL/Queries/PriceRuleQueries.cs:8 Security:
  • Filters by authenticated user ID
  • Prevents unauthorized access to other users’ rules

Mutations

PriceRuleMutations

Create, update, delete, and toggle price alert rules.
[MutationType]
public class PriceRuleMutations
{
    public async Task<PriceRule> CreatePriceRule(
        PriceRuleInput input, 
        AppDbContext dbContext, 
        [Service] IServiceProvider serviceProvider, 
        [Service] RuleCache ruleCache, 
        [Service] IUser user)
    {
        if (user.UserIdentifier == null)
        {
            throw new InvalidOperationException("User not found");
        }
        
        var instrument = await dbContext.Instruments
            .FirstOrDefaultAsync(i => i.EntityId == input.InstrumentId);
        if (instrument == null)
        {
            throw new InvalidOperationException("Instrument not found");
        }

        var options = new JsonDocumentOptions
        {
            AllowTrailingCommas = true,
            CommentHandling = JsonCommentHandling.Skip,
            MaxDepth = 64
        };
        
        var conditions = input.Conditions.Select(c => new PriceCondition
        {
            ConditionType = c.ConditionType,
            Value = c.Value,
            AdditionalValues = JsonDocument.Parse(c.AdditionalValues, options)
        }).ToList();
        
        var priceRule = new PriceRule
        {
            Name = input.Name,
            Description = input.Description,
            InstrumentId = instrument.Id,
            Conditions = conditions,
            UserId = user.UserIdentifier
        };

        dbContext.PriceRules.Add(priceRule);
        await dbContext.SaveChangesAsync();
        
        ruleCache.AddOrUpdateRule(priceRule);
        
        var binanceProcessingService = serviceProvider.GetService<BinancePriceFetcherService>();
        binanceProcessingService?.UpdateSubscriptionsAsync();

        return priceRule;
    }
    
    public async Task<PriceRule> TogglePriceRule(
        Guid id, 
        AppDbContext dbContext,
        [Service] IServiceProvider serviceProvider, 
        [Service] RuleCache ruleCache,
        [Service] IUser user)
    {
        var priceRule = await dbContext.PriceRules
            .FirstOrDefaultAsync(pr => pr.EntityId == id && pr.UserId == user.UserIdentifier);
        if (priceRule == null)
        {
            throw new InvalidOperationException("Price rule not found");
        }

        priceRule.IsEnabled = !priceRule.IsEnabled;

        dbContext.PriceRules.Update(priceRule);
        await dbContext.SaveChangesAsync();
        
        ruleCache.AddOrUpdateRule(priceRule);
        
        var binanceProcessingService = serviceProvider.GetService<BinancePriceFetcherService>();
        binanceProcessingService?.UpdateSubscriptionsAsync();

        return priceRule;
    }
}
src/PriceSignal/GraphQL/Mutations/PriceRuleMutations.cs:15 Key operations:
  1. Validate user authentication
  2. Validate referenced entities exist
  3. Parse JSON condition metadata
  4. Save to database
  5. Update in-memory cache
  6. Trigger subscription updates for background service
Example mutation:
mutation {
  createPriceRule(input: {
    name: "BTC Alert"
    description: "Alert when BTC crosses $50,000"
    instrumentId: "123e4567-e89b-12d3-a456-426614174000"
    isEnabled: true
    notificationChannel: TELEGRAM
    conditions: [
      {
        conditionType: "Price"
        value: 50000
        additionalValues: "{\"direction\": \"Above\"}"
      }
    ]
  }) {
    id
    name
    isEnabled
    lastTriggeredAt
  }
}

Subscriptions

PriceSubscriptions

Real-time price updates via GraphQL subscriptions (WebSockets).
[SubscriptionType]
public class PriceSubscriptions
{
    [Subscribe(With = nameof(SubscribeToUpdates))]
    public Price? OnPriceUpdated(string symbol, [EventMessage] Price price)
    {
        return price;
    }
    
    public async IAsyncEnumerable<Price> SubscribeToUpdates(
        [Service] ITopicEventReceiver eventReceiver,
        string symbol,
        [EnumeratorCancellation] CancellationToken cancellationToken)
    {
        var stream = await eventReceiver.SubscribeAsync<Price>(
            nameof(OnPriceUpdated), 
            cancellationToken);
        
        await foreach (var price in stream.ReadEventsAsync().WithCancellation(cancellationToken))
        {
            if (price.Symbol != symbol) continue;
            yield return price;
        }
    }
}
src/PriceSignal/GraphQL/Subscriptions/PriceSubscriptions.cs:9 Example subscription:
subscription {
  onPriceUpdated(symbol: "BTCUSDT") {
    symbol
    close
    high
    low
    volume
    timestamp
  }
}

Complex Types

PriceRuleType

Defines GraphQL schema for PriceRule with DataLoaders for N+1 prevention.
public class PriceRuleType : ObjectType<PriceRule>
{
    protected override void Configure(IObjectTypeDescriptor<PriceRule> descriptor)
    {
        descriptor.BindFieldsExplicitly();
        descriptor.Field(x => x.EntityId).Type<NonNullType<IdType>>().Name("id");
        descriptor.Field(x => x.Name).Type<NonNullType<StringType>>();
        descriptor.Field(x => x.Description).Type<StringType>();
        descriptor.Field(x => x.Instrument).Type<NonNullType<InstrumentType>>();
        descriptor.Field(x => x.IsEnabled).Type<NonNullType<BooleanType>>();
        descriptor.Field(x => x.LastTriggeredAt).Type<DateTimeType>();
        descriptor.Field(x => x.LastTriggeredPrice).Type<DecimalType>();
        descriptor.Field(x => x.NotificationChannel).Type<NonNullType<NotificationChannelTypeType>>();
        
        descriptor.Field(x => x.Conditions)
            .Type<ListType<NonNullType<PriceConditionType>>>()
            .UsePaging(options: new PagingOptions { IncludeTotalCount = true })
            .UseProjection()
            .Resolve(context =>
            {
                var rule = context.Parent<PriceRule>();
                return context.Services.GetRequiredService<IPriceConditionsOnPriceRuleDataLoader>()
                    .LoadAsync(rule.EntityId);
            });
        
        descriptor.Field(x => x.ActivationLogs)
            .Type<ListType<PriceRuleTriggerLogType>>()
            .UsePaging(options: new PagingOptions { IncludeTotalCount = true })
            .UseProjection()
            .Resolve(context =>
            {
                var rule = context.Parent<PriceRule>();
                return context.Services.GetRequiredService<IPriceRuleTriggerLogsDataLoader>()
                    .LoadAsync(rule.EntityId);
            });
        
        descriptor.Field(x => x.CreatedAt).Type<NonNullType<DateTimeType>>();
    }
}
src/PriceSignal/GraphQL/Types/PriceRule.cs:14

DataLoaders

Batch and cache database queries to solve N+1 problems.
[DataLoader]
internal static async Task<ILookup<Guid, PriceCondition>> GetPriceConditionsOnPriceRuleAsync(
    IReadOnlyList<Guid> priceRuleIds, 
    IAppDbContext dbContext, 
    CancellationToken cancellationToken)
{
    var rules = dbContext.PriceRules
        .Include(x => x.Conditions)
        .AsQueryable()
        .Where(x => priceRuleIds.Contains(x.EntityId));

    return rules.SelectMany(x => x.Conditions.Select(c => new PriceCondition()
    {
        Rule = x,
        ConditionType = c.ConditionType,
        Value = c.Value,
        AdditionalValues = c.AdditionalValues
    })).ToLookup(x => x.Rule.EntityId);
}
src/PriceSignal/GraphQL/Types/PriceRule.cs:47 Benefits:
  • Batches multiple queries into one
  • Caches results within request
  • Prevents N+1 query problems
  • Automatic with HotChocolate

Input Types

PriceRuleInput

Input object for creating/updating price rules.
public class PriceRuleInput
{
    public Guid Id { get; set; }
    public required string Name { get; set; }
    public required string Description { get; set; }
    public required Guid InstrumentId { get; set; }
    public bool IsEnabled { get; set; }
    public NotificationChannelType NotificationChannel { get; set; }
    public ICollection<PriceConditionInput> Conditions { get; set; } = new List<PriceConditionInput>();
}

public class PriceRuleInputType : InputObjectType<PriceRuleInput>
{
    protected override void Configure(IInputObjectTypeDescriptor<PriceRuleInput> descriptor)
    {
        descriptor.BindFieldsExplicitly();
        descriptor.Field(x => x.Id).Type<NonNullType<IdType>>();
        descriptor.Field(x => x.Name).Type<NonNullType<StringType>>();
        descriptor.Field(x => x.Description).Type<NonNullType<StringType>>();
        descriptor.Field(x => x.InstrumentId).Type<NonNullType<IdType>>();
        descriptor.Field(x => x.Conditions).Type<ListType<PriceConditionInputType>>();
        descriptor.Field(x => x.IsEnabled).Type<BooleanType>();
        descriptor.Field(x => x.NotificationChannel).Type<NotificationChannelTypeType>();
    }
}
src/PriceSignal/GraphQL/Types/PriceRule.cs:83

Filtering and Sorting

Custom Filter Types

public class InstrumentFilterType : FilterInputType<Instrument>
{
    protected override void Configure(IFilterInputTypeDescriptor<Instrument> descriptor)
    {
        descriptor.BindFieldsExplicitly();
        descriptor.Field(x => x.EntityId).Type<IdOperationFilterInputType>().Name("id");
        descriptor.Field(x => x.Symbol).Type<StringOperationFilterInputType>();
        descriptor.Field(x => x.Name).Type<StringOperationFilterInputType>();
        descriptor.Field(x => x.BaseAsset).Type<StringOperationFilterInputType>();
        descriptor.Field(x => x.QuoteAsset).Type<StringOperationFilterInputType>();
        descriptor.Field(x => x.Exchange.Name)
            .Type<EnumOperationFilterInputType<ExchangeCode>>()
            .Name("exchange");
    }
}
src/PriceSignal/GraphQL/Types/Instrument.cs:21 Enables complex queries:
query {
  instruments(where: {
    and: [
      { baseAsset: { eq: "BTC" } }
      { exchange: { eq: BINANCE } }
    ]
  }) {
    nodes { symbol name }
  }
}

Performance Optimizations

Projection

Only fetches requested fields from database:
[UseProjection]
public IQueryable<Instrument> GetInstruments(AppDbContext dbContext)
Query:
{ instruments { nodes { symbol } } }
Generated SQL:
SELECT symbol FROM instruments

Pagination

Cursor-based pagination for large datasets:
[UsePaging(IncludeTotalCount = true)]
public IQueryable<PriceRule> GetPriceRules(AppDbContext dbContext, [Service] IUser user)
Prevents loading all records into memory.

DataLoader Batching

Automatic query batching:
  • 10 price rules each with conditions
  • Without DataLoader: 1 + 10 queries (N+1)
  • With DataLoader: 2 queries (batched)

Error Handling

GraphQL errors with proper structure:
if (user.UserIdentifier == null)
{
    throw new InvalidOperationException("User not found");
}
Client receives:
{
  "errors": [
    {
      "message": "User not found",
      "path": ["createPriceRule"],
      "extensions": {
        "code": "INVALID_OPERATION"
      }
    }
  ]
}

Authentication

Injected IUser service provides current user context:
public IQueryable<PriceRule> GetPriceRules(AppDbContext dbContext, [Service] IUser user)
{
    return dbContext.PriceRules.Where(pr => pr.UserId == user.UserIdentifier).AsQueryable();
}

Next Steps

Build docs developers (and LLMs) love