Skip to content

Annotation Questions Architecture Analysis

Overview

This document analyzes the current architecture of annotation questions in SyRF and proposes improvements for moving domain logic from the frontend to a more appropriate location. The goal is to ensure business rules are consistently enforced regardless of how data enters the system.

Note: For detailed cross-language validation strategies (JSON Schema code generation, TypeSpec, etc.), see the Cross-Language Validation Strategies section in the formal specification.


Current Architecture

Component Diagram

┌─────────────────────────────────────────────────────────────────────────────┐
│                              CURRENT STATE                                   │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│  ┌─────────────────────────────────────────────────────────────────────┐    │
│  │                         ANGULAR FRONTEND                             │    │
│  │                                                                      │    │
│  │  ┌─────────────────────┐    ┌─────────────────────────────────────┐ │    │
│  │  │  CreateQuestion     │    │  annotation-question.entity.ts      │ │    │
│  │  │  Component          │    │  ─────────────────────────────────  │ │    │
│  │  │  ─────────────────  │    │  • System Question GUIDs           │ │    │
│  │  │  • Category rules   │◄───│  • Category definitions            │ │    │
│  │  │  • Parent selection │    │  • Type definitions                │ │    │
│  │  │  • Control params   │    │  • Tree conversion utilities       │ │    │
│  │  └────────┬────────────┘    └─────────────────────────────────────┘ │    │
│  │           │                                                          │    │
│  │           │ DOMAIN LOGIC                                            │    │
│  │           │ (Frontend-only enforcement)                              │    │
│  │           ▼                                                          │    │
│  │  ┌─────────────────────┐    ┌─────────────────────────────────────┐ │    │
│  │  │  NgRx Store         │    │  API Service                        │ │    │
│  │  │  (State Mgmt)       │    │  (HTTP Client)                      │ │    │
│  │  └─────────────────────┘    └───────────────┬─────────────────────┘ │    │
│  └─────────────────────────────────────────────┼───────────────────────┘    │
│                                                │                             │
│                                                │ HTTP/JSON                   │
│                                                │ (No rule enforcement)       │
│                                                ▼                             │
│  ┌─────────────────────────────────────────────────────────────────────┐    │
│  │                           .NET BACKEND                               │    │
│  │                                                                      │    │
│  │  ┌─────────────────────┐    ┌─────────────────────────────────────┐ │    │
│  │  │  API Controller     │    │  Project Aggregate                  │ │    │
│  │  │  ─────────────────  │───►│  ─────────────────────────────────  │ │    │
│  │  │  • DTO validation   │    │  • Limited validation              │ │    │
│  │  │  • Basic checks     │    │  • No category-specific rules      │ │    │
│  │  └─────────────────────┘    │  • Accepts invalid structures!     │ │    │
│  │                              └─────────────────────────────────────┘ │    │
│  └─────────────────────────────────────────────────────────────────────┘    │
│                                                                              │
│  ┌─────────────────────────────────────────────────────────────────────┐    │
│  │                        DATABASE SEEDING                              │    │
│  │  ─────────────────────────────────────────────────────────────────  │    │
│  │  • NO validation at all                                              │    │
│  │  • Can create invalid question structures                            │    │
│  │  • Bypasses all business rules                                       │    │
│  └─────────────────────────────────────────────────────────────────────┘    │
│                                                                              │
└─────────────────────────────────────────────────────────────────────────────┘

Problems with Current Architecture

Problem Impact Example
Inconsistent Enforcement Data integrity issues Seeded Treatment question without Control parent
Duplicated Logic Maintenance overhead GUID constants in both frontend and backend
API Bypass Risk Invalid data via direct API calls Postman/curl can create invalid structures
Testing Difficulty Integration tests may miss rule violations Seeding creates data that UI would reject
Anemic Domain Model Business logic scattered Rules in CreateQuestionComponent, not domain

Where Logic Currently Lives

