Reference CRUD pattern: the City slice
For master-data entities backed by legacy Mas*-prefixed scaffolded tables, follow
the five-layer shape established by City end-to-end. Add new entities by copying
this shape, not by inventing a new one.
Database-first rules
- The DbContext and entities under
Infrastructure/Persistence/Scaffolded/are generated. Never hand-edit generated files. - The re-scaffold command lives in the header comment of
EosDbContext.cs. - Put hand-written additions in
EosDbContext.partial.csso they survive a re-scaffold. - The rest of the codebase must go through repository interfaces in
Application/Common/Interfaces, never touch scaffolded entities directly. - Map scaffolded entities to hand-written domain models in the Infrastructure layer.
:::danger Real, confirmed schema drift across clients — never fetch a full scaffolded entity when you only need a few columns
MenuRepository first documented this for MAS_MENU/MAS_APP_MENU/MAS_APP
(different clients' real schemas have drifted from the scaffold); confirmed a
second time, independently, on MAS_ORDER_TYPES (2026-07-28, building
MasterOrder's live-stats computation) — ceva-eos's real table is missing
several columns (BLTYPE, MBLType, a few PEMS* fields) the scaffolded
MasOrderType model expects, so a full ToListAsync()/FirstOrDefaultAsync()
(which SELECTs every mapped column regardless of what's actually used) throws
a raw SqlException: Invalid column name.
This isn't a one-off fluke on one table — treat it as a standing risk on any
Mas* table, especially the wide ones (MAS_ORDER_TYPES, MAS_MENU,
MAS_STATUS, MAS_TRCODE, MAS_USER — anything with 50+ columns that's
plausibly grown/changed shape differently per client over the years).
Always use a narrow .Select(x => new { x.OnlyTheColumnsYouNeed }) projection
for any new Mas* read, never a bare full-entity fetch — a projection only
ever SELECTs the columns it explicitly names, so it can't be broken by a
column the current client's real table happens to be missing elsewhere in that
same row.
:::
Step 1: DTO
Application/Modules/<Module>/Dto/<Entity>Dto.cs (e.g. CityDto.cs). Plain data
shape at the Application boundary, mirroring every field the legacy write SP
accepts — not just the fields a first pass happens to need. If the SP takes 17
parameters, the DTO carries all 17; otherwise an Update through the SP silently
blanks the columns the DTO omitted. Never return a scaffolded EF entity from a
repository.
Step 2: Repository interface
Application/Common/Interfaces/I<Entity>Repository.cs (e.g. ICityRepository.cs).
Declares GetByCodeAsync / ListAsync / CreateAsync / UpdateAsync /
DeleteAsync in terms of the DTO. Use the entity's real key type (most Mas*
tables key on a string code, not an int) — don't force-fit IReadRepository<T>
if the key shape doesn't match.
Step 3: Repository implementation
Infrastructure/Persistence/Repositories/<Module>/<Entity>Repository.cs (e.g.
CityRepository.cs). Split reads and writes by what the legacy system actually
does — don't assume one mechanism for both:
-
GetByCodeAsync/ListAsync— plain EF Core (AsNoTracking()+ LINQ), mapping the scaffolded entity to the DTO inside the repository. City/Country's legacyGet*Ltlist SPs (MAS_GetCityLt/MAS_GetCountryLt) are not used — reads go straight through EF. -
CreateAsync/UpdateAsync/DeleteAsync— call the legacy combined "manage" stored procedure (e.g.MAS_ManCity,MAS_ManCountryDt) via a rawSqlCommandoffcontext.Database.GetDbConnection(),CommandType.StoredProcedure, always through typedSqlParameters (never string-concatenated SQL). These SPs discriminate the operation with a single int parameter (@Action/@ActionType) — see theManageActionenum:// Infrastructure/Persistence/Repositories/ManageAction.cspublic enum ManageAction{Insert = 1,Update = 2,Delete = 3,}— and report outcome through an output parameter (
@Result): the new row's identity on insert, rows-affected on update/delete. Confirm the real SP name, parameter list/order, and@Resultsemantics before wiring a new entity up — don't guess. The parameter set a delete needs can differ per SP (City's delete reuses the full insert/update parameter list; Country's delete sends only the key + action type, relying on SP-side defaults) — match what the legacy caller actually sends, not what feels symmetric. -
Inject
ILogger<T>. Log Information for normal reads/writes, Warning for handled failures (not-found, zero rows affected). IncludeActivity.Current?.IdasCorrelationIdon every log line. Never log full DTOs or SP parameter dumps — only codes/counts. Never log lat/long, manifest doc paths, connection strings, orUpdatedBy-style actor fields.
Step 4: DI registration
Register interface to implementation as scoped in
Infrastructure/DependencyInjection.cs.
Step 5: Thin controller
Api/Controllers/<Consumer>/<Entities>Controller.cs (e.g. CitiesController.cs).
Constructor-injects only the repository interface, contains no business logic
and no logging of its own (logging lives in the repository, at the DB/SP
boundary): GET-all, GET-by-key, POST, PUT, DELETE that just call the repository
and return Ok / NotFound / CreatedAtAction / NoContent.
Step 6: In-memory cache the ListAsync result — lookup tables only
Inject IMemoryCache and wrap ListAsync's query in cache.GetOrCreateAsync(key, ...) with entry.AbsoluteExpirationRelativeToNow set to a sensible duration (1
hour for City/Country). Key convention: "Mas:<Entity>:All". Every
CreateAsync/UpdateAsync/successful DeleteAsync must call
cache.Remove(key) on success so the cache never serves stale rows after a
write — this is not optional, it's the invalidation half of the same pattern.
This applies only to Mas*/lookup/reference tables (rarely-written master
data like City, Country, currency, unit-of-measure). Do not cache
GetByCodeAsync (single-row reads stay live) and do not apply this pattern
to transactional data — Operations/Rf/Reporting/Edi entities (consignments,
shipments, statuses, anything that changes per business event) must always read
live from the database. Caching transactional data risks serving stale
operational state; this pattern exists specifically because Mas* tables
change rarely and the whole list is small.
:::caution "The whole list is small" doesn't hold for every Mas* table in every real client's data
MAS_CITY turned out to be large enough in at least one real client's database
that the first request after a cold cache (app start, or the 1hr TTL expiring)
paid a real, multi-second uncached DB query + full-table serialization —
noticeable specifically as "the Cities page is slow the first time it's
opened," not on later opens once the cache is warm.
Fixed by warming ICityRepository.ListAsync() once at API startup
(Program.cs, before app.Run()), so that cost lands at boot instead of on
whichever user gets there first — wrapped in try/catch so a warm-up failure can
never block the app from starting. If another Mas* entity turns out to be
similarly large in some client's real data, give it the same startup warm-up
rather than leaving the first user to pay for it.
:::
Reference files: CityDto.cs, ICityRepository.cs, CityRepository.cs,
CitiesController.cs, ManageAction.cs (and their Country counterparts),
MasListRepositoryBase.cs.
Shared read-side base class
MasListRepositoryBase<TEntity, TDto>
(Infrastructure/Persistence/Repositories/Mas/MasListRepositoryBase.cs) factors
out the cache-or-create block behind Step 6's ListAsync/ListByTypeAsync and
the DB-paged Skip/Take behind ListPagedAsync, plus their log lines —
CityRepository/CountryRepository/ChoiceRepository all inherit it today.
It's read-only by design: writes (Step 3) stay entity-specific
(SqlCommand/MAS_Man* vs. plain EF, different key shapes) rather than
force-fit into the base. Its two protected methods take the query, sort, and
map as delegates supplied by the caller rather than overridden abstracts — this
is what lets Choice's ListByTypeAsync (active-only) and ListPagedAsync (all
rows, see the exception note below) use two different base queries for the same
entity without any per-instance state.
Reuse this base for the next simple Mas* reference-data repository that would
otherwise duplicate this shape; don't force-fit a territory-gated or
otherwise-special entity (WarehouseOrder, etc.) into it. See the Server-Side
Pagination page and the Territory Access Pattern page for more on gated
entities.
Exception: entities with no legacy manage stored procedure
Step 3 above assumes a MAS_Man* SP exists to write through. Confirm that
before assuming it — some Mas* tables never had one (nothing in the legacy
app ever wrote them through a stored proc).
MAS_CHOICES (ChoiceRepository) is the first such entity: its
CreateAsync/UpdateAsync/DeleteAsync go through plain EF Core
(context.MasChoices.Add/Remove + SaveChangesAsync) instead of a
SqlCommand/ManageAction call. Everything else about the pattern still
applies unchanged — DTO mirrors every writable column, cache invalidation on
every successful write, ILogger at Information/Warning, thin controller. If a
future entity also turns out to have no manage SP, use this same
plain-EF-Core write path rather than inventing a third mechanism.
MAS_CHOICES also composite-keys on (ChoiceType, ChoiceCode) and is shared
by many unrelated picklists — its admin page (ChoicesPage.tsx) filters the
grid by a ChoiceType selector (sourced from the separate MAS_CHOICE_TYPES
lookup table via ChoiceTypesController/IChoiceTypeRepository, same
read-only-lookup shape as AddressTypesController) rather than showing one
global unfiltered grid.
Its GetByType endpoint also has a real semantic difference between its two
paged/unpaged shapes, not just a payload-size one: unpaged (page omitted)
stays active-only and cached — what real consumers like LanguageSelector
read — while the paged admin shape includes inactive rows and is never cached,
since it's the write surface itself and needs to reflect edits immediately.
Reference files: ChoiceDto.cs, ChoiceTypeDto.cs, IChoiceRepository.cs,
IChoiceTypeRepository.cs, ChoiceRepository.cs, ChoiceTypeRepository.cs,
ChoicesController.cs, ChoiceTypesController.cs, ChoicesPage.tsx.