easyplatform-backend
Easy.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 | UseCaseCommands/{Feature}/ |
PlatformCqrsCommand, PlatformCqrsCommandApplicationHandler |
| CQRS Queries | UseCaseQueries/{Feature}/ |
PlatformCqrsPagedQuery, GetQueryBuilder |
| Entities | {Service}.Domain/Entities/ |
RootEntity, RootAuditedEntity, static expressions |
| Event Handlers | UseCaseEvents/{Feature}/ |
PlatformCqrsEntityEventApplicationHandler |
| Migrations | DataMigrations/, Migrations/ |
PlatformDataMigrationExecutor, PlatformMongoMigrationExecutor |
| Background Jobs | BackgroundJobs/ |
PlatformApplicationPagedBackgroundJobExecutor |
| Message Bus | MessageBusProducers/, MessageBusConsumers/ |
PlatformApplicationMessageBusConsumer |
Critical Rules:
- Validation:
PlatformValidationResultfluent API (.And(),.AndAsync()) — NEVER throw exceptions - Side Effects: Entity Event Handlers in
UseCaseEvents/— NEVER in command handlers - Cross-Service: Message bus only — NEVER direct database access
- DTO Mapping: DTOs own mapping via
MapToEntity()— NEVER in handlers - 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 |
|---|---|---|
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
}
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 |
|---|---|---|
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: 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 |
|---|---|
@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.
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)
⚠️
PlatformMongoMigrationExecutorhas NO constructor injection and NORootServiceProvider. Access collections viadbContext(e.g.,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 |
|---|---|---|
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)
│
└── 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 | */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
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 | <ServiceName><Feature><Action>EventBusMessage |
CandidateJobBoardApiSyncCompletedEventBusMessage |
| Request | Follower | <ConsumerServiceName><Feature>RequestBusMessage |
JobCreateNonexistentJobsRequestBusMessage |
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 |
|---|---|---|
throw new ValidationException() |
PlatformValidationResult fluent API |
Commands |
| Side effects in command handler | Entity Event Handler in UseCaseEvents/ |
Side Effects |
| Direct cross-service DB access | Message bus | Cross-Service |
| DTO mapping in handler | PlatformEntityDto.MapToEntity() |
DTOs |
| Separate Command/Handler files | ONE file: Command + Result + Handler | CQRS |
protected bool HandleWhen() |
public override async Task<bool> HandleWhen() |
Events |
| Two generic parameters in event handler | Single generic: <Entity> |
Events |
Computed property without set { } |
Add empty setter | Entities |
Missing [JsonIgnore] on navigation |
Add [JsonIgnore] |
Entities |
| No dependency waiting in consumer | TryWaitUntilAsync |
Message Bus |
| No race condition handling | Check LastMessageSyncDate |
Message Bus |
Only check Deleted action |
Also check soft delete flag | Message Bus |
Use PlatformMongoMigrationExecutor with DI |
Use PlatformDataMigrationExecutor<MongoDbContext> |
Migrations |
| Unbounded parallelism | maxConcurrent: 5 |
Jobs |
| Wrong pagination for changing data | Use scrolling pattern | Jobs |
Checklist
- Service-specific repository (
IPlatformQueryableRootRepository<T>) - Fluent validation (
.And(),.AndAsync()) — NEVER throw - 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
maxConcurrentparameter for parallelism - Migrations use
dismissSendEvent: true,checkDiff: false - MongoDB migrations:
PlatformMongoMigrationExecutor(simple) vsPlatformDataMigrationExecutor<MongoDbContext>(DI) - Database indexes configured for Entity static expression properties (see
.ai/docs/backend-code-patterns.mdPattern 17)
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