┌────────────────────────────────────────────────────────────────────────────┐
│                    LOGIC DISTRIBUTION - CURRENT STATE                       │
├────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  FRONTEND (Angular)                                                         │
│  ══════════════════                                                         │
│                                                                             │
│  ✓ Category-specific parent requirements                                    │
│  ✓ Control question selection for Treatment/DiseaseModel                    │
│  ✓ Root question placement for Study category                               │
│  ✓ Label question as parent for Cohort/Outcome/Experiment                   │
│  ✓ Lookup question option resolution                                        │
│  ✓ Conditional display logic                                                │
│  ✓ Question type UI constraints                                             │
│  ✓ System question visibility rules                                         │
│  ✓ selectAnnotationQuestionsForCurrentStage - NgRx selector for question    │
│    visibility based on stage.extraction (annotation-question.selectors.ts)  │
│  ✓ OutcomeData component hierarchy - Manages unit linking and data entry    │
│    (outcome-data.component.ts, annotation-unit.component.ts)                │
│                                                                             │
│  BACKEND (.NET)                                                              │
│  ═════════════                                                              │
│                                                                             │
│  ✓ Cannot change parent after creation                                      │
│  ✓ Cannot update system questions                                           │
│  ✓ Recursive delete of child questions                                      │
│  ✓ SubquestionIds auto-population                                           │
│  ✓ System question factory methods                                          │
│  ✓ Stage.AllStageAnnotationQuestions - computed property for question       │
│    visibility based on Stage.Extraction boolean (Stage.cs:93-96)            │
│                                                                             │
│  ✗ NO category-specific parent validation                                   │
│  ✗ NO control parameter enforcement                                         │
│  ✗ NO validation of root vs child based on category                         │
│                                                                             │
└────────────────────────────────────────────────────────────────────────────┘

Architectural Options

Move all business rules to the backend domain layer, enforced in the Project aggregate.

┌─────────────────────────────────────────────────────────────────────────────┐
│                        OPTION 1: BACKEND DOMAIN VALIDATION                   │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│  ┌─────────────────────────────────────────────────────────────────────┐    │
│  │                         ANGULAR FRONTEND                             │    │
│  │                                                                      │    │
│  │  • UI/UX concerns only                                               │    │
│  │  • Read constants from API or shared config                          │    │
│  │  • Display validation errors from backend                            │    │
│  │  • Pre-validation for UX (optional, mirrors backend)                 │    │
│  └────────────────────────────────────────────────────────────────┬────┘    │
│                                                                   │         │
│                                                                   ▼         │
│  ┌─────────────────────────────────────────────────────────────────────┐    │
│  │                           .NET BACKEND                               │    │
│  │                                                                      │    │
│  │  ┌───────────────────┐    ┌────────────────────────────────────┐    │    │
│  │  │ API Controller    │    │  Application Service               │    │    │
│  │  │                   │───►│  ────────────────────────────────  │    │    │
│  │  │                   │    │  • Orchestration                   │    │    │
│  │  └───────────────────┘    │  • Transaction management          │    │    │
│  │                            └────────────┬───────────────────────┘    │    │
│  │                                         │                            │    │
│  │                                         ▼                            │    │
│  │  ┌──────────────────────────────────────────────────────────────┐   │    │
│  │  │                    DOMAIN LAYER (Core)                        │   │    │
│  │  │  ════════════════════════════════════════════════════════    │   │    │
│  │  │                                                               │   │    │
│  │  │  ┌──────────────────────────────────────────────────────┐    │   │    │
│  │  │  │  Project Aggregate                                    │    │   │    │
│  │  │  │  ──────────────────────────────────────────────────  │    │   │    │
│  │  │  │                                                       │    │   │    │
│  │  │  │  UpsertCustomAnnotationQuestion(dto)                  │    │   │    │
│  │  │  │  {                                                    │    │   │    │
│  │  │  │    // ENFORCE ALL BUSINESS RULES HERE                 │    │   │    │
│  │  │  │    ValidateCategoryParentRules(dto);                  │    │   │    │
│  │  │  │    ValidateControlParameterIfRequired(dto);           │    │   │    │
│  │  │  │    ValidateConditionalAnswers(dto);                   │    │   │    │
│  │  │  │  }                                                    │    │   │    │
│  │  │  └──────────────────────────────────────────────────────┘    │   │    │
│  │  │                                                               │   │    │
│  │  │  ┌──────────────────────────────────────────────────────┐    │   │    │
│  │  │  │  AnnotationQuestionRules (Static/Value Object)        │    │   │    │
│  │  │  │  ──────────────────────────────────────────────────  │    │   │    │
│  │  │  │  • GetRequiredParentForCategory(category)             │    │   │    │
│  │  │  │  • RequiresControlParameter(category)                 │    │   │    │
│  │  │  │  • IsRootOnlyCategory(category)                       │    │   │    │
│  │  │  └──────────────────────────────────────────────────────┘    │   │    │
│  │  │                                                               │   │    │
│  │  └──────────────────────────────────────────────────────────────┘   │    │
│  └─────────────────────────────────────────────────────────────────────┘    │
│                                                                              │
└─────────────────────────────────────────────────────────────────────────────┘

