Awesome-omni-skill easyplatform-backend
[Implementation] Complete Easy.Platform backend development for EasyPlatform. Covers CQRS commands/queries, entities, validation, migrations, background jobs, and message bus. Use for any .NET backend task in this monorepo.
git clone https://github.com/diegosouzapw/awesome-omni-skill
T=$(mktemp -d) && git clone --depth=1 https://github.com/diegosouzapw/awesome-omni-skill "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/development/easyplatform-backend" ~/.claude/skills/diegosouzapw-awesome-omni-skill-easyplatform-backend && rm -rf "$T"
skills/development/easyplatform-backend/SKILL.mdEasy.Platform Backend Development
Complete backend development patterns for EasyPlatform .NET 9 microservices. All patterns consolidated inline for efficient context loading.
Summary
Goal: Comprehensive .NET 9 CQRS backend patterns — commands, queries, entities, events, migrations, jobs, message bus — with zero external references.
Coverage: 7 major patterns, 850+ lines of inline examples, anti-patterns catalog, complete fluent API reference.
| Pattern | File Location | Key Classes |
|---|---|---|
| CQRS Commands | | , |
| CQRS Queries | | , |
| Entities | | , , static expressions |
| Event Handlers | | |
| Migrations | , | , |
| Background Jobs | | |
| Message Bus | , | |
Critical Rules:
- Validation:
fluent API (PlatformValidationResult
,.And()
) — NEVER throw exceptions.AndAsync() - Side Effects: Entity Event Handlers in
— NEVER in command handlersUseCaseEvents/ - Cross-Service: Message bus only — NEVER direct database access
- DTO Mapping: DTOs own mapping via
— NEVER in handlersMapToEntity() - File Structure: Command + Result + Handler in ONE file
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)? → 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
§1. CQRS Commands
File:
UseCaseCommands/{Feature}/Save{Entity}Command.cs (Command + Result + Handler in ONE file)
Basic Command Template
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>();
Repository Extensions
// Extension pattern public static async Task<Employee> GetByEmailAsync( this IPlatformQueryableRootRepository<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());
§2. CQRS Queries
File:
UseCaseQueries/{Feature}/Get{Entity}ListQuery.cs
Paged Query Template
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); } }
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 ))
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) }; }
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) );
Query Patterns Summary
| Pattern | Usage | Example |
|---|---|---|
| Reusable query | |
| Conditional filter | |
| Conditional transform | |
| Pagination | |
| Tuple await | Parallel queries | |
| Eager load | Load relations | |
| Validate exists | |
| Validate all 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 }
Complete Entity 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 |
|---|---|---|
| Combine with AND | |
| Combine with OR | |
| Conditional AND | |
| Conditional OR | |
// 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: NEVER call side effects in command handlers!
File:
UseCaseEvents/{Feature}/Send{Action}On{Event}{Entity}EntityEventHandler.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 |
|---|---|
| The entity that triggered the event |
| Created, Updated, or Deleted |
| Request context with user/company info |
| User who triggered the change |
| 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:
usesPlatformMongoMigrationExecutor— NO DI support. If you need DI (inject other DbContexts, repositories, services), useActivator.CreateInstanceinstead.PlatformDataMigrationExecutor<MongoDbContext>
Pattern 1: EF Core Schema Migration
# Navigate to persistence project cd src/Services/bravoGROWTH/Growth.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<GrowthDbContext> { public override string Name => "20251015000000_MigrateEmployeePhoneNumbers"; public override DateTime? OnlyForDbsCreatedBeforeDate => new(2025, 10, 15); public override bool AllowRunInBackgroundThread => true; private readonly IGrowthRootRepository<Employee> employeeRepo; private const int PageSize = 200; public override async Task Execute(GrowthDbContext 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, IGrowthRootRepository<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)
⚠️
has NO constructor injection and NOPlatformMongoMigrationExecutor. Access collections viaRootServiceProvider(e.g.,dbContext,dbContext.UserCollection).dbContext.GetCollection<T>()
// Field rename migration — access collections via dbContext parameter public sealed class MigrateFieldName : PlatformMongoMigrationExecutor<SurveyPlatformMongoDbContext> { public override string Name => "20251015_MigrateFieldName"; public override DateTime? OnlyForDbInitBeforeDate => new DateTime(2025, 10, 15); public override async Task Execute(SurveyPlatformMongoDbContext 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<SurveyPlatformMongoDbContext> { public override string Name => "20251015_RecreateIndexes"; public override DateTime? OnlyForDbInitBeforeDate => new DateTime(2025, 10, 15); public override async Task Execute(SurveyPlatformMongoDbContext dbContext) { await dbContext.InternalEnsureIndexesAsync(recreate: true); } }
Pattern 4: Cross-Service Data Sync (One-Time)
public sealed class SyncEmployeesFromAccounts : PlatformDataMigrationExecutor<GrowthDbContext> { public override string Name => "20251015000000_SyncEmployeesFromAccounts"; public override DateTime? OnlyForDbsCreatedBeforeDate => new(2025, 10, 15); public override async Task Execute(GrowthDbContext 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 and write to MongoDB. Must be placed in the same project as the MongoDbContext (assembly scanning).
// Real example: bravoSURVEYS — backfill UserCompany.IsActive from Accounts public sealed class MigrateUserCompanyIsActiveFromAccountsDbMigration : PlatformDataMigrationExecutor<SurveyPlatformMongoDbContext> // ← MongoDB context { private const int PageSize = 100; private readonly AccountsPlatformDbContext accountsPlatformDbContext; // ← DI works! public MigrateUserCompanyIsActiveFromAccountsDbMigration( IPlatformRootServiceProvider rootServiceProvider, AccountsPlatformDbContext accountsPlatformDbContext) // ← Constructor injection : 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(SurveyPlatformMongoDbContext dbContext) { var totalCount = await accountsPlatformDbContext .GetQuery<AccountUserCompanyInfo>() .EfCoreCountAsync(); if (totalCount == 0) return; await RootServiceProvider.ExecuteInjectScopedPagingAsync( maxItemCount: totalCount, pageSize: PageSize, MigrateUserCompanyIsActivePaging); // Static method, DI-resolved params } // Static paging: first 2 params MUST be (int skipCount, int pageSize) private static async Task MigrateUserCompanyIsActivePaging( int skipCount, int pageSize, AccountsPlatformDbContext accountsPlatformDbContext, // ← DI-resolved SurveyPlatformMongoDbContext surveyDbContext) // ← DI-resolved { // 1. Read from SQL/PostgreSQL var userCompanyInfos = await accountsPlatformDbContext .GetQuery<AccountUserCompanyInfo>() .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); } }
Scrolling vs Paging
// PAGING: When skip/take stays consistent (items don't disappear) await RootServiceProvider.ExecuteInjectScopedPagingAsync(...); // SCROLLING: When processed items excluded from next query await UnitOfWorkManager.ExecuteInjectScopedScrollingPagingAsync(...);
Key Migration Options
| Option | Purpose | When to Use |
|---|---|---|
| Target specific DBs | Migrating existing data only |
| Non-blocking | Large migrations that can run async |
| Skip entity events | Data migrations (avoid event storms) |
| 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) │ └── 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
[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)
[PlatformRecurringJob("0 0 * * *")] // Daily at midnight public sealed class SyncCompanyDataJob : PlatformApplicationBatchScrollingBackgroundJobExecutor<Entity, string> { 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) { await entities.ParallelAsync(async entity => { entity.MarkSynced(); await repository.UpdateAsync(entity); }, maxConcurrent: 1); } }
Pattern 3: Master Job (Schedules Child Jobs)
[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 | | Every 5 minutes |
| Hourly | | Top of every hour |
| Daily midnight | | 00:00 daily |
| Daily 3 AM | | 03:00 daily |
| Weekly Sunday | | Midnight Sunday |
| Monthly 1st | | 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
Message Definition
// In PlatformExampleApp.Shared/CrossServiceMessages/ 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; } public TargetEmployee ToEntity() => new TargetEmployee { SourceId = Id, FullName = FullName }; public TargetEmployee UpdateEntity(TargetEmployee existing) { existing.FullName = FullName; return existing; } }
Entity Event Producer
internal sealed class EmployeeEntityEventBusMessageProducer : PlatformCqrsEntityEventBusMessageProducer<EmployeeEntityEventBusMessage, Employee, string> { 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))); } }
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) => true; public override async Task HandleLogicAsync( EmployeeEntityEventBusMessage message, string routingKey) { var payload = message.Payload; var entityData = payload.EntityData; // Wait for dependencies var companyMissing = await Util.TaskRunner .TryWaitUntilAsync( () => companyRepo.AnyAsync(c => c.Id == entityData.CompanyId), maxWaitSeconds: message.IsForceSyncDataRequest() ? 30 : 300) .Then(found => !found); if (companyMissing) 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)); } } }
Message Naming Convention
| Type | Producer Role | Pattern | Example |
|---|---|---|---|
| Event | Leader | | |
| Request | Follower | | |
Consumer Naming: Consumer class name = Message class name +
Consumer suffix
Key Message Bus 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)); }
Anti-Patterns Catalog
| Don't | Do | Pattern |
|---|---|---|
| fluent API | Commands |
| Side effects in command handler | Entity Event Handler in | Side Effects |
| Direct cross-service DB access | Message bus | Cross-Service |
| DTO mapping in handler | | DTOs |
| Separate Command/Handler files | ONE file: Command + Result + Handler | CQRS |
| | Events |
| Two generic parameters in event handler | Single generic: | Events |
Computed property without | Add empty setter | Entities |
Missing on navigation | Add | Entities |
| No dependency waiting in consumer | | Message Bus |
| No race condition handling | Check | Message Bus |
Only check action | Also check soft delete flag | Message Bus |
Use with DI | Use | Migrations |
| Unbounded parallelism | | Jobs |
| Wrong pagination for changing data | Use scrolling pattern | Jobs |
Checklist
- Service-specific repository (
)IPlatformQueryableRootRepository<T> - Fluent validation (
,.And()
) — NEVER throw.AndAsync() - No side effects in command handlers (use Entity Event Handlers)
- DTO mapping in DTO class (
,MapToEntity()
)MapToObject() - Cross-service uses message bus (NEVER direct DB access)
- Jobs have
parameter for parallelismmaxConcurrent - Migrations use
,dismissSendEvent: truecheckDiff: false - MongoDB migrations:
(simple) vsPlatformMongoMigrationExecutor
(DI)PlatformDataMigrationExecutor<MongoDbContext> - Database indexes configured for Entity static expression properties (see
Pattern 17).ai/docs/backend-code-patterns.md
Task Planning Notes
- Always plan and break many small todo tasks
- Always add a final review todo task to review the works done at the end to find any fix or enhancement needed