Skip to main content

Overview

The Application layer orchestrates business workflows by coordinating domain entities and infrastructure services. It contains use cases, application services, and defines interfaces for external dependencies following the Dependency Inversion Principle.

Core Interfaces

IAppDbContext

Defines the database abstraction for the application.
public interface IAppDbContext
{
    public DbSet<Exchange> Exchanges { get; }
    public DbSet<Instrument> Instruments { get; }
    public DbSet<InstrumentPrice> InstrumentPrices { get; }
    public DbSet<PriceRuleTriggerLog> PriceRuleTriggerLogs { get; }
    public DbSet<PriceRule> PriceRules { get; }
    public DbSet<User> Users { get; }
    public DbSet<UserNotificationChannel> UserNotificationChannels { get; }
    public DbSet<Waitlist> Waitlists { get; }
    
    Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
    int SaveChanges();
}
src/Application/Common/Interfaces/IAppDbContext.cs:10

IPubSub

Abstraction for pub/sub messaging (implemented by NATS).
public interface IPubSub
{
    Task PublishAsync<T>(string subject, T message);
    void Subscribe<T>(string stream, Func<T,Task> handler, string? subject = null);   
}
src/Application/Common/Interfaces/IPubSub.cs:3

INotificationChannel

Defines notification delivery strategy (Telegram, email, etc.).
public interface INotificationChannel
{
    Task SendAsync(string userId, string message);
}

Rule Engine

RuleEngine

Core service that evaluates price rules using NRules (business rules engine).
public class RuleEngine(RuleCache ruleCache, PriceHistoryCache priceHistoryCache, 
    ILogger<RuleEngine> logger, ISession session, IServiceProvider serviceProvider)
{
    public void EvaluateRules(IPrice price)
    {
        priceHistoryCache.AddPrice(price.Symbol, price);
        session.Insert(price);
        
        var rules = ruleCache.GetAllRules()
            .Where(r => r.Instrument.Symbol == price.Symbol)
            .ToList();

        session.InsertAll(rules);
        session.Fire();

        session.Retract(price);
        session.RetractAll(rules);
    }
}
src/Application/Rules/Common/RuleEngine.cs:7 Flow:
  1. Add incoming price to history cache
  2. Insert price and matching rules into NRules session
  3. Fire rules engine to evaluate conditions
  4. Clean up session state

TechnicalAnalysisRule

NRules rule that evaluates price conditions and technical indicators.
public class TechnicalAnalysisRule : Rule
{
    private readonly TechnicalAnalysisFactory _taFactory = new();
    public PriceHistoryCache _priceHistoryCache;

    public override void Define()
    {
        IPrice price = null;
        PriceRule priceRule = null;

        When()
            .Match<IPrice>(() => price)
            .Exists<PriceRule>(pr => !pr.HasAttempted)
            .Match<PriceRule>(() => priceRule, r => 
                r.Instrument.Symbol == price.Symbol && 
                r.IsEnabled && 
                r.DeletedAt == null && 
                !r.HasAttempted);

        Then()
            .Do(ctx => EvaluateConditions(ctx, price, priceRule))
            .Do(ctx => ctx.Update(priceRule));
    }
    