Pros:

  • Single source of truth for business rules
  • All data paths (API, seeding, migrations) validated consistently
  • Rich domain model follows DDD best practices
  • Frontend stays thin and UI-focused
  • Easier to unit test domain logic

Cons:

  • Requires backend changes
  • Frontend may show less immediate feedback (can be mitigated with pre-validation)

Option 2: Shared Rules Library

Create a shared rules library that can be used by both frontend and backend.

┌─────────────────────────────────────────────────────────────────────────────┐
│                       OPTION 2: SHARED RULES LIBRARY                         │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│                  ┌──────────────────────────────────────┐                   │
│                  │     SHARED RULES LIBRARY              │                   │
│                  │  ───────────────────────────────────  │                   │
│                  │                                       │                   │
│                  │  • TypeScript/C# dual implementation  │                   │
│                  │  • Category parent rules              │                   │
│                  │  • System question GUIDs              │                   │
│                  │  • Validation functions               │                   │
│                  │                                       │                   │
│                  └────────────────┬─────────────────────┘                   │
│                                   │                                          │
│                    ┌──────────────┴──────────────┐                          │
│                    │                             │                          │
│                    ▼                             ▼                          │
│  ┌──────────────────────────┐    ┌──────────────────────────┐              │
│  │  Angular Frontend        │    │  .NET Backend            │              │
│  │  ─────────────────────   │    │  ─────────────────────   │              │
│  │  • Import shared rules   │    │  • Import shared rules   │              │
│  │  • Pre-validate UI       │    │  • Enforce in domain     │              │
│  │  • Same constants        │    │  • Same constants        │              │
│  └──────────────────────────┘    └──────────────────────────┘              │
│                                                                              │
└─────────────────────────────────────────────────────────────────────────────┘

Pros:

  • Guaranteed consistency between frontend and backend
  • Single definition of rules
  • Frontend can provide immediate feedback
  • Easier to keep in sync

Cons:

  • Complex build/publish pipeline for shared code
  • TypeScript ↔ C# interop challenges
  • Additional project maintenance overhead
  • May still need backend enforcement anyway

Option 3: API Contract Enforcement

Enforce rules at the API boundary with comprehensive DTO validation.

