---
name: backend-data-layer
description: >
  Generates Domain entities with BaseEntity (SmartStack.Core.Domain NuGet),
  domain events, soft-delete, EF Core configurations with schema targeting,
  and migrations.
phase: development/backend
cli: cli/
allowed-tools: [Read, Glob, Grep, Bash]  # Bash: CLI invocation
---

# Data Layer — Entities + Events + EF Core + Migration

Generates domain entities, domain events, and their EF Core configurations
following SmartStack conventions. Consumes NuGet packages:
- `SmartStack.Core.Domain.BaseEntity` (provides `Id`, `CreatedAt`, `UpdatedAt`, `DeletedAt`, `AddDomainEvent`)
- `SmartStack.Core.Domain.ITenantEntity` / `IOptionalTenantEntity`
- `SmartStack.Core.Domain.IDomainEvent` (for emitted events)
- `SmartStack.Infrastructure.Persistence.SchemaConstants` (Core vs Extensions)

## Entity Pattern

- `BaseEntity` inheritance (Id, CreatedAt, UpdatedAt, DeletedAt + domain events list)
- Tenant: `ITenantEntity` (strict) / `IOptionalTenantEntity` / none
- Private parameterless constructor + static `Create()` factory
- `Update(...)` mutator with required-field validation
- `SoftDelete()` / `Restore()` toggles `DeletedAt`
- Domain events emitted on Create/Update/SoftDelete/Restore

## Domain Events

Each entity gets four companion event records in `{Entity}Events.cs`:

```csharp
public record EmployeeCreatedEvent(Guid EmployeeId) : IDomainEvent;
public record EmployeeUpdatedEvent(Guid EmployeeId) : IDomainEvent;
public record EmployeeDeletedEvent(Guid EmployeeId) : IDomainEvent;
public record EmployeeRestoredEvent(Guid EmployeeId) : IDomainEvent;
```

Clients can subscribe via MediatR `INotificationHandler<EmployeeCreatedEvent>`.

## Schema targeting

The `schemaTarget` input decides the DB schema:
- `"core"` → `SchemaConstants.Core` — for SmartStack built-in extensions only
- `"extensions"` → `SchemaConstants.Extensions` (default) — for client modules

Client projects should always leave `schemaTarget` to default. `"core"` is
reserved for SDK-level seeding.

## Table & column naming (aligned with SmartStack.app)

- **Table**: `{domainPrefix}_{PluralName}` (PascalCase plural) in the selected
  schema — e.g. `aff_Demandes`, `support_Tickets`, `hrm_Employees`.
- **`domainPrefix`**: the entity's **business-domain** prefix from `entité.md`
  (`Préfixe table: aff_` → `aff`), lowercase alphanumeric. **Never `ext`** —
  `ext` is the migration prefix of the `extensions` schema, not a table prefix.
- **Columns**: **PascalCase, EF Core default mapping — NO `HasColumnName`.**
  SmartStack.app never remaps columns (`FirstName`, `CreatedAt`, `ClientId`),
  so neither do we. snake_case columns are forbidden.
- **FK columns**: `{TargetEntity}Id` (PascalCase), default mapping.

## Indexes

Always generated (EF Core default index names — no `HasDatabaseName` override):
- an index on `CreatedAt` — sort/filter by recency
- an index on `TenantId` (if tenant mode ≠ none)

Additionally, any field flagged `indexed: true` in the spec gets its own index.

## EF Core Configuration Pattern

Columns use EF Core's **default PascalCase mapping** — no `HasColumnName`. Only
constraints (required string / maxLength) and indexes are declared.

```csharp
public class EmployeeConfiguration : IEntityTypeConfiguration<Employee>
{
    public void Configure(EntityTypeBuilder<Employee> builder)
    {
        builder.ToTable("hrm_Employees", SchemaConstants.Extensions);
        builder.HasKey(e => e.Id);
        builder.HasQueryFilter(e => e.DeletedAt == null);

        builder.HasIndex(e => e.CreatedAt);
        builder.HasIndex(e => e.TenantId);

        builder.Property(e => e.FirstName).IsRequired().HasMaxLength(100);
        builder.Property(e => e.LastName).IsRequired().HasMaxLength(100);
    }
}
```

## Relationships (foreign keys)

Same-module foreign keys are declared in the `relations[]` input — **not** as
bare `Guid` fields. Each owning relation (`many-to-one` / `one-to-one`) emits, in
generated code: the FK column (`{TargetEntity}Id` by default, or `foreignKey`),
a reference **navigation property**, and a **real EF Core constraint**
`HasOne(...).WithMany()/.WithOne().HasForeignKey(...).OnDelete(DeleteBehavior.X)`.

`onDelete` mirrors the BA cascade vocabulary (`create-data-model/levels/relationships.md`):