    private void EvaluateConditions(IContext ctx, IPrice price, PriceRule rule)
    {
        var allConditionsMet = true;
        
        foreach (var condition in rule.Conditions)
        {
            bool conditionMet;
            var threshold = condition.Value;
            var metadata = condition.AdditionalValues.RootElement;
            var direction = metadata.GetProperty("direction").GetString();

            switch (condition.ConditionType)
            {
                case nameof(ConditionType.TechnicalIndicator):
                    var indicatorName = metadata.GetProperty("name").GetString();
                    var indicator = _taFactory.GetIndicator(indicatorName);
                    var prices = _priceHistoryCache.GetPriceHistory(price.Symbol);
                    var value = indicator.Calculate(prices, metadata);
                    
                    conditionMet = direction switch
                    {
                        "Above" => value >= threshold,
                        "Below" => value <= threshold,
                        _ => false
                    };
                    break;
                case nameof(ConditionType.Price):
                    conditionMet = direction switch
                    {
                        "Above" => price.Close >= threshold,
                        "Below" => price.Close <= threshold,
                        _ => false
                    };
                    break;
                case nameof(ConditionType.PricePercentage):
                    var previousPrice = _priceHistoryCache.GetPriceHistory(price.Symbol).LastOrDefault();
                    if (previousPrice == null)
                    {
                        conditionMet = false;
                        break;
                    }
                    var percentageChange = (price.Close - previousPrice.Close) / previousPrice.Close * 100;
                    
                    conditionMet = direction switch
                    {
                        "Above" => percentageChange >= threshold,
                        "Below" => percentageChange <= threshold,
                        _ => false
                    };
                    break;
                case nameof(ConditionType.PriceCrossover):
                    var previousPriceCrossover = _priceHistoryCache.GetPriceHistory(price.Symbol)
                        .SkipLast(1).LastOrDefault();
                    if (previousPriceCrossover == null)
                    {
                        conditionMet = false;
                        break;
                    }
                    conditionMet = direction switch
                    {
                        "Above" => previousPriceCrossover.Close < threshold && price.Close >= threshold,
                        "Below" => previousPriceCrossover.Close > threshold && price.Close <= threshold,
                        _ => false
                    };
                    break;
                default:
                    conditionMet = false;
                    break;
            }

            if (!conditionMet)
            {
                allConditionsMet = false;
                break;
            }
        }
        
        if (allConditionsMet)
        {
            rule.HasAttempted = true;
        }
    }
}
src/Application/Rules/TechnicalAnalysisRule.cs:9

PriceRuleNotificationRule

NRules rule that handles notifications when price rules are triggered.
public class PriceRuleNotificationRule : Rule
{
    public override void Define()
    {
        IPrice price = null;
        PriceRule priceRule = null;
        
        When()
            .Match<IPrice>(() => price)
            .Match<PriceRule>(() => priceRule)
            .Exists<PriceRule>(r => r.HasAttempted);

        Then()
            .Do(ctx => NotifyRuleTrigger(ctx, price, priceRule));
    }
    
    private void NotifyRuleTrigger(IContext ctx, IPrice price, PriceRule rule)
    {
        var _context = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
        rule.HasAttempted = false;
        
        rule.Trigger(price.Close);
        _context.PriceRules.Update(rule);
        _context.SaveChangesAsync().GetAwaiter().GetResult();
    }
}
src/Application/Rules/PriceRuleNotificationRule.cs:11

Caching Services

PriceHistoryCache

In-memory cache for recent price data, required for technical analysis.
public class PriceHistoryCache
{
    private readonly ConcurrentDictionary<string, FixedSizeQueue<IPrice>> _priceHistory = new();
    private readonly int _maxSize;

    public PriceHistoryCache(int maxSize = 500)
    {
        _maxSize = maxSize;
    }

    public IEnumerable<IPrice> GetPriceHistory(string symbol)
    {
        if (_priceHistory.TryGetValue(symbol, out var history))
        {
            return history.ToArray();
        }
        return new List<IPrice>();
    }

    public void AddPrice(string symbol, IPrice price)
    {
        var history = _priceHistory.GetOrAdd(symbol, new FixedSizeQueue<IPrice>(_maxSize));
        history.Enqueue(price);
    }
}
src/Application/Price/PriceHistoryCache.cs:6 Features:
  • Thread-safe concurrent access
  • Fixed size queue (default 500 prices)
  • Per-symbol history tracking
  • Used by technical indicators (RSI, SMA, EMA)

RuleCache

Caches active price rules to avoid database queries on every price update.
public class RuleCache
{
    private readonly ConcurrentDictionary<long, PriceRule> _rules = new();

    public void AddOrUpdateRule(PriceRule rule)
    {
        _rules.AddOrUpdate(rule.Id, rule, (key, oldValue) => rule);
    }

    public void RemoveRule(long ruleId)
    {
        _rules.TryRemove(ruleId, out _);
    }

    public IEnumerable<PriceRule> GetAllRules()
    {
        return _rules.Values;
    }
}

Notification Services

NotificationService