┌─────────────────────────────────────────────────────────────────────────────┐
│                      OPTION 3: API CONTRACT ENFORCEMENT                      │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│  ┌─────────────────────────────────────────────────────────────────────┐    │
│  │                           API LAYER                                  │    │
│  │                                                                      │    │
│  │  ┌────────────────────────────────────────────────────────────────┐ │    │
│  │  │  UpsertAnnotationQuestionDto                                    │ │    │
│  │  │  ──────────────────────────────────────────────────────────── │ │    │
│  │  │                                                                 │ │    │
│  │  │  [CustomValidation(typeof(CategoryParentValidator))]            │ │    │
│  │  │  public string Category { get; set; }                           │ │    │
│  │  │                                                                 │ │    │
│  │  │  [CustomValidation(typeof(TargetValidator))]                    │ │    │
│  │  │  public TargetDto? Target { get; set; }                         │ │    │
│  │  │                                                                 │ │    │
│  │  │  [CustomValidation(typeof(ControlParameterValidator))]          │ │    │
│  │  │  public bool? IsControl { get; set; }                           │ │    │
│  │  └────────────────────────────────────────────────────────────────┘ │    │
│  │                                                                      │    │
│  │  ┌────────────────────────────────────────────────────────────────┐ │    │
│  │  │  FluentValidation Rules                                         │ │    │
│  │  │  ──────────────────────────────────────────────────────────── │ │    │
│  │  │                                                                 │ │    │
│  │  │  RuleFor(x => x.Target.ParentId)                                │ │    │
│  │  │    .Must((dto, parentId) => ValidParentForCategory(dto, parentId))│ │    │
│  │  │    .WithMessage("Invalid parent for category");                 │ │    │
│  │  └────────────────────────────────────────────────────────────────┘ │    │
│  └─────────────────────────────────────────────────────────────────────┘    │
│                                                                              │
└─────────────────────────────────────────────────────────────────────────────┘

Pros:

  • Clear API contracts
  • Validation before domain layer
  • Good error messages in HTTP responses
  • Works with API documentation (Swagger)

Cons:

  • Validation outside domain layer (anemic domain)
  • Doesn't protect against direct database writes
  • Rules may drift from domain understanding
  • Seeding still bypasses these validators

Combine Options 1 and 3 for defense in depth:

┌─────────────────────────────────────────────────────────────────────────────┐
│                    RECOMMENDED: LAYERED VALIDATION                           │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│  ┌─────────────────────────────────────────────────────────────────────┐    │
│  │  LAYER 1: FRONTEND (UX Optimization)                                 │    │
│  │  ───────────────────────────────────                                 │    │
│  │  • Pre-validate to avoid round-trips                                 │    │
│  │  • Read rules from shared constants (API endpoint or config)         │    │
│  │  • Show immediate feedback on invalid configurations                 │    │
│  │  • NOT authoritative - backend has final say                         │    │
│  └─────────────────────────────────────────────────────────────────────┘    │
│                                         │                                    │
│                                         ▼                                    │
│  ┌─────────────────────────────────────────────────────────────────────┐    │
│  │  LAYER 2: API (Contract Validation)                                  │    │
│  │  ──────────────────────────────────                                  │    │
│  │  • FluentValidation on DTOs                                          │    │
│  │  • Clear error messages for API consumers                            │    │
│  │  • Validates before hitting domain                                   │    │
│  │  • Documents rules via Swagger                                       │    │
│  └─────────────────────────────────────────────────────────────────────┘    │
│                                         │                                    │
│                                         ▼                                    │
│  ┌─────────────────────────────────────────────────────────────────────┐    │
│  │  LAYER 3: DOMAIN (Authoritative Rules)                               │    │
│  │  ─────────────────────────────────────                               │    │
│  │  • Project.UpsertCustomAnnotationQuestion() enforces ALL rules       │    │
│  │  • Throws DomainException for violations                             │    │
│  │  • SINGLE SOURCE OF TRUTH                                            │    │
│  │  • Used by seeding, migrations, direct domain access                 │    │
│  └─────────────────────────────────────────────────────────────────────┘    │
│                                                                              │
└─────────────────────────────────────────────────────────────────────────────┘

Implementation Plan

Phase 1: Domain Layer Rules (Priority: High)

Create domain validation in Project aggregate:

// SyRF.ProjectManagement.Core/Model/ProjectAggregate/Project.cs

