easyplatform-backend
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
PlatformValidationResultfluent 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.mdbefore 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.mdfor 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
RootRepositoryin your codebase to find project interfaces) - Validation: Use
PlatformValidationResultfluent 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.csUpdateCategoryStatsOnSnippetChangeEventHandler.csSyncEmployeeOnEmployeeUpdatedEntityEventHandler.csSendEmailOnPublishGoalEntityEventHandler.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:
PlatformMongoMigrationExecutorusesActivator.CreateInstance— NO DI support. If you need DI (inject other DbContexts, repositories, services), usePlatformDataMigrationExecutor<MongoDbContext>instead. This works becausePlatformMongoDbContextimplementsIPlatformDbContext, andMigrateDataAsyncscans the DbContext's assembly forPlatformDataMigrationExecutor<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)
⚠️
PlatformMongoMigrationExecutorhas NO constructor injection and NORootServiceProvider. It usesActivator.CreateInstance. OnlyExecute(TDbContext dbContext)is available. Access collections viadbContext(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
maxConcurrentlimit - 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-designdatabase-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