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.
Related Documentation¶
- Annotation Questions Business Logic - Core business rules
- Formal Specification - Precise rules, cross-language validation, referential integrity
- Category Question Structure - Detailed category rules
- Question Hierarchy Diagrams - Visual representations
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¶
Option 1: Backend Domain Validation (Recommended)¶
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
Recommended Approach: Layered Validation¶
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" };
}
Phase 3: API Validation (Optional but Recommended)¶
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)¶
- Add validation logic to domain layer
- Log warnings instead of throwing initially
- Monitor for violations in existing data
Step 2: Fix Existing Data¶
- Identify questions that violate rules
- Create migration to fix invalid parent references
- Run migration in staging first
Step 3: Enable Enforcement¶
- Switch from logging to throwing exceptions
- Update frontend to handle validation errors
- Update seeding to follow rules
Step 4: Cleanup Frontend Logic¶
- Remove redundant validation from frontend
- Keep pre-validation for UX if desired
- 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:
- Follows DDD and Clean Architecture principles
- Protects all data entry paths
- Makes rules testable and maintainable
- Allows frontend to remain thin and UI-focused
- 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 |