Routes notifications to the appropriate channel based on user preferences.
public class NotificationService
{
    private readonly IEnumerable<INotificationChannel> _notificationChannels;

    public NotificationService(IEnumerable<INotificationChannel> notificationChannels)
    {
        _notificationChannels = notificationChannels;
    }

    public async Task SendAsync(string userId, string message, NotificationChannelType type)
    {
        var channel = _notificationChannels.FirstOrDefault(c => 
            c.GetType().Name.StartsWith(type.ToString(), StringComparison.OrdinalIgnoreCase));
        
        if (channel != null)
        {
            await channel.SendAsync(userId, message);
        }
        else
        {
            throw new ArgumentException("Invalid notification channel type");
        }
    }
}
src/Application/Common/NotificationService.cs:6

TelegramNotificationChannel

Implements Telegram bot notifications.
public class TelegramNotificationChannel : INotificationChannel
{
    public async Task SendAsync(string userId, string message)
    {
        // Telegram bot implementation
    }
}
src/Application/Notifications/TelegramNotificationChannel.cs

Data Structures

FixedSizeQueue<T>

Circular buffer for efficient price history storage.
public class FixedSizeQueue<T>
{
    private readonly Queue<T> _queue;
    private readonly int _maxSize;

    public FixedSizeQueue(int maxSize)
    {
        _maxSize = maxSize;
        _queue = new Queue<T>(maxSize);
    }

    public void Enqueue(T item)
    {
        if (_queue.Count >= _maxSize)
        {
            _queue.Dequeue();
        }
        _queue.Enqueue(item);
    }
}
src/Application/Common/FixedSizeQueue.cs

Dependency Injection

Application Services Registration

public static class DependencyInjection
{
    public static IServiceCollection AddApplication(this IServiceCollection services, 
        IConfiguration configuration)
    {
        services.Configure<AlpacaSettings>(configuration.GetSection(AlpacaSettings.Alpaca));
        services.Configure<NatsSettings>(configuration.GetSection(NatsSettings.Nats));
        services.Configure<BinanceSettings>(configuration.GetSection(BinanceSettings.Binance));

        services.AddMediatR(config =>
        {
            config.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly());
        });
        
        services.AddSingleton<RuleCache>();
        services.AddSingleton<PriceHistoryCache>(_ => new PriceHistoryCache());
        services.AddSingleton(provider =>
        {
            var ruleEngineConfig = new RuleEngineConfig(provider);
            return ruleEngineConfig.CreateSession();
        });
        services.AddScoped<RuleEngine>();
        
        services.AddSingleton<INotificationChannel, NoneNotificationChannel>();
        services.AddSingleton<INotificationChannel, TelegramNotificationChannel>();
        services.AddSingleton<NotificationService>();
        
        return services;
    }
}
src/Application/DependencyInjection.cs:17 Key registrations:
  • Singleton: Caches, NRules session, notification channels
  • Scoped: RuleEngine (per-request)
  • MediatR: Domain event handling

External Service Integrations

Binance API

Interface for Binance exchange data.
public interface IBinanceApi
{
    [Get("/api/v3/exchangeInfo")]
    Task<ExchangeInfo> GetExchangeInfo();
    
    [Get("/api/v3/aggTrades")]
    Task<List<AggTrade>> GetAggTrades(string symbol, int limit);
}
src/Application/Services/Binance/IBinanceApi.cs

Alpaca News API

Interface for crypto news data.
public interface IAlpacaApi
{
    [Get("/v1beta1/news")]
    Task<NewsResponse> GetNews([Query] string symbols, [Query] int limit);
}
src/Application/Services/Alpaca/IAlpacaApi.cs

Architecture Benefits

Clean Architecture Principles

  1. Dependency Inversion: Application depends on abstractions (interfaces), not implementations
  2. Separation of Concerns: Business logic isolated from infrastructure
  3. Testability: Easy to mock dependencies for unit testing
  4. Flexibility: Swap implementations without changing application code

Performance Optimizations

  • In-memory caching reduces database load
  • NRules provides fast rule evaluation
  • Concurrent collections for thread-safety
  • Fixed-size buffers prevent memory growth

Next Steps

Build docs developers (and LLMs) love