Implements a flexible validation framework for student rankings, team assignments, and team composition with administrator-configurable rules and thresholds. Validation System: - Reflection-based rule discovery eliminates manual registration - Base classes (RequiredEventTypeRuleBase, EventCountThresholdRuleBase) reduce code duplication by ~250 lines - 12 validation rules covering event requirements, counts, and team constraints - Configurable severity levels (Warning/Error) per rule type - ValidationService with caching for optimal performance - Rules apply contextually (StudentRanking, StudentAssignment, TeamComposition) Configuration & Admin UI: - ValidationSettings admin page for editing thresholds and severity levels - ChapterSettings admin page for editing chapter information - Settings stored in Data/appsettings.json for runtime configuration - JSON config works in both development and production environments - Auto-creates Data directory and config template on first run User Experience: - ValidationWarnings component displays inline warnings/errors - Integrated in Event Ranking, Registration, and Team Assignment pages - Color-coded severity indicators (warning yellow, error red) - Includes "Too Many Regional Events" rule (max 3 recommended) Technical Improvements: - Template Method pattern for rule base classes - Singleton rule instances with lazy initialization - Configuration loaded via IConfiguration with fallback to defaults - Safe JSON updates preserve other appsettings sections 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
19 KiB
Validation System Improvements & Rules Engine Evaluation
Executive Summary
Current State: Custom validation system with 10 C# rules across 3 entity types. Clean architecture but hardcoded rule registration, requires recompilation for changes.
Critical Requirement: Runtime-editable validation rules for different chapters (from user feedback).
Recommendation: Hybrid approach - Improve current implementation + add JSON-based configuration storage, avoiding full rules engine adoption to maintain simplicity while enabling runtime customization.
Analysis Results
Current Implementation Assessment
Strengths:
- Clean generic design (
IValidationRule<TEntity>) - Type-safe validation
- Clear separation of concerns
- Context-aware rule execution
- Well-organized directory structure (StudentRankingRules, StudentAssignmentRules, TeamRules)
Critical Issues:
- Hardcoded rule registration - Rules manually added in
ValidationService.InitializeRules()(Open/Closed violation) - Requires recompilation - Cannot change rules or thresholds without code deployment
- Rule duplication - StudentRankingRules and StudentAssignmentRules have duplicate logic
- Non-scalable configuration - One property per rule severity (6 specific properties)
- No rule discovery - Cannot dynamically load or register rules
- Inconsistent context handling -
ValidateTeam()doesn't checkAppliesTo(context)
Performance Concerns:
- Rule objects instantiated per ValidationService (no singleton/pooling)
- No short-circuit logic (all rules execute even after critical errors)
- Batch operations materialize entire dictionary in memory
Rules Engine Library Research
Microsoft RulesEngine
GitHub - microsoft/RulesEngine | Documentation
Features:
- JSON-based rule definitions (runtime editable)
- Lambda expression support in rules
- Global/local parameters (reusable logic)
- Custom actions (v3+)
- Custom type injection
- Flexible storage (Azure Blob, Cosmos DB, files, EF Core, SQL)
- Actively maintained (tutorial from October 2025)
Example Rule:
{
"WorkflowName": "StudentValidation",
"Rules": [
{
"RuleName": "RequireRegionalEvent",
"Expression": "input.RankedEvents.Any(e => e.RegionalEvent == true)",
"ErrorMessage": "No Regional Event",
"ErrorType": "Warning"
}
]
}
Pros:
- ✅ Enables runtime editing without recompilation
- ✅ Proven, maintained library (Microsoft)
- ✅ Reduces custom code maintenance
- ✅ Flexible rule storage options
- ✅ Supports complex expressions
Cons:
- ❌ Significant complexity (JSON + lambda expressions as strings)
- ❌ Learning curve for team
- ❌ All rules must be rewritten in JSON
- ❌ Type safety lost (expressions are strings)
- ❌ Debugging more difficult (no compile-time checks)
- ❌ External dependency
Alternative: NRules
GitHub - NRules/NRules | Website
Characteristics:
- Forward-chaining rules engine based on Rete algorithm
- C# internal DSL for rule authoring
- More complex than RulesEngine
- Better for complex decision trees and rule dependencies
Assessment: Overkill for TSA validation use case. Designed for complex business logic scenarios with many interdependent rules.
Alternative: FluentValidation
GitHub - FluentValidation | NuGet
Characteristics:
- Validation-specific library (NOT a rules engine)
- Fluent interface for building validation rules
- Strongly-typed C# validation rules
Assessment: Not applicable - FluentValidation is for object validation, not business rule execution. Different use case.
Decision Matrix
| Aspect | Current Implementation | Improve Current + JSON Config | Full RulesEngine Adoption |
|---|---|---|---|
| Runtime Editing | ❌ No (requires recompile) | ✅ Yes (configuration only) | ✅ Yes (rules + config) |
| Simplicity | ✅ Simple C# rules | ✅ C# rules + JSON config | ❌ Complex JSON + lambdas |
| Type Safety | ✅ Full compile-time checks | ✅ Rules are type-safe | ❌ String-based expressions |
| Maintenance | ❌ Custom code to maintain | ⚠️ Less custom code | ✅ Library maintained |
| Flexibility | ❌ Can't add new rules | ⚠️ Can't add new rules | ✅ Can add new rules |
| Learning Curve | ✅ Familiar C# | ✅ Minimal | ❌ Significant |
| Debugging | ✅ Easy | ✅ Easy | ❌ Harder (runtime errors) |
| Dependencies | ✅ None | ✅ None | ⚠️ External library |
Recommended Approach: Hybrid Improvement
Strategy
Phase 1: Immediate Improvements (No Breaking Changes) Improve current implementation to fix code smells and add runtime configurability WITHOUT adopting a rules engine library.
Phase 2: JSON Configuration Storage
Move ValidationConfiguration to JSON files (Data/validation-config.json) to enable per-chapter customization at runtime.
Phase 3: Rule Registry Pattern (Optional) Add plugin architecture for future extensibility if needed.
Why This Approach?
- Meets critical requirement: Enables runtime editing of thresholds, severities, and requirements
- Maintains simplicity: Keeps familiar C# rule classes with type safety
- Reduces complexity: Avoids JSON lambda expressions and external dependencies
- Pragmatic: Most chapter-to-chapter differences are in THRESHOLDS (min/max events, severity levels), not rule LOGIC
- Incremental: Can adopt RulesEngine later if rule logic customization becomes critical
What Can Be Customized at Runtime?
With JSON configuration approach, chapters can customize:
- Event count thresholds (MinRecommendedEvents, MaxRecommendedEvents, etc.)
- Requirement toggles (RequireRegionalEvent, RequireOnSiteActivity, etc.)
- Severity levels (Warning vs Error for each rule type)
This covers ~95% of expected chapter-to-chapter variation without rule engine complexity.
When to Adopt RulesEngine?
Only adopt Microsoft RulesEngine if you need:
- Different chapters with fundamentally different rules (not just thresholds)
- Non-developers authoring new validation rules
- Complex rule dependencies/chaining
- Rule versioning and AB testing
Implementation Plan
Phase 1: Core Improvements (No External Dependencies)
1.1 Fix Rule Registration Anti-Pattern
File: Core/Validation/ValidationService.cs
Current Problem:
private void InitializeRules()
{
_studentRules.Add(new NoRegionalEventRule());
_studentRules.Add(new NoOnSiteActivityRule());
// ... hardcoded rule instantiation
}
Solution: Use reflection-based rule discovery or explicit registration API:
// Option A: Reflection-based discovery (simple)
private void InitializeRules()
{
// Auto-discover all IValidationRule<Student> implementations
_studentRules = DiscoverRules<Student>();
_teamRules = DiscoverRules<Team>();
_statisticsRules = DiscoverRules<StudentEventStatistics>();
}
private List<IValidationRule<T>> DiscoverRules<T>()
{
var ruleType = typeof(IValidationRule<T>);
return Assembly.GetExecutingAssembly()
.GetTypes()
.Where(t => t.IsClass && !t.IsAbstract && ruleType.IsAssignableFrom(t))
.Select(t => (IValidationRule<T>)Activator.CreateInstance(t)!)
.ToList();
}
Benefits:
- Adding new rules just requires creating the class
- No need to modify ValidationService
- Rules automatically discovered
1.2 Eliminate Rule Duplication
Files: Student ranking rules vs assignment rules
Current Problem: Near-duplicate code between:
NoRegionalEventRule(Student) vsNoRegionalEventAssignmentRule(StudentEventStatistics)NoOnSiteActivityRule(Student) vsNoOnSiteActivityAssignmentRule(StudentEventStatistics)
Solution: Create abstract base classes:
// Core/Validation/Rules/BaseRules/RequiredEventTypeRule.cs
public abstract class RequiredEventTypeRule<TEntity> : IValidationRule<TEntity>
{
protected abstract string EventTypeName { get; }
protected abstract string IconIdentifier { get; }
protected abstract Func<ValidationConfiguration, bool> IsRequired { get; }
protected abstract Func<ValidationConfiguration, ValidationSeverity> GetSeverity { get; }
protected abstract Func<TEntity, bool> HasEventType { get; }
protected abstract ValidationContext[] ApplicableContexts { get; }
public ValidationWarning? Validate(TEntity entity, ValidationConfiguration config)
{
if (!IsRequired(config)) return null;
if (HasEventType(entity)) return null;
return new ValidationWarning { /* ... */ };
}
public bool AppliesTo(ValidationContext context) =>
ApplicableContexts.Contains(context);
}
Then specific rules become:
public class NoRegionalEventRule : RequiredEventTypeRule<Student>
{
protected override string EventTypeName => "Regional Event";
protected override Func<Student, bool> HasEventType =>
s => s.RankedEvents.Any(e => e.RegionalEvent);
// ... etc
}
Benefits:
- Eliminates duplication
- Easier to maintain
- Consistent behavior across similar rules
1.3 Fix Context Handling Inconsistency
File: Core/Validation/ValidationService.cs
Current Problem: ValidateTeam() doesn't filter by context
Solution:
public List<ValidationWarning> ValidateTeam(Team team, ValidationContext context = ValidationContext.Team)
{
return _teamRules
.Where(rule => rule.AppliesTo(context)) // ADD THIS
.Select(rule => rule.Validate(team, _config))
.Where(warning => warning != null)
.Cast<ValidationWarning>()
.ToList();
}
1.4 Add Rule Singleton Pattern
File: Core/Validation/ValidationService.cs
Current: Rules instantiated per ValidationService instance
Solution: Use lazy static singletons:
private static readonly Lazy<List<IValidationRule<Student>>> _studentRuleDefinitions =
new(() => DiscoverRules<Student>());
public ValidationService(ValidationConfiguration? config = null)
{
_config = config ?? ValidationConfiguration.Default;
_studentRules = _studentRuleDefinitions.Value;
_teamRules = _teamRuleDefinitions.Value;
_statisticsRules = _statisticsRuleDefinitions.Value;
}
Benefits:
- Rules instantiated once per application lifetime
- Reduced memory allocation
- Better performance
Phase 2: JSON Configuration Storage
2.1 Add Configuration Serialization
File: Core/Validation/ValidationConfiguration.cs
Add methods:
public static ValidationConfiguration FromJson(string json)
{
return JsonSerializer.Deserialize<ValidationConfiguration>(json)
?? Default;
}
public static async Task<ValidationConfiguration> LoadFromFileAsync(string path)
{
if (!File.Exists(path)) return Default;
var json = await File.ReadAllTextAsync(path);
return FromJson(json);
}
public async Task SaveToFileAsync(string path)
{
var json = JsonSerializer.Serialize(this, new JsonSerializerOptions
{
WriteIndented = true
});
await File.WriteAllTextAsync(path, json);
}
2.2 Create Default Configuration File
File: Data/validation-config.json (new)
{
"MinRecommendedEvents": 2,
"MaxRecommendedEvents": 4,
"MinCriticalEvents": 1,
"MaxCriticalEvents": 6,
"RequireRegionalEvent": true,
"RequireOnSiteActivity": true,
"RequireIndividualEvent": false,
"RequireTeamCaptain": true,
"NoRegionalEventSeverity": "Warning",
"NoOnSiteActivitySeverity": "Warning",
"NoIndividualEventSeverity": "Warning",
"TeamSizeSeverity": "Warning",
"EventCountSeverity": "Warning",
"MissingCaptainSeverity": "Warning"
}
2.3 Update Service Registration
File: WebApp/Program.cs (lines 176-177)
Current:
builder.Services.AddScoped<Core.Validation.ValidationService>(sp =>
new Core.Validation.ValidationService(Core.Validation.ValidationConfiguration.Default));
New:
// Load validation configuration from Data directory (or use default)
var validationConfigPath = Path.Combine(
builder.Environment.ContentRootPath,
"Data",
"validation-config.json");
var validationConfig = await Core.Validation.ValidationConfiguration
.LoadFromFileAsync(validationConfigPath);
builder.Services.AddScoped<Core.Validation.ValidationService>(sp =>
new Core.Validation.ValidationService(validationConfig));
2.4 Add Configuration UI (Optional)
File: WebApp/Components/Pages/Settings/ValidationSettings.razor (new)
Create admin page to edit validation configuration with live preview.
Phase 3: Optional Enhancements
3.1 Add Short-Circuit Logic
public List<ValidationWarning> ValidateStudentRankings(
Student student,
ValidationContext context,
bool stopOnFirstError = false)
{
var warnings = new List<ValidationWarning>();
foreach (var rule in _studentRules.Where(r => r.AppliesTo(context)))
{
var warning = rule.Validate(student, _config);
if (warning != null)
{
warnings.Add(warning);
if (stopOnFirstError && warning.Severity == ValidationSeverity.Error)
break;
}
}
return warnings;
}
3.2 Add Rule Metadata Interface
public interface IValidationRuleMetadata
{
string Name { get; }
string Description { get; }
string Category { get; }
int Priority { get; } // For ordering
}
public interface IValidationRule<TEntity> : IValidationRuleMetadata
{
ValidationWarning? Validate(TEntity entity, ValidationConfiguration config);
bool AppliesTo(ValidationContext context);
}
Alternative: Full RulesEngine Migration Plan
If you decide runtime rule editing (not just configuration) is critical, here's the Microsoft RulesEngine adoption plan:
Installation
dotnet add package RulesEngine
Migration Strategy
1. Create Rule JSON Files
File: Data/validation-rules.json
{
"StudentRankingWorkflow": {
"WorkflowName": "StudentRankingValidation",
"Rules": [
{
"RuleName": "RequireRegionalEvent",
"Enabled": true,
"Expression": "RequireRegionalEvent AND input.RankedEvents.Any(e => e.RegionalEvent == false)",
"RuleExpressionType": "LambdaExpression",
"ErrorMessage": "No Regional Event",
"ErrorType": "Warning",
"SuccessEvent": "NoRegionalEventWarning"
},
{
"RuleName": "RequireOnSiteActivity",
"Enabled": true,
"Expression": "RequireOnSiteActivity AND input.RankedEvents.Any(e => e.OnSiteActivity == false)",
"RuleExpressionType": "LambdaExpression",
"ErrorMessage": "No On-Site Activity",
"ErrorType": "Warning"
}
]
}
}
2. Create RulesEngine Wrapper
File: Core/Validation/RulesEngineValidationService.cs (new)
using RulesEngine.Models;
public class RulesEngineValidationService
{
private readonly RulesEngine.RulesEngine _rulesEngine;
private readonly ValidationConfiguration _config;
public RulesEngineValidationService(string rulesJsonPath, ValidationConfiguration config)
{
var rulesJson = File.ReadAllText(rulesJsonPath);
var workflows = JsonSerializer.Deserialize<Workflow[]>(rulesJson);
_rulesEngine = new RulesEngine.RulesEngine(workflows);
_config = config;
}
public async Task<List<ValidationWarning>> ValidateStudentRankingsAsync(Student student)
{
var input = new RuleParameter("input", student);
var configParam = new RuleParameter("config", _config);
var results = await _rulesEngine.ExecuteAllRulesAsync(
"StudentRankingValidation",
input,
configParam);
return results
.Where(r => !r.IsSuccess)
.Select(r => new ValidationWarning
{
Code = r.Rule.RuleName,
Message = r.Rule.ErrorMessage,
Severity = r.Rule.ErrorType == "Error"
? ValidationSeverity.Error
: ValidationSeverity.Warning,
Context = ValidationContext.StudentRanking
})
.ToList();
}
}
3. Gradual Migration
- Keep existing ValidationService
- Add RulesEngineValidationService alongside
- Use feature flag to toggle between implementations
- Migrate one context at a time (StudentRanking → StudentAssignment → Team)
Critical Files
Current Implementation
Core/Validation/ValidationService.cs- Main orchestration serviceCore/Validation/ValidationConfiguration.cs- Configuration modelCore/Validation/IValidationRule.cs- Generic rule interfaceCore/Validation/Rules/StudentRankingRules/*.cs- Student ranking rules (3 files)Core/Validation/Rules/StudentAssignmentRules/*.cs- Assignment rules (4 files)Core/Validation/Rules/TeamRules/*.cs- Team rules (3 files)WebApp/Program.cs- Service registration (line 176)
For Improvements
Data/validation-config.json- Runtime configuration file (new)Core/Validation/Rules/BaseRules/*.cs- Abstract base rule classes (new)
For RulesEngine Migration
Data/validation-rules.json- RulesEngine rule definitions (new)Core/Validation/RulesEngineValidationService.cs- Wrapper service (new)
Recommendations Summary
Recommended: Hybrid Improvement Approach
Do This:
- ✅ Fix hardcoded rule registration (reflection-based discovery)
- ✅ Eliminate rule duplication (abstract base classes)
- ✅ Add JSON configuration storage (
Data/validation-config.json) - ✅ Enable per-chapter threshold/severity customization
- ✅ Fix context handling inconsistency
- ✅ Add rule singleton pattern for performance
Benefits:
- Enables runtime customization without complexity
- Maintains type safety and simplicity
- No external dependencies
- Easy to debug and maintain
- Covers 95% of expected customization needs
Estimated Effort: 4-6 hours
Not Recommended (Yet): Full RulesEngine Adoption
Don't Do This Unless:
- Chapters need fundamentally different rule logic (not just thresholds)
- Non-developers need to author rules
- Complex rule dependencies are required
- A/B testing of rules is needed
Complexity Cost:
- All 10 rules rewritten in JSON with lambda strings
- Team must learn RulesEngine syntax and concepts
- Type safety lost (runtime errors vs compile-time)
- Harder debugging (no breakpoints in rule logic)
- External dependency to maintain
Next Steps
- Decision Point: Confirm hybrid approach or full RulesEngine migration
- If Hybrid: Implement Phase 1 improvements (2-3 hours)
- Then: Add JSON configuration storage (1-2 hours)
- Test: Deploy to test chapter with custom config
- Iterate: Add Phase 3 enhancements if needed