public void UpsertCustomAnnotationQuestion(AnnotationQuestionDto dto)
{
    // Existing validations...
    Guard.Against.Null(dto, nameof(dto));

    if (existingQuestion?.System == true)
        throw new DomainException("Cannot modify system questions");

    // NEW: Category-specific parent validation
    ValidateCategoryParentRequirements(dto);

    // NEW: Control parameter validation
    ValidateControlParameter(dto);

    // ... existing logic
}

private void ValidateCategoryParentRequirements(AnnotationQuestionDto dto)
{
    var requiredSystemParent = AnnotationQuestionRules.GetRequiredParentForCategory(dto.Category);

    if (requiredSystemParent == null) return; // Study category - no requirement (can be root)

    if (dto.Target?.ParentId == null)
        throw new DomainException($"Questions in {dto.Category} must have a parent");

    // Parent can be either:
    // 1. The category's system question (first-level custom question)
    // 2. Another custom question in the same category (nested custom question)
    if (dto.Target.ParentId == requiredSystemParent)
        return; // Valid: first-level custom question

    var parentQuestion = _annotationQuestions.FirstOrDefault(q => q.Id == dto.Target.ParentId);
    if (parentQuestion == null)
        throw new DomainException($"Parent question {dto.Target.ParentId} not found");

    if (parentQuestion.Category != dto.Category)
        throw new DomainException($"Nested questions must have parent in same category");

    if (parentQuestion.System)
        throw new DomainException($"First-level questions must parent to {requiredSystemParent}");

    // Valid: nested custom question parenting to another custom question in same category
}

private void ValidateControlParameter(AnnotationQuestionDto dto)
{
    if (!AnnotationQuestionRules.RequiresControlParameter(dto.Category))
        return;

    // Control parameter must be specified for Treatment/DiseaseModel
    if (dto.Target?.ConditionalParentAnswers == null)
        throw new DomainException($"{dto.Category} questions must specify control parameter");
}

Phase 2: Rules Value Object

Create a value object for rules:

// SyRF.ProjectManagement.Core/Model/ProjectAggregate/AnnotationQuestionRules.cs

public static class AnnotationQuestionRules
{
    public static Guid? GetRequiredParentForCategory(string category) => category switch
    {
        "Study" => null, // Root questions allowed
        "Cohort" => SystemAnnotationQuestionGuids.CohortLabel,
        "Disease Model Induction" => SystemAnnotationQuestionGuids.ModelControl,
        "Treatment" => SystemAnnotationQuestionGuids.TreatmentControl,
        "Outcome Assessment" => SystemAnnotationQuestionGuids.OutcomeLabel,
        "Experiment" => SystemAnnotationQuestionGuids.ExperimentLabel,
        _ => throw new ArgumentException($"Unknown category: {category}")
    };

    public static bool RequiresControlParameter(string category) =>
        category is "Disease Model Induction" or "Treatment";

    public static bool IsRootOnlyCategory(string category) =>
        category == "Study";

    public static IReadOnlyList<string> CategoriesWithControlQuestions =>
        new[] { "Disease Model Induction", "Treatment" };
}

Add FluentValidation for better API error messages:

// SyRF.API.Endpoint/Validators/UpsertAnnotationQuestionValidator.cs

public class UpsertAnnotationQuestionValidator : AbstractValidator<UpsertAnnotationQuestionDto>
{
    public UpsertAnnotationQuestionValidator()
    {
        RuleFor(x => x.Category)
            .NotEmpty()
            .Must(BeValidCategory)
            .WithMessage("Invalid category");

        RuleFor(x => x)
            .Must(HaveValidParentForCategory)
            .WithMessage(x => $"Questions in {x.Category} require a specific parent");

        RuleFor(x => x)
            .Must(HaveControlParameterIfRequired)
            .WithMessage(x => $"{x.Category} questions must specify control parameter");
    }

