Skip to content

Convention — lookup CRUD template

What it is

The repeatable recipe for adding the next lookup-admin CRUD feature. ManpowerIQ matured this template across seven reference-data entities — ShiftTemplate, Holiday, DemandReason, NodeType, Terminal, Grade, Skill — over sprints MIQ-131, MIQ-132 and MIQ-133. Following it means a new lookup gets a consistent 5-verb API, soft-delete, FK-protection, bilingual i18n, RBAC permissions, and a matching React admin screen, with no surprises.

When to use it

When you need full admin management (list / view / create / edit / delete) for a reference-data entity. If the entity already has a production/runtime surface, this admin track coexists alongside it — see Coexistence pattern.

The recipe

1. Pre-flight audit (do this first). Before writing code, confirm the shape (MIQ-131/132/133 reports, "pre-flight" sections):

  • Entity base class — TenantEntity (BU-scoped) vs AuditableEntity (global). AuditableEntity descendants need an explicit HasQueryFilter(x => !x.IsDeleted) in the DbContext config or soft-deleted rows leak into lists; TenantEntity descendants inherit the central filter (MIQ-132 decision 46).
  • Every FK pointing at this entity — each one is a separate "referenced" count surface for delete-blocking (see step 3).
  • Arabic name nullability — it is per-entity, not global (4 of 7 entities require Name_2_Arabic; DemandReason leaves it optional). Audit it independently; never blanket-copy the previous entity (MIQ-131 decision 40; MIQ-132 decision 47).
  • The code unique index must carry WHERE is_deleted = false so a code can be reused after a soft-delete (PB-043 compliance).

2. Backend. Mirror the shape of an existing entity (Grade is the canonical 1-FK example):

  • Application/<Entity>s/I<Entity>Service (the 5 verbs), <Entity>Dtos (ListItem / Detail / CreateRequest / UpdateRequest — no Code field on UpdateRequest, code is immutable), event-type constants, and a <Entity>ReferencedException if delete can be FK-blocked.
  • Infrastructure/Services/<Entity>s/<Entity>Service — implements the interface; injects IAuditLogger, ICurrentTenantProvider, and IStringLocalizer<ErrorMessages> (the service composes localized message bodies — the controller stays mechanical). Delete calls SoftDeleteExtensions.MarkDeleted(...).
  • API/Controllers/<Entity>sController — route /api/admin/<entity-plural>, 5 verbs each [Authorize(Policy = "<entity>.view")] / ".config", using the shared helpers (see Helpers catalog).
  • API/Validation/<Entity>Validators — FluentValidation Create/Update validators, auto-registered.
  • Register services.AddScoped<I<Entity>Service, <Entity>Service>() in Program.cs.

3. Delete-protection. Two guards, in this order: reject if the row IsSystem (a seeded system row), then count each FK surface and, if any is non-zero, throw the <Entity>ReferencedException (409). See Exceptions.

4. i18n keys. Add the entity's keys to both ErrorMessages.resx and ErrorMessages.ar.resx in lockstep, following the BE i18n key convention: <Entity>.IsSystem.Body, <Entity>.Referenced.{Header, Intro, Surface.<Table>, Footer}, <Entity>.Validation.<Rule>.

5. Migration. Seed the two permissions (<entity>.view, <entity>.config) with category = "Reference Data"; add the partial-unique index migration if the code index lacks the is_deleted = false filter.

6. Frontend. api/<entity-plural>.ts (5 wrappers) → a hook file consuming the createLookupHooks factory → an <Entity>Table + dialog → a page using the shared DeleteConfirmDialog → route + SideNav entry under Reference Data → en.json/ar.json keys in parity. See Helpers catalog for the factory and dialog.

7. Tests. A render/permission test (web) asserting en/ar key parity; an integration test using the FK-block try/finally ownership pattern (see Test patterns).

Gotchas / constraints

  • code is immutable on update — enforced by omitting it from UpdateRequest; the FE disables the input as a visual backstop only.
  • Service composes the localized body, not the controllerIStringLocalizer<ErrorMessages> is injected at the service ctor; the controller just maps the caught exception to a 409 with the code.
  • Don't OR the FK pre-checks together — count each surface separately so the "referenced by" message can name exactly which tables block the delete.
  • Arabic nullability per entity — re-audit each time; copying the previous entity's validator is the classic trap.
  • Factory/dialog shapes are locked — if a new entity doesn't fit the shared factory or dialog, keep that entity's hooks/dialog inline rather than bending the abstraction. This is the §G discipline.

Build status

Available — the template is live across all seven lookup entities (MIQ-131/132/133). It is a convention, not a framework: there's no generator, you mirror an existing entity.