---
name: backend-business-layer
description: >
  Generates full CQRS stack — Commands, Queries, Handlers, DTOs, Validators,
  Service interface + implementation — on SmartStack NuGet abstractions
  (ICoreDbContext, MediatR, FluentValidation).
phase: development/backend
cli: cli/scaffold-business
allowed-tools: [Read, Glob, Grep, Bash]  # Bash: CLI invocation
---

# Business Layer — CQRS + Services

Generates the full application layer following the SmartStack CQRS pattern:
commands, queries, **handlers** (one per command/query), DTOs, FluentValidation
validators, service interface and service implementation.

## When to Use

- Phase 4 (business) of the development workflow
- When adding business logic for a new entity

## CQRS Pattern (output)

### Commands (each with a matching handler)

```csharp
public record CreateEmployeeCommand(string FirstName, string LastName) : IRequest<Guid>;

public class CreateEmployeeCommandHandler : IRequestHandler<CreateEmployeeCommand, Guid>
{
    private readonly IEmployeeService _service;
    public CreateEmployeeCommandHandler(IEmployeeService service) => _service = service;
    public Task<Guid> Handle(CreateEmployeeCommand request, CancellationToken cancellationToken)
        => _service.CreateAsync(request, cancellationToken);
}

public class CreateEmployeeCommandValidator : AbstractValidator<CreateEmployeeCommand>
{
    public CreateEmployeeCommandValidator()
    {
        RuleFor(x => x.FirstName).NotEmpty().MaximumLength(100);
        RuleFor(x => x.LastName).NotEmpty().MaximumLength(100);
    }
}
```

### Update/Delete commands return `IRequest<Unit>`

MediatR 12 convention: void operations still need a response type. We use `Unit`:

```csharp
public record UpdateEmployeeCommand(Guid Id, string FirstName, string LastName) : IRequest<Unit>;

public class UpdateEmployeeCommandHandler : IRequestHandler<UpdateEmployeeCommand, Unit>
{
    private readonly IEmployeeService _service;
    public UpdateEmployeeCommandHandler(IEmployeeService service) => _service = service;
    public async Task<Unit> Handle(UpdateEmployeeCommand request, CancellationToken cancellationToken)
    {
        await _service.UpdateAsync(request, cancellationToken);
        return Unit.Value;
    }
}
```

### Queries

```csharp
public record GetEmployeeQuery(Guid Id) : IRequest<EmployeeDetailDto?>;
// Every stored Guid FK of the entity appends an optional `Guid?` relation filter
// (here DepartmentId) — the read path of an entity IN RELATION (a 360 related tab
// fetches `?departmentId={currentId}`). Wire name = camelCase of the same field.
public record GetEmployeesQuery(int Page = 1, int PageSize = 20, string? Search = null,
    string? SortBy = null, string? SortDir = null, Guid? DepartmentId = null) : IRequest<PaginatedResult<EmployeeListDto>>;

// Screen stratum — scaffolder-owned input DTO for GetForListScreenAsync (same contract,
// including the FK relation filters), PLUS one optional member per pagespec filter
// (spec.screenFilters → lib/page-spec-filters.ts: select/text → string?, boolean →
// bool?, date-range → DateTime? From/To; lookups ride the Guid? FK channel and the
// global-search filter is the Search param). scaffold-screen-controller exposes the
// SAME ordered params as [FromQuery] and passes them as NAMED args.
public record GetEmployeeListScreenQuery(int Page = 1, int PageSize = 20, string? Search = null,
    string? SortBy = null, string? SortDir = null, Guid? DepartmentId = null,
    string? Grade = null, bool? Active = null, DateTime? HireDateFrom = null, DateTime? HireDateTo = null);

// Handlers delegate to the service (thin)
public class GetEmployeeQueryHandler : IRequestHandler<GetEmployeeQuery, EmployeeDetailDto?> { ... }
public class GetEmployeesQueryHandler : IRequestHandler<GetEmployeesQuery, PaginatedResult<EmployeeListDto>> { ... }
```