| `onDelete` | EF `DeleteBehavior` | Use when |
|------------|---------------------|----------|
| `restrict` (default) | `Restrict` | a rule blocks deletion of the parent |
| `cascade` | `Cascade` | children can't exist without the parent |
| `set-null` | `SetNull` | detach on delete (requires `nullable: true`) |
| `no-action` | `NoAction` | self-references (SQL can't cascade a cycle) |

`one-to-many` / `many-to-many` declared on the principal emit only the inverse
collection navigation (`ICollection<T>`); the FK column lives on the owning side.

Relation spec entry (same-module):

```json
{ "type": "many-to-one", "targetEntity": "Contact", "foreignKey": "ContactId", "nullable": false, "onDelete": "restrict" }
```

Cross-module / Core variant — add `targetScope` (and `targetTable`/`targetSchema` for `core`):

```json
{ "type": "many-to-one", "targetEntity": "Department", "foreignKey": "DepartmentId", "nullable": true, "onDelete": "restrict", "targetScope": "core", "targetTable": "ref_Departments", "targetSchema": "core" }
```

**Cross-module / Core references are ALSO real FKs.** A navigation property can't
span the two DbContexts, but the FK CONSTRAINT can — it is ONE physical database
(`core` + `extensions` schemas). `cross-module` → FK to the real type, no
navigation; `core` → FK to a generated `*Reference` principal stub mapped to the
Core table (`ExcludeFromMigrations`), a real cross-schema constraint (see
`relationships.md`). The ONLY non-FK `*Id` columns are the identity/audit
allowlist (`lib/fk-allowlist.ts`). Audit **DEV-DAT-008** fails the build (err) when
ANY declared relationship has no matching FK constraint with the declared cascade.

## CLIs

This skill contains **two colocalized CLIs**:

- **`cli/scaffold-entity`**: emits `Entity.cs` + `EntityEvents.cs` + `Configuration.cs` (incl. FK constraints + navigation properties from `relations[]`)
- **`cli/scaffold-migration`**: runs `dotnet ef migrations add`, then **freezes any new/changed SQL objects** (functions/views/procs under `…/Persistence/SqlObjects/**`) into the generated migration as `migrationBuilder.Sql(@"…")` literals — EF never emits raw SQL, so without this a `database update` that doesn't boot the app won't deploy them. Project-name-agnostic (glob), reuses the shared `lib/sql-objects.ts` helper (same as `/efcore create` + `squash`). Reports them in `report.sqlObjectsInlined`.

## Invocation

```bash
npx --prefer-offline tsx skills/development/backend/data-layer/cli/scaffold-entity/index.ts \
  --spec '{"name":"Employee","module":"hrm","appCode":"MyApp","domainPrefix":"hrm","fields":[{"name":"FirstName","type":"string","required":true,"maxLength":100,"indexed":true}],"relations":[{"type":"many-to-one","targetEntity":"Department","foreignKey":"DepartmentId","nullable":false,"onDelete":"restrict"}],"tenantMode":"strict","schemaTarget":"extensions","projectPath":"/path"}'
```

## Key rules

1. **Factory + private ctor**: `new Employee()` outside the class MUST fail.
2. **Required string validation**: `ArgumentException.ThrowIfNullOrWhiteSpace(...)` in `Create` and `Update`.
3. **Domain events on every state change**: Create/Update/SoftDelete/Restore each emit one.
4. **Soft-delete, not hard-delete**: `SoftDelete()` sets `DeletedAt`. Physical deletion is an ops concern.
5. **Query filter**: `HasQueryFilter(e => e.DeletedAt == null)` so soft-deleted rows are hidden by default.
6. **Indexes default on**: CreatedAt and TenantId always indexed; other fields require `indexed: true`.
7. **domainPrefix convention**: lowercase alphanumeric, no hyphen — the entity's
   business-domain prefix from `entité.md` (`aff_` → `aff`), **never `ext`** (that
   is the `extensions` migration prefix, not a table prefix).
8. **Computed fields are skipped**: any `fields[]` entry with a non-empty
   `formula` is **omitted** from the Domain entity and the EF Configuration —
   the value lives only in the read DTOs (`scaffold-business` injects the
   formula into the LINQ projection, single SQL query, no extra column).
   Don't add a property by hand in the Domain entity for a computed field
   ; the `[NotMapped]` round-trip would just confuse EF tracking.
9. **Every relationship becomes a real FK (any scope)**: each `relations[]` entry
   of type `many-to-one` / `one-to-one` emits the FK column +
   `HasOne(...).HasForeignKey(...).OnDelete(DeleteBehavior.X)` with the declared
   cascade — plus a navigation property for `same-module`. `cross-module` / `core`
   refs emit a no-navigation FK (`core` via a `*Reference` stub +
   `ExcludeFromMigrations`, a cross-schema constraint). The `TenantId` FK is emitted
   automatically from `tenantMode`. Only the identity/audit allowlist stays a bare
   `Guid` (DEV-DAT-008 enforces all of this — blocking).
10. **Columns are PascalCase**: EF Core default mapping, NO `HasColumnName`. The
   property name IS the column name (`FirstName`, `CreatedAt`, `ClientId`),
   matching SmartStack.app. snake_case columns are forbidden.
