Prerequisites: MUST READ before executing:
-
.claude/skills/shared/understand-code-first-protocol.md
-
.claude/skills/shared/evidence-based-reasoning-protocol.md
Quick Summary
Goal: Develop .NET backend features using Easy.Platform CQRS, repository, and entity patterns.
Workflow:
-
Classify Task — Use decision tree: Command (write) / Query (read) / Entity / Event Handler / Migration / Job
-
Implement — Follow per-pattern templates: Command+Handler+Result in one file, static expressions on entities
-
Validate — Use PlatformValidationResult fluent API, never throw for validation errors
Key Rules:
-
Side effects go in Entity Event Handlers (UseCaseEvents/ ), NEVER in command handlers
-
Cross-service communication via message bus only, NEVER direct DB access
-
MUST READ .ai/docs/backend-code-patterns.md before implementation
-
DTOs own mapping responsibility via MapToEntity() / MapToObject()
-
All reference patterns now merged inline below (no external files needed)
Content: 7 main patterns (CQRS Commands, Queries, Entities, Event Handlers, Migrations, Jobs, Message Bus) + detailed sub-patterns for validation, expressions, paging, scrolling, dependency waiting, race conditions, cron schedules, and anti-patterns.
Easy.Platform Backend Development
Complete backend development patterns for .NET 9 microservices using Easy.Platform. All reference patterns merged inline.
Project Pattern Discovery
Before implementation, search your codebase for project-specific patterns:
-
Search for: RootRepository , CqrsCommand , PlatformValidation , EntityEventHandler , DataMigration
-
Look for: service-specific repository interfaces, entity base classes, command/query conventions, event handler naming
MANDATORY IMPORTANT MUST Plan ToDo Task to READ backend-patterns-reference.md for project-specific patterns and code examples. If file not found, continue with search-based discovery above.
MUST READ before implementation:
- .ai/docs/backend-code-patterns.md
Quick Decision Tree
[Backend Task] ├── API endpoint? │ ├── Creates/Updates/Deletes data → CQRS Command (§1) │ └── Reads data → CQRS Query (§2) │ ├── Business entity? │ └── Entity Development (§3) │ ├── Side effects (notifications, emails, external APIs)? │ └── Entity Event Handler (§4) - NEVER in command handlers! │ ├── Data transformation/backfill? │ └── Migration (§5) │ ├── Scheduled/recurring task? │ └── Background Job (§6) │ └── Cross-service sync? └── Message Bus (§7) - NEVER direct DB access!
File Organization
{Service}.Application/ ├── UseCaseCommands/{Feature}/Save{Entity}Command.cs # Command+Handler+Result ├── UseCaseQueries/{Feature}/Get{Entity}ListQuery.cs # Query+Handler+Result ├── UseCaseEvents/{Feature}/*EntityEventHandler.cs # Side effects ├── BackgroundJobs/{Feature}/*Job.cs # Scheduled tasks ├── MessageBusProducers/*Producer.cs # Outbound events ├── MessageBusConsumers/{Entity}/*Consumer.cs # Inbound events └── DataMigrations/*DataMigration.cs # Data migrations
{Service}.Domain/ └── Entities/{Entity}.cs # Domain entities
Critical Rules
-
Repository: Use service-specific repos (search for RootRepository in your codebase to find project interfaces)
-
Validation: Use PlatformValidationResult fluent API - NEVER throw exceptions
-
Side Effects: Handle in Entity Event Handlers - NEVER in command handlers
-
DTO Mapping: DTOs own mapping via PlatformEntityDto<T,K>.MapToEntity()
-
Cross-Service: Use message bus - NEVER direct database access
§1. CQRS Commands
File: UseCaseCommands/{Feature}/Save{Entity}Command.cs (Command + Result + Handler in ONE file)
public sealed class SaveEmployeeCommand : PlatformCqrsCommand<SaveEmployeeCommandResult> { public string? Id { get; set; } public string Name { get; set; } = "";
public override PlatformValidationResult<IPlatformCqrsRequest> Validate()
=> base.Validate().And(_ => Name.IsNotNullOrEmpty(), "Name required");
}
public sealed class SaveEmployeeCommandResult : PlatformCqrsCommandResult { public EmployeeDto Entity { get; set; } = null!; }
internal sealed class SaveEmployeeCommandHandler : PlatformCqrsCommandApplicationHandler<SaveEmployeeCommand, SaveEmployeeCommandResult> { protected override async Task<SaveEmployeeCommandResult> HandleAsync( SaveEmployeeCommand req, CancellationToken ct) { var entity = req.Id.IsNullOrEmpty() ? req.MapToNewEntity().With(e => e.CreatedBy = RequestContext.UserId()) : await repository.GetByIdAsync(req.Id, ct) .EnsureFound().Then(e => req.UpdateEntity(e));
await entity.ValidateAsync(repository, ct).EnsureValidAsync();
await repository.CreateOrUpdateAsync(entity, ct);
return new SaveEmployeeCommandResult { Entity = new EmployeeDto(entity) };
}
}
Command Validation Patterns
Sync Validation (in Command class)
public override PlatformValidationResult<IPlatformCqrsRequest> Validate() { return base.Validate() .And(_ => Name.IsNotNullOrEmpty(), "Name required") .And(_ => StartDate <= EndDate, "Invalid range") .And(_ => Items.Count > 0, "At least one item required") .Of<IPlatformCqrsRequest>(); }
Async Validation (in Handler)
protected override async Task<PlatformValidationResult<SaveCommand>> ValidateRequestAsync( PlatformValidationResult<SaveCommand> validation, CancellationToken ct) { return await validation // Validate all IDs exist .AndAsync(req => repository.GetByIdsAsync(req.RelatedIds, ct) .ThenValidateFoundAllAsync(req.RelatedIds, ids => $"Not found: {ids}")) // Validate uniqueness .AndNotAsync(req => repository.AnyAsync(e => e.Code == req.Code && e.Id != req.Id, ct), "Code already exists") // Validate business rule .AndAsync(req => ValidateBusinessRuleAsync(req, ct)); }
Chained Validation with Of<>
return this.Validate(p => p.Id.IsNotNullOrEmpty(), "Id required") .And(p => p.Status != Status.Deleted, "Cannot modify deleted") .Of<IPlatformCqrsRequest>();
§2. CQRS Queries
File: UseCaseQueries/{Feature}/Get{Entity}ListQuery.cs
public sealed class GetEmployeeListQuery : PlatformCqrsPagedQuery<GetEmployeeListQueryResult, EmployeeDto> { public List<Status> Statuses { get; set; } = []; public string? SearchText { get; set; } }
internal sealed class GetEmployeeListQueryHandler : PlatformCqrsQueryApplicationHandler<GetEmployeeListQuery, GetEmployeeListQueryResult> { protected override async Task<GetEmployeeListQueryResult> HandleAsync( GetEmployeeListQuery req, CancellationToken ct) { var qb = repository.GetQueryBuilder((uow, q) => q .Where(e => e.CompanyId == RequestContext.CurrentCompanyId()) .WhereIf(req.Statuses.Any(), e => req.Statuses.Contains(e.Status)) .PipeIf(req.SearchText.IsNotNullOrEmpty(), q => searchService.Search(q, req.SearchText, Employee.DefaultFullTextSearchColumns())));
var (total, items) = await (
repository.CountAsync((uow, q) => qb(uow, q), ct),
repository.GetAllAsync((uow, q) => qb(uow, q)
.OrderByDescending(e => e.CreatedDate)
.PageBy(req.SkipCount, req.MaxResultCount), ct)
);
return new GetEmployeeListQueryResult(items.SelectList(e => new EmployeeDto(e)), total, req);
}
}
Query Patterns
GetQueryBuilder (Reusable Queries)
var queryBuilder = repository.GetQueryBuilder((uow, q) => q .Where(e => e.CompanyId == RequestContext.CurrentCompanyId()) .WhereIf(req.Statuses.Any(), e => req.Statuses.Contains(e.Status)) .WhereIf(req.FilterIds.IsNotNullOrEmpty(), e => req.FilterIds!.Contains(e.Id)) .WhereIf(req.FromDate.HasValue, e => e.CreatedDate >= req.FromDate) .WhereIf(req.ToDate.HasValue, e => e.CreatedDate <= req.ToDate) .PipeIf(req.SearchText.IsNotNullOrEmpty(), q => searchService.Search(q, req.SearchText, Entity.DefaultFullTextSearchColumns())));
Parallel Tuple Queries
var (total, items) = await ( repository.CountAsync((uow, q) => queryBuilder(uow, q), ct), repository.GetAllAsync((uow, q) => queryBuilder(uow, q) .OrderByDescending(e => e.CreatedDate) .PageBy(req.SkipCount, req.MaxResultCount), ct, e => e.RelatedEntity, // Eager load e => e.AnotherRelated) );
Full-Text Search
// In entity - define searchable columns public static Expression<Func<Entity, object?>>[] DefaultFullTextSearchColumns() => [e => e.Name, e => e.Code, e => e.Description, e => e.Email];
// In query handler .PipeIf(req.SearchText.IsNotNullOrEmpty(), q => searchService.Search( q, req.SearchText, Entity.DefaultFullTextSearchColumns(), fullTextAccurateMatch: true, // true=exact phrase, false=fuzzy includeStartWithProps: Entity.DefaultFullTextSearchColumns() // For autocomplete ))
Aggregation Query
var (total, items, statusCounts) = await ( repository.CountAsync((uow, q) => queryBuilder(uow, q), ct), repository.GetAllAsync((uow, q) => queryBuilder(uow, q).PageBy(skip, take), ct), repository.GetAllAsync((uow, q) => queryBuilder(uow, q) .GroupBy(e => e.Status) .Select(g => new { Status = g.Key, Count = g.Count() }), ct) );
Single Entity Query
protected override async Task<GetEntityByIdQueryResult> HandleAsync( GetEntityByIdQuery req, CancellationToken ct) { var entity = await repository.GetByIdAsync(req.Id, ct, e => e.RelatedEntity, e => e.Children) .EnsureFound($"Entity not found: {req.Id}");
return new GetEntityByIdQueryResult
{
Entity = new EntityDto(entity)
.WithRelated(entity.RelatedEntity)
.WithChildren(entity.Children)
};
}
Repository Extensions
// Extension pattern public static async Task<Employee> GetByEmailAsync( this IServiceRootRepository<Employee> repo, string email, CancellationToken ct = default) => await repo.FirstOrDefaultAsync(Employee.ByEmailExpr(email), ct).EnsureFound();
public static async Task<List<Entity>> GetByIdsValidatedAsync( this IPlatformQueryableRootRepository<Entity, string> repo, List<string> ids, CancellationToken ct = default) => await repo.GetAllAsync(p => ids.Contains(p.Id), ct).EnsureFoundAllBy(p => p.Id, ids);
public static async Task<string> GetIdByCodeAsync( this IPlatformQueryableRootRepository<Entity, string> repo, string code, CancellationToken ct = default) => await repo.FirstOrDefaultAsync(q => q.Where(Entity.CodeExpr(code)).Select(p => p.Id), ct).EnsureFound();
// Projection await repo.FirstOrDefaultAsync(q => q.Where(expr).Select(e => e.Id), ct);
Fluent Helpers
// Mutation & transformation await repo.GetByIdAsync(id).With(e => e.Name = newName).WithIf(cond, e => e.Status = Active); await repo.GetByIdAsync(id).Then(e => e.Process()).ThenAsync(e => e.ValidateAsync(svc, ct)); await repo.GetByIdAsync(id).EnsureFound($"Not found: {id}"); await repo.GetByIdsAsync(ids, ct).EnsureFoundAllBy(x => x.Id, ids);
// Parallel operations var (entity, files) = await ( repo.CreateOrUpdateAsync(entity, ct), files.ParallelAsync(f => fileService.UploadAsync(f, ct)) ); var ids = await repo.GetByIdsAsync(ids, ct).ThenSelect(e => e.Id); await items.ParallelAsync(item => ProcessAsync(item, ct), maxConcurrent: 10);
// Conditional actions await repo.GetByIdAsync(id).PipeActionIf(cond, e => e.Update()).PipeActionAsyncIf(() => svc.Any(), e => e.Sync());
Query Patterns Summary
Pattern Usage Example
GetQueryBuilder
Reusable query repository.GetQueryBuilder((uow, q) => ...)
WhereIf
Conditional filter .WhereIf(ids.Any(), e => ids.Contains(e.Id))
PipeIf
Conditional transform .PipeIf(text != null, q => searchService.Search(...))
PageBy
Pagination .PageBy(skip, take)
Tuple await Parallel queries var (count, items) = await (q1, q2)
Eager load Load relations GetByIdAsync(id, ct, e => e.Related)
.EnsureFound()
Validate exists await repo.GetByIdAsync(id).EnsureFound()
.ThenValidateFoundAllAsync()
Validate all IDs repo.GetByIdsAsync(ids).ThenValidateFoundAllAsync(ids, ...)
§3. Entity Development
File: {Service}.Domain/Entities/{Entity}.cs
Entity Base Classes
Non-Audited Entity
public class Employee : RootEntity<Employee, string> { // No CreatedBy, UpdatedBy, CreatedDate, etc. }
Audited Entity (With Audit Trail)
public class AuditedEmployee : RootAuditedEntity<AuditedEmployee, string, string> { // Includes: CreatedBy, UpdatedBy, CreatedDate, UpdatedDate }
Entity Structure Template
[TrackFieldUpdatedDomainEvent] // Track all field changes public sealed class Entity : RootEntity<Entity, string> { // ═══════════════════════════════════════════════════════════════════════════ // CORE PROPERTIES // ═══════════════════════════════════════════════════════════════════════════
[TrackFieldUpdatedDomainEvent] // Track specific field changes
public string Name { get; set; } = "";
public string Code { get; set; } = "";
public string CompanyId { get; set; } = "";
public EntityStatus Status { get; set; }
public DateTime? EffectiveDate { get; set; }
// ═══════════════════════════════════════════════════════════════════════════
// NAVIGATION PROPERTIES
// ═══════════════════════════════════════════════════════════════════════════
[JsonIgnore]
public Company? Company { get; set; }
[JsonIgnore]
public List<EntityChild>? Children { get; set; }
// ═══════════════════════════════════════════════════════════════════════════
// COMPUTED PROPERTIES (MUST have empty set { })
// ═══════════════════════════════════════════════════════════════════════════
[ComputedEntityProperty]
public bool IsActive
{
get => Status == EntityStatus.Active && !IsDeleted;
set { } // Required empty setter for EF Core
}
[ComputedEntityProperty]
public string DisplayName
{
get => $"{Code} - {Name}".Trim();
set { } // Required empty setter
}
// ═══════════════════════════════════════════════════════════════════════════
// STATIC EXPRESSIONS (For Repository Queries)
// ═══════════════════════════════════════════════════════════════════════════
// Simple filter expression
public static Expression<Func<Entity, bool>> OfCompanyExpr(string companyId)
=> e => e.CompanyId == companyId;
// Unique constraint expression
public static Expression<Func<Entity, bool>> UniqueExpr(string companyId, string code)
=> e => e.CompanyId == companyId && e.Code == code;
// Filter by status list
public static Expression<Func<Entity, bool>> FilterByStatusExpr(List<EntityStatus> statuses)
{
var statusSet = statuses.ToHashSet();
return e => e.Status.HasValue && statusSet.Contains(e.Status.Value);
}
// Composite expression with conditional
public static Expression<Func<Entity, bool>> ActiveInCompanyExpr(string companyId, bool includeInactive = false)
=> OfCompanyExpr(companyId).AndAlsoIf(!includeInactive, () => e => e.IsActive);
// Full-text search columns
public static Expression<Func<Entity, object?>>[] DefaultFullTextSearchColumns()
=> [e => e.Name, e => e.Code, e => e.Description];
// ═══════════════════════════════════════════════════════════════════════════
// ASYNC EXPRESSIONS (When External Dependencies Needed)
// ═══════════════════════════════════════════════════════════════════════════
public static async Task<Expression<Func<Entity, bool>>> FilterWithLicenseExprAsync(
IRepository<License> licenseRepo,
string companyId,
CancellationToken ct = default)
{
var hasLicense = await licenseRepo.HasLicenseAsync(companyId, ct);
return hasLicense ? PremiumFilterExpr() : StandardFilterExpr();
}
// ═══════════════════════════════════════════════════════════════════════════
// VALIDATION METHODS
// ═══════════════════════════════════════════════════════════════════════════
public PlatformValidationResult ValidateCanBeUpdated()
{
return PlatformValidationResult.Valid()
.And(() => !IsDeleted, "Entity is deleted")
.And(() => Status != EntityStatus.Locked, "Entity is locked");
}
public async Task<PlatformValidationResult> ValidateAsync(
IRepository<Entity> repository,
CancellationToken ct = default)
{
return await PlatformValidationResult.Valid()
.And(() => Name.IsNotNullOrEmpty(), "Name is required")
.And(() => Code.IsNotNullOrEmpty(), "Code is required")
.AndNotAsync(async () => await repository.AnyAsync(
e => e.Id != Id && e.CompanyId == CompanyId && e.Code == Code, ct),
"Code already exists");
}
// ═══════════════════════════════════════════════════════════════════════════
// INSTANCE METHODS
// ═══════════════════════════════════════════════════════════════════════════
public void Activate() => Status = EntityStatus.Active;
public void Deactivate() => Status = EntityStatus.Inactive;
}
Expression Composition
Pattern Usage Example
AndAlso
Combine with AND expr1.AndAlso(expr2)
OrElse
Combine with OR expr1.OrElse(expr2)
AndAlsoIf
Conditional AND .AndAlsoIf(condition, () => expr)
OrElseIf
Conditional OR .OrElseIf(condition, () => expr)
// Composing multiple expressions var expr = Entity.OfCompanyExpr(companyId) .AndAlso(Entity.FilterByStatusExpr(statuses)) .AndAlsoIf(deptIds.Any(), () => Entity.FilterByDeptExpr(deptIds)) .AndAlsoIf(searchText.IsNotNullOrEmpty(), () => Entity.SearchExpr(searchText));
// Complex expression public static Expression<Func<E, bool>> ComplexExpr(int s, string c, int? m) => BaseExpr(s, c) .AndAlso(e => e.User!.IsActive) .AndAlsoIf(m != null, () => e => e.Start <= Clock.UtcNow.AddMonths(-m!.Value));
Computed Property Rules
MUST have empty setter set { }
// CORRECT [ComputedEntityProperty] public bool IsRoot { get => Id == RootId; set { } // Required for EF Core mapping }
// WRONG - No setter causes EF Core issues [ComputedEntityProperty] public bool IsRoot => Id == RootId;
DTO Mapping
public class EmployeeDto : PlatformEntityDto<Employee, string> { public EmployeeDto() { } public EmployeeDto(Employee e, User? u) : base(e) { FullName = e.FullName ?? u?.FullName ?? ""; }
public string? Id { get; set; }
public string FullName { get; set; } = "";
public OrganizationDto? Company { get; set; }
public EmployeeDto WithCompany(OrganizationalUnit c) { Company = new OrganizationDto(c); return this; }
protected override object? GetSubmittedId() => Id;
protected override string GenerateNewId() => Ulid.NewUlid().ToString();
protected override Employee MapToEntity(Employee e, MapToEntityModes mode) { e.FullName = FullName; return e; }
}
// Value object DTO public sealed class ConfigDto : PlatformDto<ConfigValue> { public string ClientId { get; set; } = ""; public override ConfigValue MapToObject() => new() { ClientId = ClientId }; }
// Usage var dtos = employees.SelectList(e => new EmployeeDto(e, e.User).WithCompany(e.Company!));
Static Validation Method
public static List<string> ValidateEntity(Entity? e) => e == null ? ["Not found"] : !e.IsActive ? ["Inactive"] : [];
§4. Entity Event Handlers (Side Effects)
CRITICAL RULE
NEVER call side effects directly in command handlers!
Platform automatically raises PlatformCqrsEntityEvent on repository CRUD. Handle side effects in Entity Event Handlers instead.
File Location & Naming:
{Service}.Application/ └── UseCaseEvents/ └── {Feature}/ └── {Action}On{Event}{Entity}EntityEventHandler.cs
Naming Examples:
-
SendNotificationOnCreateLeaveRequestEntityEventHandler.cs
-
UpdateCategoryStatsOnSnippetChangeEventHandler.cs
-
SyncEmployeeOnEmployeeUpdatedEntityEventHandler.cs
-
SendEmailOnPublishGoalEntityEventHandler.cs
Implementation Pattern
internal sealed class SendNotificationOnCreateEntityEntityEventHandler : PlatformCqrsEntityEventApplicationHandler<Entity> // Single generic parameter! { private readonly INotificationService notificationService; private readonly IServiceRootRepository<Entity> repository;
public SendNotificationOnCreateEntityEntityEventHandler(
ILoggerFactory loggerFactory,
IPlatformUnitOfWorkManager unitOfWorkManager,
IServiceProvider serviceProvider,
IPlatformRootServiceProvider rootServiceProvider,
INotificationService notificationService,
IServiceRootRepository<Entity> repository)
: base(loggerFactory, unitOfWorkManager, serviceProvider, rootServiceProvider)
{
this.notificationService = notificationService;
this.repository = repository;
}
// Filter: Which events to handle
// NOTE: Must be public override async Task<bool> - NOT protected, NOT bool!
public override async Task<bool> HandleWhen(PlatformCqrsEntityEvent<Entity> @event)
{
// Skip during test data seeding
if (@event.RequestContext.IsSeedingTestingData()) return false;
// Only handle specific CRUD actions
return @event.CrudAction == PlatformCqrsEntityEventCrudAction.Created;
}
protected override async Task HandleAsync(
PlatformCqrsEntityEvent<Entity> @event,
CancellationToken ct)
{
var entity = @event.EntityData;
// Load additional data if needed
var relatedData = await repository.GetByIdAsync(entity.Id, ct, e => e.Related);
// Execute side effect
await notificationService.SendAsync(new NotificationRequest
{
EntityId = entity.Id,
EntityName = entity.Name,
Action = "Created",
UserId = @event.RequestContext.UserId()
});
}
}
CRUD Action Filtering
Single Action
public override async Task<bool> HandleWhen(PlatformCqrsEntityEvent<Entity> @event) { return @event.CrudAction == PlatformCqrsEntityEventCrudAction.Created; }
Multiple Actions
public override async Task<bool> HandleWhen(PlatformCqrsEntityEvent<Entity> @event) { return @event.CrudAction is PlatformCqrsEntityEventCrudAction.Created or PlatformCqrsEntityEventCrudAction.Updated; }
Updated with Specific Condition
public override async Task<bool> HandleWhen(PlatformCqrsEntityEvent<Entity> @event) { return @event.CrudAction == PlatformCqrsEntityEventCrudAction.Updated && @event.EntityData.Status == Status.Published; }
Skip Test Data Seeding
public override async Task<bool> HandleWhen(PlatformCqrsEntityEvent<Entity> @event) { if (@event.RequestContext.IsSeedingTestingData()) return false; return @event.CrudAction == PlatformCqrsEntityEventCrudAction.Created; }
Accessing Event Data
Property Description
@event.EntityData
The entity that triggered the event
@event.CrudAction
Created, Updated, or Deleted
@event.RequestContext
Request context with user/company info
@event.RequestContext.UserId()
User who triggered the change
@event.RequestContext.CurrentCompanyId()
Company context
§5. Data Migrations
Migration Type Decision
Is this a schema change? ├── YES → EF Core Migration │ ├── SQL Server: dotnet ef migrations add {Name} │ └── PostgreSQL: dotnet ef migrations add {Name} │ └── NO → Data Migration ├── MongoDB (simple, NO DI needed): │ └── PlatformMongoMigrationExecutor<TDbContext> │ ⚠️ NO constructor injection, NO RootServiceProvider │ Receives dbContext in Execute(TDbContext dbContext) │ Use for: field renames, index recreation, simple updates │ ├── MongoDB (needs DI / cross-DB / paging): │ └── PlatformDataMigrationExecutor<MongoDbContext> │ ✅ Full DI via constructor, RootServiceProvider available │ Place in same project as MongoDbContext (scanned by assembly) │ Use for: cross-DB sync, complex transforms, service injection │ ├── SQL/PostgreSQL: │ └── PlatformDataMigrationExecutor<EfCoreDbContext> │ └── MongoDB index recreation only: └── PlatformMongoMigrationExecutor → dbContext.InternalEnsureIndexesAsync(recreate: true)
CRITICAL: PlatformMongoMigrationExecutor uses Activator.CreateInstance — NO DI support. If you need DI (inject other DbContexts, repositories, services), use PlatformDataMigrationExecutor<MongoDbContext> instead. This works because PlatformMongoDbContext implements IPlatformDbContext , and MigrateDataAsync scans the DbContext's assembly for PlatformDataMigrationExecutor<TDbContext> .
File Locations
EF Core Schema Migrations
{Service}.Persistence/ └── Migrations/ └── {Timestamp}_{MigrationName}.cs
Data Migrations (EF Core)
{Service}.Application/ or {Service}.Persistence/ └── DataMigrations/ └── {Date}_{MigrationName}DataMigration.cs
Data Migrations (MongoDB — PlatformDataMigrationExecutor)
{Service}.Data.MongoDb/ └── DataMigrations/ └── {Date}_{MigrationName}Migration.cs NOTE: Must be in same project/assembly as MongoDbContext (scanned by GetType().Assembly)
MongoDB Migrations (PlatformMongoMigrationExecutor)
{Service}.Data.MongoDb/ └── Migrations/ └── {Date}_{MigrationName}.cs
Pattern 1: EF Core Schema Migration
Navigate to persistence project
cd src/Backend/{ServiceDir}/{Service}.Persistence
Add migration
dotnet ef migrations add AddEmployeePhoneNumber
Apply migration
dotnet ef database update
Rollback last migration
dotnet ef migrations remove
public partial class AddEmployeePhoneNumber : Migration { protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.AddColumn<string>( name: "PhoneNumber", table: "Employees", type: "nvarchar(50)", maxLength: 50, nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Employees_PhoneNumber",
table: "Employees",
column: "PhoneNumber");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Employees_PhoneNumber",
table: "Employees");
migrationBuilder.DropColumn(
name: "PhoneNumber",
table: "Employees");
}
}
Pattern 2: Data Migration (SQL/PostgreSQL)
public sealed class MigrateEmployeePhoneNumbers : PlatformDataMigrationExecutor<ServiceDbContext> { public override string Name => "20251015000000_MigrateEmployeePhoneNumbers"; public override DateTime? OnlyForDbsCreatedBeforeDate => new(2025, 10, 15); public override bool AllowRunInBackgroundThread => true;
private readonly IServiceRootRepository<Employee> employeeRepo;
private const int PageSize = 200;
public override async Task Execute(ServiceDbContext dbContext)
{
var queryBuilder = employeeRepo.GetQueryBuilder((uow, q) =>
q.Where(e => e.PhoneNumber == null && e.LegacyPhone != null));
var totalCount = await employeeRepo.CountAsync((uow, q) => queryBuilder(uow, q));
if (totalCount == 0)
{
Logger.LogInformation("No employees need phone migration");
return;
}
Logger.LogInformation("Migrating phone numbers for {Count} employees", totalCount);
await RootServiceProvider.ExecuteInjectScopedPagingAsync(
maxItemCount: totalCount,
pageSize: PageSize,
processingDelegate: ExecutePaging,
queryBuilder);
}
private static async Task<List<Employee>> ExecutePaging(
int skip,
int take,
Func<IPlatformUnitOfWork, IQueryable<Employee>, IQueryable<Employee>> queryBuilder,
IServiceRootRepository<Employee> repo,
IPlatformUnitOfWorkManager uowManager)
{
using var unitOfWork = uowManager.Begin();
var employees = await repo.GetAllAsync((uow, q) =>
queryBuilder(uow, q)
.OrderBy(e => e.Id)
.Skip(skip)
.Take(take));
if (employees.IsEmpty()) return employees;
foreach (var employee in employees)
{
employee.PhoneNumber = NormalizePhoneNumber(employee.LegacyPhone);
}
await repo.UpdateManyAsync(
employees,
dismissSendEvent: true,
checkDiff: false);
await unitOfWork.CompleteAsync();
return employees;
}
}
Pattern 3: MongoDB Migration (Simple — No DI)
⚠️ PlatformMongoMigrationExecutor has NO constructor injection and NO RootServiceProvider . It uses Activator.CreateInstance . Only Execute(TDbContext dbContext) is available. Access collections via dbContext (e.g., dbContext.UserCollection , dbContext.GetCollection<T>() ). If you need DI, use Pattern 5 (PlatformDataMigrationExecutor<MongoDbContext> ) instead.
// Field rename migration — access collections via dbContext parameter public sealed class MigrateFieldName : PlatformMongoMigrationExecutor<ServiceMongoDbContext> { public override string Name => "20251015_MigrateFieldName"; public override DateTime? OnlyForDbInitBeforeDate => new DateTime(2025, 10, 15);
public override async Task Execute(ServiceMongoDbContext dbContext)
{
var collection = dbContext.GetCollection<BsonDocument>("distributions");
var filter = Builders<BsonDocument>.Filter.Exists("OldFieldName");
var pipeline = PipelineDefinition<BsonDocument, BsonDocument>.Create(
[new BsonDocument("$set", new BsonDocument("NewFieldName", "$OldFieldName"))]);
await collection.UpdateManyAsync(filter, pipeline);
}
}
// Index recreation migration — simplest pattern public sealed class RecreateIndexes : PlatformMongoMigrationExecutor<ServiceMongoDbContext> { public override string Name => "20251015_RecreateIndexes"; public override DateTime? OnlyForDbInitBeforeDate => new DateTime(2025, 10, 15);
public override async Task Execute(ServiceMongoDbContext dbContext)
{
await dbContext.InternalEnsureIndexesAsync(recreate: true);
}
}
Pattern 4: Cross-Service Data Sync (One-Time)
public sealed class SyncEmployeesFromAccounts : PlatformDataMigrationExecutor<ServiceDbContext> { public override string Name => "20251015000000_SyncEmployeesFromAccounts"; public override DateTime? OnlyForDbsCreatedBeforeDate => new(2025, 10, 15);
public override async Task Execute(ServiceDbContext dbContext)
{
var sourceEmployees = await accountsDbContext.Employees
.Where(e => e.CreatedDate < OnlyForDbsCreatedBeforeDate)
.AsNoTracking()
.ToListAsync();
Logger.LogInformation("Syncing {Count} employees from Accounts", sourceEmployees.Count);
var targetEmployees = sourceEmployees.Select(MapToGrowthEmployee).ToList();
await RootServiceProvider.ExecuteInjectScopedPagingAsync(
maxItemCount: targetEmployees.Count,
pageSize: 100,
async (skip, take, repo, uow) =>
{
var batch = targetEmployees.Skip(skip).Take(take).ToList();
await repo.CreateManyAsync(batch, dismissSendEvent: true);
return batch;
});
}
}
Pattern 5: Cross-DB Data Migration to MongoDB (DI-Supported)
Use when: You need to read from a SQL/PostgreSQL DB (e.g., Accounts) and write to MongoDB. PlatformDataMigrationExecutor<MongoDbContext> gives you full DI + RootServiceProvider
- paging. Must be placed in the same project as the MongoDbContext (assembly scanning).
Real example: Backfill UserCompany.IsActive from a source service's UserCompanyInfos .
// File: {Service}.Data.MongoDb/DataMigrations/{Date}_{MigrationName}Migration.cs
public sealed class MigrateUserCompanyIsActiveFromAccountsDbMigration : PlatformDataMigrationExecutor<ServiceMongoDbContext> // ← MongoDB context, NOT EF Core { private const int PageSize = 100; private readonly SourceServiceDbContext accountsPlatformDbContext; // ← DI: cross-DB EF Core context
public MigrateUserCompanyIsActiveFromAccountsDbMigration(
IPlatformRootServiceProvider rootServiceProvider,
SourceServiceDbContext accountsPlatformDbContext) // ← Constructor injection works!
: base(rootServiceProvider)
{
this.accountsPlatformDbContext = accountsPlatformDbContext;
}
public override string Name => "20260206000001_MigrateUserCompanyIsActiveFromAccountsDb";
public override DateTime? OnlyForDbsCreatedBeforeDate => new(2026, 02, 06);
public override bool AllowRunInBackgroundThread => true;
public override async Task Execute(ServiceMongoDbContext dbContext)
{
var totalCount = await accountsPlatformDbContext
.GetQuery<SourceEntity>()
.EfCoreCountAsync();
if (totalCount == 0) return;
await RootServiceProvider.ExecuteInjectScopedPagingAsync(
maxItemCount: totalCount,
pageSize: PageSize,
MigrateUserCompanyIsActivePaging); // ← Static method, params resolved by DI
}
// Static paging method: first 2 params MUST be (int skipCount, int pageSize)
// Remaining params are resolved from DI container
private static async Task MigrateUserCompanyIsActivePaging(
int skipCount,
int pageSize,
SourceServiceDbContext accountsPlatformDbContext, // ← DI-resolved
ServiceMongoDbContext surveyDbContext) // ← DI-resolved
{
// 1. Read from SQL/PostgreSQL source
var userCompanyInfos = await accountsPlatformDbContext
.GetQuery<SourceEntity>()
.OrderBy(p => p.Id)
.Skip(skipCount)
.Take(pageSize)
.EfCoreToListAsync();
if (userCompanyInfos.Count == 0) return;
// 2. Group for efficient lookup
var infoByUserId = userCompanyInfos
.GroupBy(p => p.UserId)
.ToDictionary(g => g.Key, g => g.ToList());
// 3. Read matching users from MongoDB
var userIds = infoByUserId.Keys.ToList();
var filter = Builders<User>.Filter.In(u => u.ExternalId, userIds);
var users = await surveyDbContext.UserCollection.Find(filter).ToListAsync();
// 4. Build bulk update operations
var bulkUpdates = new List<WriteModel<User>>();
foreach (var user in users)
{
if (!infoByUserId.TryGetValue(user.ExternalId, out var companyInfos)) continue;
var updated = false;
foreach (var company in user.Companies)
{
var match = companyInfos.FirstOrDefault(ci => ci.CompanyId == company.CompanyId);
if (match != null && company.IsActive != match.IsActive)
{
company.IsActive = match.IsActive;
updated = true;
}
}
if (updated)
{
bulkUpdates.Add(new UpdateOneModel<User>(
Builders<User>.Filter.Eq(u => u.Id, user.Id),
Builders<User>.Update.Set(u => u.Companies, user.Companies)));
}
}
// 5. Batch write to MongoDB
if (bulkUpdates.Count > 0)
await surveyDbContext.UserCollection.BulkWriteAsync(bulkUpdates);
}
}
Why this works: PlatformMongoDbContext implements IPlatformDbContext . Its MigrateDataAsync() calls IPlatformDbContext.MigrateDataAsync<TDbContext>() which scans GetType().Assembly for PlatformDataMigrationExecutor<TDbContext> . CreateNewInstance uses ActivatorUtilities.CreateInstance (full DI support).
Scrolling vs Paging
// PAGING: When skip/take stays consistent // (items don't disappear from query after processing) await RootServiceProvider.ExecuteInjectScopedPagingAsync(...);
// SCROLLING: When processed items excluded from next query // (e.g., status change means item no longer matches filter) await UnitOfWorkManager.ExecuteInjectScopedScrollingPagingAsync(...);
Migration Options & Flags
Option Purpose When to Use
OnlyForDbsCreatedBeforeDate
Target specific DBs Migrating existing data only
AllowRunInBackgroundThread
Non-blocking Large migrations that can run async
dismissSendEvent: true
Skip entity events Data migrations (avoid event storms)
checkDiff: false
Skip change detection Bulk updates (performance)
§6. Background Jobs
Job Type Decision Tree
Does processing affect the query result? ├── NO → Simple Paged (skip/take stays consistent) │ └── Use: PlatformApplicationPagedBackgroundJobExecutor │ └── YES → Scrolling needed (processed items excluded from next query) │ └── Is this multi-tenant (company-based)? ├── YES → Batch Scrolling (batch by company, scroll within) │ └── Use: PlatformApplicationBatchScrollingBackgroundJobExecutor │ └── NO → Simple Scrolling └── Use: ExecuteInjectScopedScrollingPagingAsync
Pattern 1: Simple Paged Job
Use when: Items don't change during processing (or changes don't affect query).
[PlatformRecurringJob("0 3 * * *")] // Daily at 3 AM public sealed class ProcessPendingItemsJob : PlatformApplicationPagedBackgroundJobExecutor { private readonly IServiceRepository<Item> repository;
protected override int PageSize => 50;
private IQueryable<Item> QueryBuilder(IQueryable<Item> query)
=> query.Where(x => x.Status == Status.Pending);
protected override async Task<int> MaxItemsCount(
PlatformApplicationPagedBackgroundJobParam<object?> param)
{
return await repository.CountAsync((uow, q) => QueryBuilder(q));
}
protected override async Task ProcessPagedAsync(
int? skip,
int? take,
object? param,
IServiceProvider serviceProvider,
IPlatformUnitOfWorkManager unitOfWorkManager)
{
var items = await repository.GetAllAsync((uow, q) =>
QueryBuilder(q)
.OrderBy(x => x.CreatedDate)
.PageBy(skip, take));
await items.ParallelAsync(async item =>
{
item.Process();
await repository.UpdateAsync(item);
}, maxConcurrent: 5);
}
}
Pattern 2: Batch Scrolling Job (Multi-Tenant)
Use when: Processing per-company, data changes during processing.
[PlatformRecurringJob("0 0 * * *")] // Daily at midnight public sealed class SyncCompanyDataJob : PlatformApplicationBatchScrollingBackgroundJobExecutor<Entity, string> { // Batch key = CompanyId, Entity = what we're processing protected override int BatchKeyPageSize => 50; // Companies per page protected override int BatchPageSize => 25; // Entities per company batch
protected override IQueryable<Entity> EntitiesQueryBuilder(
IQueryable<Entity> query,
object? param,
string? batchKey = null)
{
return query
.Where(e => e.NeedsSync)
.WhereIf(batchKey != null, e => e.CompanyId == batchKey)
.OrderBy(e => e.Id);
}
protected override IQueryable<string> EntitiesBatchKeyQueryBuilder(
IQueryable<Entity> query,
object? param,
string? batchKey = null)
{
return EntitiesQueryBuilder(query, param, batchKey)
.Select(e => e.CompanyId)
.Distinct();
}
protected override async Task ProcessEntitiesAsync(
List<Entity> entities,
string batchKey, // CompanyId
object? param,
IServiceProvider serviceProvider)
{
Logger.LogInformation("Processing {Count} entities for company {CompanyId}",
entities.Count, batchKey);
await entities.ParallelAsync(async entity =>
{
entity.MarkSynced();
await repository.UpdateAsync(entity);
}, maxConcurrent: 1); // Often 1 to avoid race conditions within company
}
}
Pattern 3: Scrolling Job (Data Changes During Processing)
Use when: Processing creates a log/record that excludes item from next query.
public sealed class ProcessAndLogJob : PlatformApplicationBackgroundJobExecutor { public override async Task ProcessAsync(object? param) { var queryBuilder = repository.GetQueryBuilder((uow, q) => q.Where(x => x.Status == Status.Pending) .Where(x => !processedLogRepo.Query().Any(log => log.EntityId == x.Id)));
var totalCount = await repository.CountAsync((uow, q) => queryBuilder(uow, q));
await UnitOfWorkManager.ExecuteInjectScopedScrollingPagingAsync<Entity>(
processingDelegate: ExecutePaged,
maxPageCount: totalCount / PageSize,
param: param,
pageSize: PageSize);
}
private static async Task<List<Entity>> ExecutePaged(
object? param,
int? limitPageSize,
IServiceRepository<Entity> repo,
IServiceRepository<ProcessedLog> logRepo)
{
var items = await repo.GetAllAsync((uow, q) =>
q.Where(x => x.Status == Status.Pending)
.Where(x => !logRepo.Query().Any(log => log.EntityId == x.Id))
.OrderBy(x => x.Id)
.PipeIf(limitPageSize != null, q => q.Take(limitPageSize!.Value)));
if (items.IsEmpty()) return items;
// Create log entries (excludes from next query)
await logRepo.CreateManyAsync(items.SelectList(e => new ProcessedLog(e)));
foreach (var item in items)
{
item.Process();
await repo.UpdateAsync(item, dismissSendEvent: true);
}
return items;
}
}
Pattern 4: Master Job (Schedules Child Jobs)
Use when: Complex coordination across companies and date ranges.
[PlatformRecurringJob("0 6 * * *")] public sealed class MasterSchedulerJob : PlatformApplicationBackgroundJobExecutor { public override async Task ProcessAsync(object? param) { var companies = await companyRepo.GetAllAsync(c => c.IsActive); var dateRange = DateRangeBuilder.BuildDateRange( Clock.UtcNow.AddDays(-7), Clock.UtcNow);
await companies.ParallelAsync(async company =>
{
await dateRange.ParallelAsync(async date =>
{
await BackgroundJobScheduler.Schedule<ChildProcessingJob, ChildJobParam>(
Clock.UtcNow,
new ChildJobParam
{
CompanyId = company.Id,
ProcessDate = date
});
});
}, maxConcurrent: 10);
}
}
Cron Schedule Reference
Schedule Cron Expression Description
Every 5 min */5 * * * *
Every 5 minutes
Hourly 0 * * * *
Top of every hour
Daily midnight 0 0 * * *
00:00 daily
Daily 3 AM 0 3 * * *
03:00 daily
Weekly Sunday 0 0 * * 0
Midnight Sunday
Monthly 1st 0 0 1 * *
Midnight, 1st day
Job Attributes
// Basic recurring job [PlatformRecurringJob("0 3 * * *")]
// With startup execution [PlatformRecurringJob("5 0 * * *", executeOnStartUp: true)]
// Disabled (for manual or event-triggered) [PlatformRecurringJob(isDisabled: true)]
§7. Message Bus (Cross-Service)
CRITICAL: Cross-service data sync uses message bus - NEVER direct database access!
File Locations
Producer (Source Service)
{Service}.Application/ └── MessageBusProducers/ └── {Entity}EntityEventBusMessageProducer.cs
Consumer (Target Service)
{Service}.Application/ └── MessageBusConsumers/ └── {SourceEntity}/ └── {Action}On{Entity}EntityEventBusConsumer.cs
Message Definition (Shared)
PlatformExampleApp.Shared/ └── CrossServiceMessages/ └── {Entity}EntityEventBusMessage.cs
Message Definition
// In PlatformExampleApp.Shared public sealed class EmployeeEntityEventBusMessage : PlatformCqrsEntityEventBusMessage<EmployeeEventData, string> { public EmployeeEntityEventBusMessage() { }
public EmployeeEntityEventBusMessage(
PlatformCqrsEntityEvent<Employee> entityEvent,
EmployeeEventData entityData)
: base(entityEvent, entityData)
{
}
}
public sealed class EmployeeEventData { public string Id { get; set; } = ""; public string FullName { get; set; } = ""; public string Email { get; set; } = ""; public string CompanyId { get; set; } = ""; public bool IsDeleted { get; set; }
public EmployeeEventData() { }
public EmployeeEventData(Employee entity)
{
Id = entity.Id;
FullName = entity.FullName;
Email = entity.Email;
CompanyId = entity.CompanyId;
IsDeleted = entity.IsDeleted;
}
public TargetEmployee ToEntity() => new TargetEmployee
{
SourceId = Id,
FullName = FullName,
Email = Email,
CompanyId = CompanyId
};
public TargetEmployee UpdateEntity(TargetEmployee existing)
{
existing.FullName = FullName;
existing.Email = Email;
return existing;
}
}
Pattern 1: Entity Event Producer
Auto-publishes when entity changes via repository CRUD.
internal sealed class EmployeeEntityEventBusMessageProducer : PlatformCqrsEntityEventBusMessageProducer<EmployeeEntityEventBusMessage, Employee, string> { public EmployeeEntityEventBusMessageProducer( ILoggerFactory loggerFactory, IPlatformUnitOfWorkManager unitOfWorkManager, IServiceProvider serviceProvider, IPlatformRootServiceProvider rootServiceProvider) : base(loggerFactory, unitOfWorkManager, serviceProvider, rootServiceProvider) { }
public override async Task<bool> HandleWhen(PlatformCqrsEntityEvent<Employee> @event)
{
if (@event.RequestContext.IsSeedingTestingData()) return false;
return @event.EntityData.IsActive ||
@event.CrudAction == PlatformCqrsEntityEventCrudAction.Deleted;
}
protected override Task<EmployeeEntityEventBusMessage> BuildMessageAsync(
PlatformCqrsEntityEvent<Employee> @event,
CancellationToken ct)
{
return Task.FromResult(new EmployeeEntityEventBusMessage(
@event,
new EmployeeEventData(@event.EntityData)));
}
}
Pattern 2: Entity Event Consumer
internal sealed class UpsertOrDeleteEmployeeOnEmployeeEntityEventBusConsumer : PlatformApplicationMessageBusConsumer<EmployeeEntityEventBusMessage> { private readonly ITargetServiceRepository<TargetEmployee> employeeRepo; private readonly ITargetServiceRepository<Company> companyRepo;
public override async Task<bool> HandleWhen(
EmployeeEntityEventBusMessage message,
string routingKey)
{
return true;
}
public override async Task HandleLogicAsync(
EmployeeEntityEventBusMessage message,
string routingKey)
{
var payload = message.Payload;
var entityData = payload.EntityData;
// ═══════════════════════════════════════════════════════════════════
// WAIT FOR DEPENDENCIES (with timeout)
// ═══════════════════════════════════════════════════════════════════
var companyMissing = await Util.TaskRunner
.TryWaitUntilAsync(
() => companyRepo.AnyAsync(c => c.Id == entityData.CompanyId),
maxWaitSeconds: message.IsForceSyncDataRequest() ? 30 : 300)
.Then(found => !found);
if (companyMissing)
{
Logger.LogWarning("Company {CompanyId} not found, skipping employee sync",
entityData.CompanyId);
return;
}
// ═══════════════════════════════════════════════════════════════════
// HANDLE DELETE
// ═══════════════════════════════════════════════════════════════════
if (payload.CrudAction == PlatformCqrsEntityEventCrudAction.Deleted ||
(payload.CrudAction == PlatformCqrsEntityEventCrudAction.Updated && entityData.IsDeleted))
{
await employeeRepo.DeleteAsync(entityData.Id);
return;
}
// ═══════════════════════════════════════════════════════════════════
// HANDLE CREATE/UPDATE
// ═══════════════════════════════════════════════════════════════════
var existing = await employeeRepo.FirstOrDefaultAsync(
e => e.SourceId == entityData.Id);
if (existing == null)
{
await employeeRepo.CreateAsync(
entityData.ToEntity()
.With(e => e.LastMessageSyncDate = message.CreatedUtcDate));
}
else if (existing.LastMessageSyncDate <= message.CreatedUtcDate)
{
await employeeRepo.UpdateAsync(
entityData.UpdateEntity(existing)
.With(e => e.LastMessageSyncDate = message.CreatedUtcDate));
}
// else: Skip - we have a newer version already
}
}
Pattern 3: Custom Message (Non-Entity)
// Message definition public sealed class NotificationRequestMessage : PlatformBusMessage { public string UserId { get; set; } = ""; public string Subject { get; set; } = ""; public string Body { get; set; } = ""; public NotificationType Type { get; set; } }
// Producer (manual publish) public class NotificationService { private readonly IPlatformMessageBusProducer messageBus;
public async Task SendNotificationAsync(NotificationRequest request)
{
await messageBus.PublishAsync(new NotificationRequestMessage
{
UserId = request.UserId,
Subject = request.Subject,
Body = request.Body,
Type = request.Type
});
}
}
// Consumer internal sealed class ProcessNotificationRequestConsumer : PlatformApplicationMessageBusConsumer<NotificationRequestMessage> { public override async Task HandleLogicAsync( NotificationRequestMessage message, string routingKey) { await notificationService.ProcessAsync(message); } }
Message Naming Convention
Type Producer Role Pattern Example
Event Leader <ServiceName><Feature><Action>EventBusMessage
CandidateJobBoardApiSyncCompletedEventBusMessage
Request Follower <ConsumerServiceName><Feature>RequestBusMessage
JobCreateNonexistentJobsRequestBusMessage
Consumer Naming: Consumer class name = Message class name + Consumer suffix
Key Patterns
Wait for Dependencies
var found = await Util.TaskRunner.TryWaitUntilAsync( () => companyRepo.AnyAsync(c => c.Id == companyId), maxWaitSeconds: 300);
if (!found) return;
Prevent Race Conditions
if (existing.LastMessageSyncDate <= message.CreatedUtcDate) { await repository.UpdateAsync(existing.With(e => e.LastMessageSyncDate = message.CreatedUtcDate)); }
Force Sync Detection
var timeout = message.IsForceSyncDataRequest() ? 30 : 300;
Anti-Patterns
Don't Do
throw new ValidationException()
Use PlatformValidationResult fluent API
Side effects in command handler Entity Event Handler in UseCaseEvents/
IPlatformRootRepository<T>
Service-specific: IServiceRootRepository<T>
Direct cross-service DB access Message bus
DTO mapping in handler PlatformEntityDto.MapToEntity()
Separate Command/Handler files ONE file: Command + Result + Handler
protected bool HandleWhen()
public override async Task<bool> HandleWhen()
No paging for large datasets Use ExecuteInjectScopedPagingAsync
Send events during migration dismissSendEvent: true
Missing unit of work using var uow = uowManager.Begin()
Use migration for ongoing sync Use message bus consumers
Use PlatformMongoMigrationExecutor when you need DI Use PlatformDataMigrationExecutor<MongoDbContext>
Assume MongoMigrationExecutor has RootServiceProvider
It does NOT — only PlatformDataMigrationExecutor has it
Update MongoDB one-by-one in a loop Use BulkWriteAsync with List<WriteModel<T>>
No dependency waiting (message bus) TryWaitUntilAsync
No race condition handling Check LastMessageSyncDate
Blocking in producer Keep BuildMessageAsync fast
Only check Deleted action Also check soft delete flag
Unbounded parallelism maxConcurrent: 5
Long-running without unit of work Commit per batch
Business logic in entity properties Use methods
Missing [JsonIgnore] on navigation Add [JsonIgnore]
Complex logic in getters Extract to method
Computed property without set { }
Add empty setter
Verification Checklist
-
Uses service-specific repository (I{Service}RootRepository<T> )
-
Validation uses fluent API (.And() , .AndAsync() )
-
No side effects in command handlers
-
DTO mapping in DTO class
-
Cross-service uses message bus
-
Background jobs have maxConcurrent limit
-
Migrations use dismissSendEvent: true
-
Entity event handlers use public override async Task<bool> HandleWhen()
-
MongoDB migrations choose correct executor (Mongo vs Data)
-
Cross-DB migrations use PlatformDataMigrationExecutor<MongoDbContext>
Related
-
api-design
-
database-optimization
IMPORTANT Task Planning Notes (MUST FOLLOW)
-
Always plan and break work into many small todo tasks
-
Always add a final review todo task to verify work quality and identify fixes/enhancements