### Server-side list (pagination + search + sort)

The list query is **100% server-side** — the frontend list page sends
`page`/`pageSize`/`search`/`sortBy`/`sortDir` and reads `PaginatedResult.totalCount`;
it never fetches the whole set to slice in the browser.

**Tenant isolation is NOT this layer's job**: it rides the named "Tenant" EF
query filter mounted per entity in ExtensionsDbContext (scaffold-entity,
TENANT-FILTERS markers — gate DEV-API-032 must be green). Never add a manual
`TenantId` predicate here, and never `IgnoreQueryFilters()` / `FindAsync()`
(FindAsync bypasses every query filter — cross-tenant update/delete): loads by
id use `FirstOrDefaultAsync(x => x.Id == …)`.

`GetAllAsync` (and the screen
stratum's `GetForListScreenAsync`) MUST:

```csharp
var q = _context.Set<Employee>().AsQueryable();
if (!string.IsNullOrEmpty(query.Search))
    // multi-field: OR of LIKE across EVERY stored string column (not just the first)
    q = q.Where(x => EF.Functions.Like(x.FirstName, $"%{query.Search}%")
                  || EF.Functions.Like(x.LastName, $"%{query.Search}%"));
// Relation (FK) filters — one explicit Where per Guid FK, BEFORE CountAsync so the
// total reflects the filter. Never a dynamic property lookup (same anti-injection
// posture as the sort whitelist). Hand-written GetForListScreenAsync bodies MUST
// apply the same Wheres for every `Guid?` param of their ListScreenQuery — and one
// predicate per pagespec-filter member: select → equality, text → Like, boolean →
// equality, date-range → inclusive bounds. A member serving a COMPUTED column
// (settlementStatus, origin…) has no stored column: implement the same derivation
// the projection uses (subquery/join) — never a silent drop (wrong result set).
if (query.DepartmentId is not null)
    q = q.Where(x => x.DepartmentId == query.DepartmentId);
if (!string.IsNullOrEmpty(query.Grade))
    q = q.Where(x => x.Grade == query.Grade);
if (query.Active is not null)
    q = q.Where(x => x.Active == query.Active);
if (query.HireDateFrom is not null)
    q = q.Where(x => x.HireDate >= query.HireDateFrom);
if (query.HireDateTo is not null)
    q = q.Where(x => x.HireDate <= query.HireDateTo);
var total = await q.CountAsync(ct);                       // server-side total
var asc = (query.SortDir ?? "").ToLowerInvariant() == "asc";
var ordered = (query.SortBy ?? "").ToLowerInvariant() switch   // WHITELIST — anti-injection
{
    "firstname" => asc ? q.OrderBy(x => x.FirstName) : q.OrderByDescending(x => x.FirstName),
    // … one arm per stored column + Id + CreatedAt …
    _ => q.OrderByDescending(x => x.CreatedAt),            // default
};
var items = await ordered.Skip((query.Page - 1) * query.PageSize).Take(query.PageSize)
    .Select(x => new EmployeeListDto(...)).ToListAsync(ct);
```

A bare `.ToListAsync()` (no `Skip`/`Take`/`CountAsync`) makes the frontend paginate in
the browser — everything past `pageSize` is invisible. **Blocked by audit `DEV-API-017`.**

### DTOs (immutable records)

```csharp
public record EmployeeListDto(Guid Id, string FirstName, string LastName, DateTime CreatedAt);
public record EmployeeDetailDto(Guid Id, string FirstName, string LastName, /* all fields */, DateTime CreatedAt, DateTime? UpdatedAt);
public record CreateEmployeeDto(string FirstName, string LastName);
public record UpdateEmployeeDto(string FirstName, string LastName);
```

### Service (uses `ICoreDbContext` — the SmartStack abstraction)

```csharp
public class EmployeeService : IEmployeeService
{
    private readonly ICoreDbContext _context;
    public EmployeeService(ICoreDbContext context) => _context = context;

    public async Task<Guid> CreateAsync(CreateEmployeeCommand command, CancellationToken ct = default)
    {
        var entity = Employee.Create(command.FirstName, command.LastName);
        _context.Set<Employee>().Add(entity);
        await _context.SaveChangesAsync(ct);
        return entity.Id;
    }
    // UpdateAsync / DeleteAsync / GetByIdAsync / GetAllAsync…
}
```

## ⚠ BLOCKING — Key Rules

**Any generated file violating these rules MUST fail audit and be rewritten.**

1. **Every command/query has a handler** — MediatR fails at runtime otherwise (unresolved `IRequestHandler<>`).
2. **Commands modify state** — return `Guid` (created id) or `Unit` (void).
3. **Queries are read-only** — return DTOs, NEVER domain entities.
4. **Services take `ICoreDbContext`**, not `IApplicationDbContext` or the concrete `CoreDbContext`. This is the SmartStack abstraction boundary.
5. **FluentValidation** — one validator per command, one rule per PRD business rule. The CLI LANDS the wiring itself: `AddValidatorsFromAssembly` rides the `CLIENT-APPLICATION-ASSEMBLY-DI` marker block the generator splices into the Application DI host (idempotent, hand-written scans honoured — DEV-API-026 is the gate).
6. **DTOs are records** — immutable, value equality, nullable-aware.
7. **Service interface** — abstracts CQRS for controller consumption (`IEmployeeService`).
8. **No cross-layer leak** — Application must NOT import from `{AppCode}.Api.*` or `{AppCode}.Infrastructure.*` concrete types (only abstractions).
9. **`CancellationToken ct`** — propagated through every async method signature.
10. **`isKey` fields excluded from Update** — an update command never receives the primary key as a mutable field.
11. **Server-resolved factory tail (`tenantMode` / `dataScope` — mirror scaffold-entity EXACTLY).** The entity
    factory ends with `… [ownerUserId] [tenantId]` and the generated `CreateAsync` builds the call positionally:
    - `tenantMode != 'none'` (default `strict`) → the service injects `ICurrentTenantService`, resolves
      `tenantId` server-side (`?? throw`), appends it to the factory call. The Create DTO/Command NEVER carry it.
    - `dataScope.mode` includes `own` → the service injects `ICurrentUserAccessor` and passes
      `ownerUserId = _currentUser.UserId` at the owner's positional slot. The owner column is NEVER exposed on
      Create/Update DTOs, Commands or Validators (client input would be spoofable — row visibility hinges on it
      via the `{Entity}ScopePolicy` "DataScope" filter). The nullable assigned column stays exposed/updatable.
    Pass the SAME `tenantMode`/`dataScope` values given to `scaffold-entity` — a mismatch breaks the positional
    factory contract at compile time.
12. **`versioned` mirrors scaffold-entity** (rowversion optimistic concurrency — see the dedicated section).
    Pass the SAME flag; a mismatch breaks compilation (the generated service reads `entity.RowVersion`).
    `RowVersion` is EF-managed: it NEVER appears in Create DTOs/Commands, validators, search, sort or
    `entity.Update(...)` — only the Detail DTO (read), the Update DTO/Command (echo) and the save guard.

## Optimistic concurrency — offline-write (`versioned: true`)

Mirror of `scaffold-entity`'s `versioned` flag for the SAME entity — the PWA **offline-write 409** path
(socle `IVersionedEntity` / MyTime update pattern). When true the generator emits:

- **`{E}DetailDto`** — trailing `byte[]? RowVersion` member (base64 on the wire), mapped from
  `x.RowVersion` in the `GetByIdAsync` projection, so the client reads the token it must echo;
- **`Update{E}Dto` / `Update{E}Command`** — trailing `byte[]? RowVersion = null` (sent by the offline
  outbox on an edit replay; `null` for online edits — no conflict check, last-write-wins as before);
- **`UpdateAsync`** — before save:

  ```csharp
  if (command.RowVersion is { Length: > 0 })
  {
      _context.Set<Employee>().Entry(entity).Property(e => e.RowVersion).OriginalValue = command.RowVersion;
  }
  ```

  then `SaveChangesAsync` wrapped in `try/catch (DbUpdateConcurrencyException)` → reload the CURRENT
  state via `GetByIdAsync` → `throw new ConflictException(message, current)` — HTTP 409, the current
  server state riding in `ErrorResponse.Details` for the client outbox's `onConflict` hook.
  (The guard goes through `DbSet<T>.Entry(entity)` because `IExtensionsDbContext` exposes only
  `Set<T>()` + `SaveChangesAsync` — same semantics as the socle's `context.Entry(entity)`.)

The schema change (the `rowversion` column) lives on the ENTITY half (`scaffold-entity`): an EF extension
migration is REQUIRED — **signalled only, never auto-run; `/efcore` is the sanctioned path**. Pages planned
`offline: 'write'` REQUIRE the seam **end-to-end** (entity + business + wire) — audit **DEV-PWA-009**.

## Files emitted per entity

```
src/{Ns}.Application/{Mod}/
├── Commands/Create{E}Command.cs          — IRequest<Guid>
├── Commands/Update{E}Command.cs          — IRequest<Unit>
├── Commands/Delete{E}Command.cs          — IRequest<Unit>
├── Queries/Get{E}Query.cs                — IRequest<{E}DetailDto?>  + Get{Plural}Query
├── Handlers/Create{E}CommandHandler.cs   — delegates to I{E}Service
├── Handlers/Update{E}CommandHandler.cs
├── Handlers/Delete{E}CommandHandler.cs
├── Handlers/Get{E}QueryHandler.cs
├── Handlers/Get{Plural}QueryHandler.cs
├── Validators/Create{E}CommandValidator.cs
├── Validators/Update{E}CommandValidator.cs
├── Interfaces/I{E}Service.cs
└── DTOs/{E}Dtos.cs

src/{Ns}.Infrastructure/Services/{Mod}/
└── {E}Service.cs                         — uses ICoreDbContext
```

## Business Rules → Validators

Each business rule from the PRD becomes a FluentValidation rule:

```csharp
// PRD: "Start date must be before end date"
RuleFor(x => x.StartDate).LessThan(x => x.EndDate)
    .WithMessage("Start date must be before end date");
```

The CLI applies precedence: explicit `rule.expression` → description heuristics
(required / range / pattern / comparison) → TODO comment fallback with the rule id.
Each rule is emitted into **both** `Create{E}CommandValidator` and
`Update{E}CommandValidator` — a business invariant holds on modification too, not
only on creation.

**Clock-referencing rules get an injectable time source.** A rule expression
containing `DateTime.UtcNow` / `.Now` / `.Today` is rewritten onto an injected
`TimeProvider` (`_time.GetUtcNow().UtcDateTime`, `_time.GetLocalNow().DateTime`
/ `.Date`); both validators then take `TimeProvider` in their ctor and
`diPatches` registers `TimeProvider.System` in the `BUSINESS-SERVICES-DI`
block (skipped when the project registers its own). Blast radius is bounded:
a module with no clock-referencing rule keeps parameterless validators. Why: a
guard hard-wired on the ambient clock can never be tested AT its boundary — a
`FakeTimeProvider` can sit the test exactly on the threshold. (Non-goal:
audit timestamps in scaffold-entity stay on the ambient clock.)

## Post-scaffold business-logic pass (MANDATORY)

`scaffold-business` is deterministic and intentionally leaves the *hard* logic as
explicit, greppable markers rather than guessing. After the CLI has emitted the
CQRS stack, the dev agent (Phase 2 of `ba-develop`) **MUST** complete them — the
API gate is **BLOCKING** on zero residual markers (see `ba-develop/SKILL.md`
§ "After Phase 2 (API)") and audit `DEV-API-008/009` rejects any left behind.

Read the module's `règles-métier.md` (rule expressions + valid/invalid examples)
and its sections' `use-case.md` (main / alternative / exception flows), then:

1. **`// TODO[BR-…]` in a validator** — a business rule the heuristics could not
   translate. Replace it with the real enforcement:
   - field / cross-field, expressible in FluentValidation → write the `RuleFor(...)`
     in *both* `Create{E}CommandValidator` and `Update{E}CommandValidator`.
   - **multi-entity, stateful or temporal** (e.g. "cannot approve a budget whose
     parent is archived", "end date after start date and not in the past") →
     FluentValidation cannot express it. Enforce it inside the **service method**
     that performs the operation, throwing the domain exception the use case
     expects (`ValidationException` or a domain-specific one).
2. **`throw new NotImplementedException()` + `// TODO[UC-…]` in a service method** —
   a non-canonical custom action (anything beyond archive / restore / activate /
   deactivate / duplicate). Implement the body from the linked use case's main
   flow: load the aggregate, apply the state change + invariants, persist, return
   the response DTO. Keep the `// UC-…` trace comment.
3. **Trace** every rule / use case you implement with a `// BR-…` / `// UC-…`
   comment so the audit can confirm coverage.

Never delete a marker without implementing it, and never weaken a test to make a
stub pass. If a rule is genuinely unimplementable from the BA docs, halt and route
back to `create-prd` / the owning BA phase — do not ship a stub.

## Client DI registration (AUTOMATIC — the CLI lands it)

The CLI patches the registrations itself on every run — the manual-nextSteps
era shipped whole modules whose EVERY endpoint answered 500 behind a green
build, green audits and green unit tests (nothing exercised the container):

1. `services.AddScoped<I{E}Service, {E}Service>();` — one `global::`-qualified
   line per entity, unioned into the `<<< BUSINESS-SERVICES-DI BEGIN/END >>>`
   marker block of `src/{Ns}.Infrastructure/DependencyInjection.cs` (fallbacks:
   `ServiceCollectionExtensions.cs`, `InfrastructureModule.cs`; a pre-marker
   host gets the block inserted before its last `return services;`).
2. The client Application assembly scan — MediatR handlers + FluentValidation
   validators — spliced as the `<<< CLIENT-APPLICATION-ASSEMBLY-DI BEGIN/END >>>`
   block of `src/{Ns}.Application/DependencyInjection.cs`. The platform's
   `AddSmartStack()` scans ONLY its own assembly; without this block every
   generated Handler/Validator is invisible to MediatR at runtime.

Idempotent (lib/di-markers): a re-run never duplicates a line; a registration
the project wrote by hand OUTSIDE the block is detected and skipped. Gates:
`audit-dev-api DEV-API-026` (static) and the generated `DiResolutionTests`
(runtime — resolves every controller from the real container).

## After generation — MANDATORY

Once the CLI has written all files, invoke the audit skill:

```
@.claude/skills/development/audit/SKILL.md
Audit the application layer I just generated in src/{AppCode}.Application/
for the entity {Name}. Check CQRS conventions, validator coverage,
DTO-vs-entity, handler presence, DI registrations.
```

The audit MUST be green before declaring the generation "done".

## Invocation

```bash
npx --prefer-offline tsx skills/development/backend/business-layer/cli/scaffold-business/index.ts \
  --spec '{"name":"Employee","module":"hrm","appCode":"MyApp","fields":[{"name":"firstName","type":"string","required":true,"maxLength":100}],"businessRules":[...],"projectPath":"/path"}'
```

`fields[].isKey` marks a field that is part of the primary key — excluded from
Update commands. Default is `false`.

`fields[].formula` (optional, string) marks a **computed attribute**. When set,
the generator treats the field as read-only :

- `${E}ListDto` and `${E}DetailDto` include it.
- `Create${E}Dto`, `Update${E}Dto`, `Create${E}Command`, `Update${E}Command` and
  the FluentValidation validators **exclude** it (the user never writes the
  value).
- `Get${E}` and `Get${Plural}` LINQ projections inject the formula directly :
  ```csharp
  .Select(x => new BudgetListDto(
      x.Id,
      x.Label,
      x.InitialAmount,
      x.CurrentBalance,
      (x.InitialAmount - x.CurrentBalance) / x.InitialAmount,  // consumptionRate
      x.CreatedAt))
  ```
  → one SQL query, no N+1, no separate analysis endpoint.

The formula syntax is a C# expression referencing **PascalCase property names**
of the SAME entity (`InitialAmount`, `CurrentBalance`, …). The CLI rewrites
each known property reference to `x.<Property>` so EF Core can translate it.
Cross-entity references are forbidden at the BA level (audit DM-016) and never
reach the dev pipeline.

When the PRD slice declares a computed column (e.g. `domain.md` lists
`Budget.consumptionRate (decimal) = (InitialAmount - CurrentBalance) / InitialAmount`),
you MUST forward that formula via the `formula` field of the corresponding
spec entry. Do NOT emit a stub helper or hand-roll the projection — the CLI
output is the single source of truth.

`fields[].source` (optional, object) marks a **Core-projected field** — the
sibling of `formula` for values that live in a SmartStack Core entity (V1
whitelist) reached through the navigation property scaffold-entity emitted for
a `targetScope: 'core'` relation. Shape:
`{ "nav": "User", "target": "TenantOrganisation"?, "property": "FirstName", "fkField": "UserId"?, "fallbackLocal": "Email"? }`
(`target` defaults to `nav` — set it when the nav is renamed, e.g. `Customer` →
`TenantOrganisation`; `fkField` defaults to `{nav}Id`). Three canonical shapes:

- **Person `mandatory`** (FK required): `{"nav":"User","property":"FirstName"}` →
  the LINQ projection reads `x.User.FirstName`. The field is a PURE projection:
  in `${E}ListDto`/`${E}DetailDto` only, excluded from Create/Update
  DTOs/Commands/Validators and the factory call (same mechanics as `formula`).
- **Person `optional` (overlay)**: the field IS the local stored column and
  `fallbackLocal` equals its own name —
  `{"nav":"User","property":"Email","fkField":"UserId","fallbackLocal":"Email"}`
  with a nullable FK → read path `(x.UserId != null ? x.User!.Email : x.Email)`;
  the column stays writable (still in Update commands).
- **Core-reference display** (e.g. `Order.CustomerCompanyName`):
  `{"nav":"Customer","target":"TenantOrganisation","property":"Name","fkField":"CustomerCompanyId"}`
  → `x.Customer.Name` (required FK) or
  `(x.CustomerCompanyId != null ? x.Customer!.Name : null)` (nullable FK; the
  DTO member becomes nullable, value types get an explicit `(T?)` cast).

Rules: `property` is a SINGLE identifier — traversal (`Department.Name`) is
rejected because navigations between two whitelist entities are ignored by
`SmartStackExtensionDbContext` (use `ICoreDataService` for nested Core reads).
**No `Include()` is ever emitted** — projections through a reference navigation
translate to SQL JOINs on their own. Never forward a pure-projection field to
`scaffold-controller`'s `fields[]` (its dto→command mapping maps every field to
a Command argument and would not compile) — same exclusion as `formula` fields.
ba-develop Phase 2a derives `source` entries from the PRD's `Person:`/`Proj:`
lines (themselves from `entité.md`'s `Personne` line + `scope core` Relations).
