Phase 3: Collection Infrastructure¶
Implementation plan reference (frozen). Originally captured as GSD-style plans in
.planning/phases/03-collection-infrastructure/on PR #2398. The phase was researched and planned but not yet executed. Migrated here so the design rationale, code skeletons, and pitfalls survive the cleanup of.planning/.When this work is picked up: Use this as a design reference. Execute through whatever workflow is current at the time — the original GSD execution loop is no longer in active use.
Phase goal: Two new MongoDB collections (pmAnnotationQuestion and pmQuestionSet) and foundational patterns (scope fields, optimistic concurrency) exist and are ready for domain entities.
Requirements addressed: ARCH-03, ARCH-04, ARCH-05, ARCH-06 (see requirements tracker)
Depends on: Phase 2 (PRISMA constraints inform field design)
Plan structure: Three sub-plans, all in Wave 1 (parallel-safe with care around shared file edits in IPmUnitOfWork.cs / MongoPmUnitOfWork.cs).
| Plan | Subject | Files |
|---|---|---|
| 03-01 | pmAnnotationQuestion collection |
New entity + repository + UoW wiring |
| 03-02 | pmQuestionSet collection |
New entity + repository + UoW wiring |
| 03-03 | Study optimistic concurrency fix | New SaveWithConcurrencyCheckAsync extension + ConcurrencyException |
Research Summary¶
Researched: 2026-02-18 Confidence: HIGH (all patterns verified by reading source)
Phase 3 creates two new MongoDB collections and fixes the optimistic concurrency mechanism for Study writes. The codebase has a mature, well-established pattern for new collections — extending AggregateRoot<Guid>, implementing MongoRepositoryBase<T, Guid>, registering in IPmUnitOfWork / MongoPmUnitOfWork, and relying on Lamar scan conventions for DI discovery. No new libraries or frameworks are needed.
The optimistic concurrency fix is the highest-risk item. The current MongoExtensions.SaveAsync() uses IsUpsert = true on all ReplaceOne operations — a version mismatch silently creates a duplicate document rather than rejecting the write. For Study documents, this must be changed to IsUpsert = false with explicit MatchedCount == 0 checking and a new ConcurrencyException.
Primary recommendation: Follow the existing repository pattern exactly for both new collections. Add a NEW concurrency-checked save method (don't modify the existing SaveAsync) to keep blast radius zero outside Study.
Standard Stack¶
All infrastructure already exists in the codebase. No new packages.
| Library | Purpose |
|---|---|
| MongoDB.Driver 3.x (already in codebase) | IMongoCollection, FilterDefinition, ReplaceOneAsync, BulkWriteAsync |
SyRF.SharedKernel (internal) |
Entity<TId>, AggregateRoot<TId>, Audit, base interfaces |
SyRF.Mongo.Common (internal) |
MongoRepositoryBase<T,TId>, MongoContext, MongoExtensions, MongoUnitOfWorkBase |
| Lamar | DI scan conventions for auto-wiring |
| MongoDB.Bson | BsonClassMap, BsonExtraElements for schema evolution |
Recommended Project Structure¶
src/libs/project-management/
├── SyRF.ProjectManagement.Core/
│ ├── Model/
│ │ ├── AnnotationQuestionAggregate/ # NEW (03-01)
│ │ │ └── AnnotationQuestion.cs # AggregateRoot<Guid>
│ │ └── QuestionSetAggregate/ # NEW (03-02)
│ │ └── QuestionSet.cs # AggregateRoot<Guid>
│ └── Interfaces/
│ ├── IAnnotationQuestionRepository.cs # NEW (03-01)
│ ├── IQuestionSetRepository.cs # NEW (03-02)
│ └── IPmUnitOfWork.cs # MODIFIED — add new repository properties
├── SyRF.ProjectManagement.Mongo.Data/
│ ├── Repositories/
│ │ ├── AnnotationQuestionRepository.cs # NEW (03-01)
│ │ └── QuestionSetRepository.cs # NEW (03-02)
│ └── MongoPmUnitOfWork.cs # MODIFIED — wire new repositories
src/libs/mongo/SyRF.Mongo.Common/
├── ConcurrencyException.cs # NEW (03-03)
└── MongoExtensions.cs # MODIFIED — add SaveWithConcurrencyCheckAsync
Established Patterns to Follow¶
Pattern 1 — New Collection Entity: Extend AggregateRoot<Guid>. The class must be in a namespace starting with SyRF.ProjectManagement so MongoContext.GetBoundedContextCode() returns the pm prefix.
namespace SyRF.ProjectManagement.Core.Model.AnnotationQuestionAggregate;
public class AnnotationQuestion : AggregateRoot<Guid>
{
// ARCH-03 fields
public Guid ProjectId { get; set; } // Scope
public Guid? OwnerId { get; set; } // Owner
public Guid? DerivedFromId { get; set; } // Lineage
public AnnotationQuestion(Guid id) : base(id) { DefaultSchemaVersion = 1; }
public AnnotationQuestion() : base(Guid.NewGuid()) { DefaultSchemaVersion = 1; }
}
Pattern 2 — Repository Interface + Implementation: Interface extends ICrudRepository<T, Guid>; implementation extends MongoRepositoryBase<T, Guid>.
public interface IAnnotationQuestionRepository : ICrudRepository<AnnotationQuestion, Guid> { }
public class AnnotationQuestionRepository
: MongoRepositoryBase<AnnotationQuestion, Guid>, IAnnotationQuestionRepository
{
public AnnotationQuestionRepository(
MongoContext context,
RepositoryCache<Guid, AnnotationQuestion> cache,
ILogger<AnnotationQuestionRepository> logger,
Func<IPmUnitOfWork> unitOfWork
) : base(context, cache, logger, unitOfWork) { }
public override async Task InitialiseIndexesAsync()
{
await CreateAscendingIndexAsync(aq => aq.ProjectId);
await CreateAscendingIndexAsync(aq => aq.ProjectId, aq => aq.OwnerId);
}
public override void CreateMappings()
{
if (!BsonClassMap.IsClassMapRegistered(typeof(AnnotationQuestion)))
{
BsonClassMap.RegisterClassMap<AnnotationQuestion>(cm =>
{
cm.AutoMap();
cm.SetIgnoreExtraElements(true);
});
}
}
}
Pattern 3 — Wire into UnitOfWork: Add to both IPmUnitOfWork and MongoPmUnitOfWork. Use AddRepository(...) in the constructor and a typed accessor property.
Pattern 4 — Collection Naming (automatic): Class AnnotationQuestion in namespace SyRF.ProjectManagement.Core.Model.AnnotationQuestionAggregate becomes collection pmAnnotationQuestion. Class QuestionSet becomes pmQuestionSet.
Pattern 5 — DI Auto-Discovery: Lamar's WithDefaultConventions() auto-maps IXxxRepository to XxxRepository by name. No manual DI registration needed if naming convention is followed.
Anti-Patterns to Avoid¶
- Embedding AggregateRoot in another document: AnnotationQuestion and QuestionSet must be standalone collections, not embedded in Project. Use
Entity<Guid>for embedded objects,AggregateRoot<Guid>for collections. - Manual DI registration: Follow Lamar naming conventions — manual
For<>().Use<>()calls are anti-pattern here. - Wrong namespace: Entity classes must be in
SyRF.ProjectManagement.*namespace to get thepmprefix. - Skipping BsonClassMap registration: Always implement
CreateMappings()for forward-compatible deserialization withSetIgnoreExtraElements(true).
Key Pitfalls¶
Pitfall 1 — IsUpsert=true creates duplicates on version mismatch (CRITICAL)¶
The current MongoExtensions.SaveAsync() uses IsUpsert = true on ReplaceOneAsync. When two concurrent saves target the same document:
- Save A succeeds (version filter matches).
- Save B's version filter no longer matches.
- Because
IsUpsert = true, MongoDB inserts a new document with a different_idinstead of rejecting.
Result: Duplicate Study documents with the same logical identity but different _id values. MatchedCount = 0 and ModifiedCount = 0 in ReplaceOneResult with an implicit insert.
Fix scope (Plan 03-03): Add a new SaveWithConcurrencyCheckAsync method that uses IsUpsert = false and throws ConcurrencyException when MatchedCount == 0 for existing entities. Keep the existing SaveAsync untouched to avoid blast radius.
Pitfall 2 — Naming collision with existing embedded AnnotationQuestion¶
There is already an AnnotationQuestion class at SyRF.ProjectManagement.Core.Model.ProjectAggregate.AnnotationQuestion (extends Entity<Guid>, embedded in Project). The new standalone class shares the name.
Fix: Place the new entity in AnnotationQuestionAggregate namespace. Use namespace aliases in MongoPmUnitOfWork.cs where both are referenced:
using AnnotationQuestionEntity = SyRF.ProjectManagement.Core.Model.AnnotationQuestionAggregate.AnnotationQuestion;
Pitfall 3 — Missing BsonClassMap registration¶
Complex types with enums or schema evolution may not serialize correctly without explicit BsonClassMap registration. Always implement CreateMappings().
Pitfall 4 — Forgetting to add to MongoPmUnitOfWork¶
DI auto-discovers the interface→implementation mapping, but the UnitOfWork's internal _crudRepositoryDictionary needs explicit AddRepository() calls. Checklist for every new repository:
- Add constructor parameter
- Call
AddRepository(...)in constructor body - Add typed property accessor on
MongoPmUnitOfWork - Add property declaration on
IPmUnitOfWork
Pitfall 5 — ARCH-03 indexes must support PRISMA scope queries¶
Index ProjectId first in compound indexes. PRISMA queries will filter by project then aggregate across questions.
Plan 03-01: pmAnnotationQuestion collection¶
Files modified:
src/libs/project-management/SyRF.ProjectManagement.Core/Model/AnnotationQuestionAggregate/AnnotationQuestion.cs(NEW)src/libs/project-management/SyRF.ProjectManagement.Core/Interfaces/IAnnotationQuestionRepository.cs(NEW)src/libs/project-management/SyRF.ProjectManagement.Core/Interfaces/IPmUnitOfWork.cs(MODIFIED)src/libs/project-management/SyRF.ProjectManagement.Mongo.Data/Repositories/AnnotationQuestionRepository.cs(NEW)src/libs/project-management/SyRF.ProjectManagement.Mongo.Data/MongoPmUnitOfWork.cs(MODIFIED)
Objective¶
Create the standalone pmAnnotationQuestion MongoDB collection with its entity, repository, indexes, and UnitOfWork wiring.
Purpose: Foundation for the question versioning system. Unlike the existing embedded AnnotationQuestion (which is Entity<Guid> inside Project), this standalone collection uses AggregateRoot<Guid> and supports independent versioning, scope fields (ARCH-03), and PRISMA-compatible cross-project queries.
Task 1: Create AnnotationQuestion entity and repository interface¶
Create the standalone entity in a new namespace to avoid collision with the existing embedded AnnotationQuestion:
- Namespace:
SyRF.ProjectManagement.Core.Model.AnnotationQuestionAggregate - Class extends
AggregateRoot<Guid> - ARCH-03 fields:
ProjectId,OwnerId?,DerivedFromId? - Standard constructor pattern + parameterless constructor for MongoDB deserialization
- No domain-specific fields yet — those come in Phase 4 (Question Lifecycle). This phase creates infrastructure only.
Repository interface extends ICrudRepository<AnnotationQuestion, Guid> with no extra methods (Phase 4 adds domain-specific queries).
Verify: dotnet build src/libs/project-management/SyRF.ProjectManagement.Core/SyRF.ProjectManagement.Core.csproj — must compile.
Task 2: Create repository implementation and wire into UnitOfWork¶
Repository implementation follows DataExportJobRepository.cs pattern exactly. Key indexes:
public override async Task InitialiseIndexesAsync()
{
// ARCH-03 primary scope index — essential for project-level queries
await CreateAscendingIndexAsync(aq => aq.ProjectId);
// Compound index for owner-scoped queries within a project
await CreateAscendingIndexAsync(aq => aq.ProjectId, aq => aq.OwnerId);
// Lineage queries for version chains
await CreateAscendingIndexAsync(aq => aq.DerivedFromId);
}
IPmUnitOfWork change: Add IAnnotationQuestionRepository AnnotationQuestions { get; }.
MongoPmUnitOfWork changes:
- Use a type alias to disambiguate from the embedded entity:
- Add constructor parameter
IAnnotationQuestionRepository annotationQuestionRepository - Call
AddRepository(annotationQuestionRepository)in constructor body - Add typed accessor:
Verify: dotnet build src/services/project-management/project-management.slnf — must compile cleanly.
Success criteria¶
pmAnnotationQuestioncollection infrastructure fully wired- ARCH-03 fields present:
ProjectId,OwnerId,DerivedFromId - Scope-based indexes:
ProjectId,ProjectId+OwnerId,DerivedFromId - BsonClassMap registered with
SetIgnoreExtraElements(true) - No namespace collision with existing embedded
AnnotationQuestion
Plan 03-02: pmQuestionSet collection¶
Files modified:
src/libs/project-management/SyRF.ProjectManagement.Core/Model/QuestionSetAggregate/QuestionSet.cs(NEW)src/libs/project-management/SyRF.ProjectManagement.Core/Interfaces/IQuestionSetRepository.cs(NEW)src/libs/project-management/SyRF.ProjectManagement.Core/Interfaces/IPmUnitOfWork.cs(MODIFIED — alongside 03-01's additions)src/libs/project-management/SyRF.ProjectManagement.Mongo.Data/Repositories/QuestionSetRepository.cs(NEW)src/libs/project-management/SyRF.ProjectManagement.Mongo.Data/MongoPmUnitOfWork.cs(MODIFIED — alongside 03-01's additions)
Wave note: Plans 03-01 and 03-02 both modify
IPmUnitOfWork.csandMongoPmUnitOfWork.cs. Read the current file state before editing — additions from one plan should preserve additions from the other.
Objective¶
Create the standalone pmQuestionSet MongoDB collection.
Purpose: QuestionSets group AnnotationQuestions into reusable, versioned sets that can be assigned to stages. Each QuestionSet has scope fields (ARCH-03) for cross-project sharing and a Published flag for lifecycle management. The Published index enables efficient querying of active sets (ARCH-06).
Task 1: Create QuestionSet entity and repository interface¶
- Namespace:
SyRF.ProjectManagement.Core.Model.QuestionSetAggregate - Class extends
AggregateRoot<Guid> - ARCH-03 fields:
ProjectId,OwnerId?,DerivedFromId? - Additional infrastructure field:
public bool Published { get; set; }— used for indexed queries during stage activation - Standard constructor patterns
- No domain-specific fields yet (question membership, version info come in Phase 4)
No naming collision concerns — QuestionSet is unique across the codebase.
Verify: Core project compiles cleanly.
Task 2: Create repository implementation and wire into UnitOfWork¶
Indexes (note the published-flag compound index required for ARCH-06):
public override async Task InitialiseIndexesAsync()
{
await CreateAscendingIndexAsync(qs => qs.ProjectId);
// ARCH-06: efficient query for "active question sets in a project"
await CreateAscendingIndexAsync(qs => qs.ProjectId, qs => qs.Published);
await CreateAscendingIndexAsync(qs => qs.ProjectId, qs => qs.OwnerId);
await CreateAscendingIndexAsync(qs => qs.DerivedFromId);
}
UnitOfWork changes mirror plan 03-01: interface property, constructor parameter, AddRepository(...), typed accessor. No type alias needed since QuestionSet is unique.
Verify: dotnet build src/services/project-management/project-management.slnf — must compile cleanly.
Success criteria¶
pmQuestionSetcollection infrastructure fully wired- ARCH-03 fields present:
ProjectId,OwnerId,DerivedFromId Publishedflag for lifecycle management- Indexes:
ProjectId,ProjectId+Published(ARCH-06),ProjectId+OwnerId,DerivedFromId - BsonClassMap registered
Plan 03-03: Study optimistic concurrency¶
Files modified:
src/libs/mongo/SyRF.Mongo.Common/ConcurrencyException.cs(NEW)src/libs/mongo/SyRF.Mongo.Common/MongoExtensions.cs(MODIFIED — additions only)
Objective¶
Add optimistic concurrency protection to Study document writes by creating a new SaveWithConcurrencyCheckAsync extension method that uses IsUpsert = false for existing entities and throws ConcurrencyException on version mismatch.
Critical design rationale: Add NEW methods rather than modifying the existing SaveAsync/Save. The existing methods are used by every entity in the system; changing their IsUpsert semantics globally is too risky. The new method is opt-in. Wiring it into Study save paths happens in Phase 4+.
Task 1: Create ConcurrencyException and SaveWithConcurrencyCheckAsync¶
ConcurrencyException.cs¶
namespace SyRF.Mongo.Common;
public class ConcurrencyException : Exception
{
public ConcurrencyException(string message) : base(message) { }
public ConcurrencyException(string message, Exception innerException)
: base(message, innerException) { }
}
Add to MongoExtensions.cs (after the existing SaveAsync)¶
public static async Task<ReplaceOneResult> SaveWithConcurrencyCheckAsync<TAggregateRoot, TId>(
this IMongoCollection<TAggregateRoot> collection,
TAggregateRoot aggregateRoot,
int schemaVersion,
string appVersion,
Guid? userId = null,
IClientSessionHandle? session = null)
where TAggregateRoot : IAggregateRoot<TId>
where TId : notnull, IEquatable<TId>, IComparable<TId>
{
var currentVersion = aggregateRoot.Version;
aggregateRoot.OnSaving(schemaVersion, appVersion, userId);
var filter = GetFilter<TAggregateRoot, TId>(aggregateRoot.Id, currentVersion);
// For new entities (version 0 before OnSaving incremented): use upsert
// For existing entities (version > 0): strict replace, throw on mismatch
var isNewEntity = currentVersion == 0;
var result = session == null
? await collection.ReplaceOneAsync(filter, aggregateRoot,
new ReplaceOptions { IsUpsert = isNewEntity })
: await collection.ReplaceOneAsync(session, filter, aggregateRoot,
new ReplaceOptions { IsUpsert = isNewEntity });
if (!isNewEntity && result.MatchedCount == 0)
{
throw new ConcurrencyException(
$"Concurrency conflict: {typeof(TAggregateRoot).Name} with id {aggregateRoot.Id} " +
$"was modified by another operation (expected version {currentVersion})");
}
return result;
}
A sync variant (SaveWithConcurrencyCheck) follows the same pattern using collection.ReplaceOne(...).
Bulk-write helper¶
public static ReplaceOneModel<TAggregateRoot> CreateConcurrencyCheckedReplaceModel<TAggregateRoot, TId>(
TAggregateRoot aggregateRoot, int schemaVersion, string appVersion, Guid? userId = null)
where TAggregateRoot : IAggregateRoot<TId>
where TId : notnull, IEquatable<TId>, IComparable<TId>
{
var currentVersion = aggregateRoot.Version;
var filter = GetFilter<TAggregateRoot, TId>(aggregateRoot.Id, currentVersion);
aggregateRoot.OnSaving(schemaVersion, appVersion, userId);
var isNewEntity = currentVersion == 0;
return new ReplaceOneModel<TAggregateRoot>(filter, aggregateRoot) { IsUpsert = isNewEntity };
}
Important: BulkWrite does NOT throw on individual match failures — callers building batches with this helper must compare BulkWriteResult.MatchedCount against the expected count and surface a ConcurrencyException themselves. Study bulk-write adoption is deferred to Phase 4+.
Verification¶
dotnet build src/libs/mongo/SyRF.Mongo.Common/SyRF.Mongo.Common.csproj— compilesdotnet build src/services/project-management/project-management.slnf— no downstream breakage- Existing
SaveAsyncandSaveare completely unchanged — diff shows only additions - New entity path (
version == 0) usesIsUpsert = true— initial creation still works - Existing entity path (
version > 0) usesIsUpsert = false+MatchedCount == 0check — concurrent modifications throw
Success criteria¶
ConcurrencyExceptionclass exists inSyRF.Mongo.CommonnamespaceSaveWithConcurrencyCheckAsyncextension method available for opt-in use- Sync variant
SaveWithConcurrencyCheckavailable CreateConcurrencyCheckedReplaceModelhelper available for bulk write scenarios- Existing
SaveAsync/Savemethods completely untouched — zero blast radius
Open Design Questions¶
These were flagged during research but deferred to the implementation pass.
1. Naming strategy for standalone AnnotationQuestion¶
Recommendation: Use the same name AnnotationQuestion in the new namespace AnnotationQuestionAggregate (consistent with how Study lives in StudyAggregate). Both can coexist via fully-qualified names / type aliases during migration.
2. Scope of optimistic concurrency fix¶
Recommendation: Study-specific (via opt-in to the new method) for Phase 3. A global fix can be a separate, well-tested phase. Avoids any blast radius on currently-working save paths.
3. ARCH-03 field design — Scope, OwnerId, DerivedFrom¶
Recommendation: Guid ProjectId for Scope, Guid? OwnerId for ownership, Guid? DerivedFromId for lineage. Formalise based on the requirements when Phase 4 work begins.
4. New entity's relationship to existing embedded AnnotationQuestion data¶
Recommendation: Phase 3 creates empty collections only. The schema should be compatible with eventual migration from the embedded format, but do not build migration tooling in this phase. Migration is its own phase (Phase 7 — Release 1 Data Migration).
Reference Code Locations¶
Key existing files used as patterns during research:
src/libs/kernel/SyRF.SharedKernel/BaseClasses/AggregateRoot.cs— base entity patternsrc/libs/kernel/SyRF.SharedKernel/BaseClasses/Audit.cs— version trackingsrc/libs/kernel/SyRF.SharedKernel/Interfaces/ICrudRepository.cs— repository interfacesrc/libs/mongo/SyRF.Mongo.Common/MongoRepositoryBase.cs— repository base classsrc/libs/mongo/SyRF.Mongo.Common/MongoContext.cs— collection naming, bounded context prefixessrc/libs/mongo/SyRF.Mongo.Common/MongoExtensions.cs—SaveAsyncand version filtering (theIsUpsert = truesite)src/libs/mongo/SyRF.Mongo.Common/MongoLamarRegistry.cs— DI scan conventionssrc/libs/project-management/SyRF.ProjectManagement.Core/Interfaces/IPmUnitOfWork.cssrc/libs/project-management/SyRF.ProjectManagement.Mongo.Data/MongoPmUnitOfWork.cssrc/libs/project-management/SyRF.ProjectManagement.Mongo.Data/Repositories/StudyRepository.cs— complex repository examplesrc/libs/project-management/SyRF.ProjectManagement.Mongo.Data/Repositories/DataExportJobRepository.cs— simple repository patternsrc/libs/project-management/SyRF.ProjectManagement.Core/Model/ProjectAggregate/AnnotationQuestion.cs— existing embedded entity (collision source)src/libs/project-management/SyRF.ProjectManagement.Core/Model/StudyAggregate/Study.cs— concurrency target