    private bool HaveValidParentForCategory(UpsertAnnotationQuestionDto dto)
    {
        var requiredParent = AnnotationQuestionRules.GetRequiredParentForCategory(dto.Category);

        // Study category: no parent requirement (can be root)
        if (requiredParent == null)
            return true;

        // Must have a parent
        if (dto.Target?.ParentId == null)
            return false;

        // First-level: parent is the system question
        if (dto.Target.ParentId == requiredParent)
            return true;

        // Nested: parent must be a valid custom question in same category
        // (Full validation happens in domain layer - API just checks parent exists)
        return dto.Target.ParentId != null;
    }

    private bool HaveControlParameterIfRequired(UpsertAnnotationQuestionDto dto)
    {
        if (!AnnotationQuestionRules.RequiresControlParameter(dto.Category))
            return true;
        return dto.Target?.ConditionalParentAnswers != null;
    }
}

Phase 4: Update Seeding

Update seeding to use domain methods:

// SyRF.ProjectManagement.Endpoint/Seeding/ProjectSeeder.cs

public async Task SeedAsync(Project project)
{
    // Use domain methods that enforce rules
    foreach (var questionDto in seedQuestions)
    {
        project.UpsertCustomAnnotationQuestion(questionDto);
    }

    // OR: Create questions with proper parent references
    var treatmentQuestion = new AnnotationQuestionDto
    {
        Category = "Treatment",
        Target = new TargetDto
        {
            ParentId = SystemAnnotationQuestionGuids.TreatmentControl,
            ConditionalParentAnswers = new BooleanConditionalDto { TargetParentBoolean = false }
        }
        // ... other properties
    };
}

Phase 5: Frontend Updates (Optional)

Optionally update frontend to read rules from API:

// annotation-question-rules.service.ts

@Injectable({ providedIn: 'root' })
export class AnnotationQuestionRulesService {
  private rules$ = this.http.get<AnnotationQuestionRules>('/api/rules/annotation-questions');

  getRequiredParentForCategory(category: Category): Observable<string | null> {
    return this.rules$.pipe(
      map(rules => rules.categoryParentRequirements[category] ?? null)
    );
  }
}

Migration Strategy

Step 1: Add Backend Validation (Non-Breaking)

  1. Add validation logic to domain layer
  2. Log warnings instead of throwing initially
  3. Monitor for violations in existing data

Step 2: Fix Existing Data

  1. Identify questions that violate rules
  2. Create migration to fix invalid parent references
  3. Run migration in staging first

Step 3: Enable Enforcement

  1. Switch from logging to throwing exceptions
  2. Update frontend to handle validation errors
  3. Update seeding to follow rules

Step 4: Cleanup Frontend Logic

  1. Remove redundant validation from frontend
  2. Keep pre-validation for UX if desired
  3. Update to read constants from API/config

Comparison Summary

Aspect Current Proposed
Rule Location Frontend only Domain layer (authoritative)
Seeding Safety ❌ Unsafe ✅ Validated
API Safety ❌ Unsafe ✅ Validated
Consistency ❌ Can drift ✅ Single source
Testing ❌ Hard to test ✅ Unit testable
Maintainability ❌ Duplicated ✅ Centralized

Decision

Recommended: Implement layered validation with Domain Layer as Source of Truth

Rationale:

  1. Follows DDD and Clean Architecture principles
  2. Protects all data entry paths
  3. Makes rules testable and maintainable
  4. Allows frontend to remain thin and UI-focused
  5. Provides defense in depth with API validation

Appendix: Files to Modify

Backend Files

File Changes
Project.cs Add validation methods
AnnotationQuestionRules.cs New - rules value object
UpsertAnnotationQuestionValidator.cs New - FluentValidation (optional)
ProjectSeeder.cs Update to use domain methods
SystemAnnotationQuestionGuids.cs Move from frontend or keep in sync

Frontend Files (Optional Cleanup)

File Changes
create-question.component.ts Remove/simplify domain logic
annotation-question.entity.ts Read GUIDs from API or shared config
annotation-question-rules.service.ts New - fetch rules from API