umbrella-dotnet-scaffold-api-server-models
Scaffold sealed API model records (request/response types) for a new feature, following the Umbrella shared-model, input-binding, immutability, and concurrency contracts.
SKILL.md.ai-shared/skills/umbrella-dotnet-scaffold-api-server-models/SKILL.md
GitHubScaffold API Server Models
Purpose
Create the API model records for a new feature — the request and response types used by API endpoints. These models are typically kept in a shared project (accessible by both server and client) and use C# records with data-annotation validation attributes.
This skill covers the model types only. For mapping entities to models using Mapperly, use the umbrella-dotnet-scaffold-mapperly-factories skill.
Discovery (read these before writing anything)
- Read 2-3 existing feature model folders in the shared models directory (e.g.,
Web.Shared\Models\Api\) to understand the naming conventions, which model types are used, and whether base class hierarchies are common. - Note whether the project uses
requiredproperties,initvsset,abstract recordbase types, and which validation attributes are standard (e.g.,[Required],[ShortStringLength]). Prefer the nearest established feature hierarchy over inventing an interface-based alternative. - Check whether the project has a constants file pattern per feature (e.g.,
<Feature>Constants.cs) for error message strings used in validation attributes. - Determine the friendly singular entity name used by grids and dialogs. The slim list record must declare it with a record-level
[Display(Name = "<Friendly Singular>")]attribute.
Model types reference
Choose only the types the feature needs. Skip types that do not apply.
| Type name | Interfaces | Purpose |
|---|---|---|
<Name>Model |
IKeyedItem<int>, IReadOnlyConcurrencyStamp |
Full read model — returned by GET single |
Slim<Name>Model |
IKeyedItem<int> |
Slim read model — used as the item in paginated list responses |
Create<Name>Model |
(none) | Create request body |
Update<Name>Model |
IUpdateModel<int> |
Update request body — includes Id and ConcurrencyStamp |
Create<Name>ResultModel |
ICreateResultModel<int> |
Create operation result |
Update<Name>ResultModel |
IUpdateResultModel |
Update operation result — returns the new ConcurrencyStamp |
<Name>PaginatedResultModel (optional) |
extends PaginatedResultModel<Slim<Name>Model> |
Feature-specific paginated response, only when the contract needs a named wrapper or extra properties |
Choosing which model types to scaffold
Match the models you create to the endpoints you intend to enable. Models for disabled endpoints (those using object or NoOp* in the controller) do not need to exist.
| Endpoints enabled | Model types to scaffold |
|---|---|
| Full CRUD | All six non-pagination model types in the table; add a named paginated wrapper only when the surrounding contract uses one |
| Read list only | Slim<Name>Model; optionally a named paginated wrapper |
| Read list + detail | Slim<Name>Model, <Name>Model; optionally a named paginated wrapper |
| Create only (e.g. analytics, session recording) | Create<Name>Model, Create<Name>ResultModel |
| Read list + create (no update, no detail) | Slim<Name>Model, Create<Name>Model, Create<Name>ResultModel; optionally a named paginated wrapper |
| Read list + detail + create (no update) | Slim<Name>Model, <Name>Model, Create<Name>Model, Create<Name>ResultModel; optionally a named paginated wrapper |
Every list endpoint needs Slim<Name>Model and a paginated contract. Prefer PaginatedResultModel<Slim<Name>Model> directly when that matches nearby controllers and services. Create a feature-specific derived record only when nearby features consistently name the wrapper or the response needs additional properties. If there is no list endpoint, neither form is needed.
Interface property requirements — accessor rules:
IKeyedItem<int>→int Id { get; }—initis fine hereIReadOnlyConcurrencyStamp→string ConcurrencyStamp { get; }→ declarerequired string ConcurrencyStamp { get; init; }. This is the contract for read and result models.IConcurrencyStamp→string ConcurrencyStamp { get; set; }— mutablesetrequired;initdoes NOT satisfy this interface. Use it only on EF/Dataverse entities and onIUpdateModel<TKey>request models that need the stamp assigned after construction.ICreateResultModel<int>→int Id { get; }→ declarerequired int Id { get; init; }IUpdateModel<int>inheritsIKeyedItem<int>+IConcurrencyStamp→ needsint Id { get; }andstring ConcurrencyStamp { get; set; }IUpdateResultModelinheritsIReadOnlyConcurrencyStamp→ declarerequired string ConcurrencyStamp { get; init; }
Result models are always populated by a mapper, never by the controller base, so their Id and ConcurrencyStamp are init-only. Reaching the stamp through the mutable IConcurrencyStamp on a read or result model blocks init and will trigger UA013.
Record declaration and input-model style
All analyzer-matched model types use record. Concrete record classes are sealed by default; abstract records and record structs are exempt. Use [UmbrellaAllowUnsealedModel("reason")] only when a concrete model is intentionally inherited. Project type does not determine whether a model is partial; use partial only when a source generator actually needs to add an implementation.
The [UmbrellaInputModel], [UmbrellaAllowUnsealedModel], [UmbrellaAllowNonRequiredProperty] and [UmbrellaAllowMutableProperty] attributes used below are in the Umbrella.Analyzers namespace but ship in the Umbrella.Analyzers.Abstractions package. Before scaffolding, confirm the repository has the package as a global reference without PrivateAssets per umbrella-dotnet-install-analyzers. Do not work around an unresolved attribute by hand-declaring a local copy.
Mark only a concrete UI-bound or request-input model directly with [UmbrellaInputModel]; the marker is not inherited and is invalid on abstract types. It permits set and non-required input properties, but does not permit mutable collection contracts or missing getters. Do not add it by default to read or result models. A concrete input type that declares mutable trimmable string properties directly implements IUmbrellaTrimmable. Make that type partial when the installed trimming generator supplies the implementation; otherwise provide the interface implementation manually without forcing partial.
Read and result models normally remain immutable records with required ... { get; init; }. Do not mark them as input models merely because the same feature also has a Blazor form.
Conditional validation
Before implementing IValidatableObject or a custom ValidationAttribute for a request/form rule, inspect the installed Umbrella.DataAnnotations package and nearby input models for an existing contingent validation attribute. Prefer the narrowest attribute that expresses the model-local condition, including RequiredIf, RequiredIfNot, RequiredIfTrue, RequiredIfFalse, RequiredIfEmpty, RequiredIfNotEmpty, RequiredIfRegExMatch, and RequiredIfNotRegExMatch where appropriate.
Reference dependent properties with nameof(...), and use the feature's validation-message constants for ErrorMessage when that pattern exists:
[RequiredIfNotEmpty(
nameof(VideoThumbnailProviderFileName),
ErrorMessage = IndustryConstants.VideoThumbnailAltTextRequiredErrorMessage)]
public string? VideoThumbnailAltText { get; set; }
Contingent attributes evaluate only the state present on the model being validated. If a rule depends on persisted entity state, values restored later by a controller/service lifecycle hook, external data, collections, or a condition the installed attributes cannot express, enforce that portion at the appropriate server boundary. Use IValidatableObject only when no existing attribute cleanly expresses a model-local rule.
Base class hierarchies (use when models share fields)
When the full read, create, and update models share properties, put those properties and their validation attributes on a mutable abstract <Name>ModelBase. Add an abstract CreateUpdate<Name>ModelBase : <Name>ModelBase as the common Blazor form type. Do not replace this hierarchy with an I<Name>InputModel; the base records carry the shared implementation, validation, trimming contract, and form-binding surface.
Use [UmbrellaAllowNonRequiredProperty] and [UmbrellaAllowMutableProperty] with specific binding reasons on shared mutable base properties. Mark the abstract base partial and implement IUmbrellaTrimmable when its strings are handled by the trimming generator. Keep [UmbrellaInputModel] off abstract types; apply it directly only to a concrete input record that declares additional mutable properties requiring the marker.
When to introduce bases:
- Use
<Name>ModelBasewhenever the full read, create, and update records share editable properties. - Use
CreateUpdate<Name>ModelBasewhen one UI form binds both create and update records, even if it is currently an empty specialization. - Use a result-model base only when create/update result records genuinely share returned properties.
- Prefer independent sealed records only when the models do not share implementation.
Typical hierarchy (used when warranted):
<Name>ModelBase (mutable abstract partial record, shared properties + validation)
├── sealed <Name>Model : <Name>ModelBase, IKeyedItem<int>, IReadOnlyConcurrencyStamp
└── CreateUpdate<Name>ModelBase (abstract record, common form type)
├── sealed Create<Name>Model : CreateUpdate<Name>ModelBase
└── sealed Update<Name>Model : CreateUpdate<Name>ModelBase, IUpdateModel<int>
CreateUpdate<Name>ResultModelBase (abstract record)
├── sealed Create<Name>ResultModel : CreateUpdate<Name>ResultModelBase, ICreateResultModel<int>
└── sealed Update<Name>ResultModel : CreateUpdate<Name>ResultModelBase, IUpdateResultModel
Notes:
<Name>ModelBaseholds properties common to the full read, create, and update models. Its mutable accessors support Blazor binding, while analyzer suppressions document that concrete models populate them through binding or mapping.CreateUpdate<Name>ModelBasegives a combined manage page one concrete common contract without duplicating shared fields or introducing an input interface.- Concrete create/update records own only fields unique to their operation, such as
Id,ConcurrencyStamp, orReplaceExistingImage. Slim<Name>Modelusually does NOT inherit from<Name>ModelBase— it exists independently with only the fields needed in list viewsCreateUpdate<Name>ResultModelBaseholds properties returned by both create and update results (e.g.,ConcurrencyStamp, any computed properties likeImageUrl)- A two-tier result base is only worth adding if there are genuinely shared result properties; if both results only return
ConcurrencyStamp, a base is unlikely to add value
Step 1 -- Create the model records
Directory: Web.Shared\Models\Api\<Feature>\ (confirm with discovery)
Abstract base (shared display + editable properties with validation):
using Umbrella.Analyzers;
using Umbrella.Utilities.Text;
namespace <AppName>.Web.Shared.Models.Api.<Feature>;
public abstract partial record <Name>ModelBase : IUmbrellaTrimmable
{
[Required(ErrorMessage = "<Name>Constants.NameRequiredErrorMessage")]
[MaxLength(200)]
[UmbrellaAllowNonRequiredProperty("Shared base property is populated through binding by a concrete input model.")]
[UmbrellaAllowMutableProperty("Shared base property supports binding by a concrete input model.")]
public string Name { get; set; } = null!;
// other shared read properties
}
Shared create/update base (when one form handles both operations):
namespace <AppName>.Web.Shared.Models.Api.<Feature>;
public abstract record CreateUpdate<Name>ModelBase : <Name>ModelBase
{
}
Full read model:
namespace <AppName>.Web.Shared.Models.Api.<Feature>;
public sealed record <Name>Model : <Name>ModelBase, IKeyedItem<int>, IReadOnlyConcurrencyStamp
{
public required int Id { get; init; }
public required string ConcurrencyStamp { get; init; }
// Dynamic Image pairs populated by asynchronous enrichment, when used:
// [UmbrellaAllowNonRequiredProperty("Populated after the generated mapping.")]
// [UmbrellaAllowMutableProperty("Populated after the generated mapping.")]
// public string? ImageUrl { get; set; }
// [UmbrellaAllowNonRequiredProperty("Populated after the generated mapping.")]
// [UmbrellaAllowMutableProperty("Populated after the generated mapping.")]
// public string? ImageVersionToken { get; set; }
}
Slim read model (for paginated list items):
namespace <AppName>.Web.Shared.Models.Api.<Feature>;
[Display(Name = "<Friendly Singular>")]
public sealed record Slim<Name>Model : IKeyedItem<int>
{
public required int Id { get; init; }
// only the fields shown in list views
}
Create model:
namespace <AppName>.Web.Shared.Models.Api.<Feature>;
using Umbrella.Utilities.Text;
public sealed partial record Create<Name>Model : CreateUpdate<Name>ModelBase, IUmbrellaTrimmable
{
}
The shared base owns the common mutable properties and validation. The concrete create record remains empty unless create-only fields exist. The partial declaration assumes the trimming source generator supplies IUmbrellaTrimmable; follow the nearest established feature when the generator handles inherited properties.
Update model:
namespace <AppName>.Web.Shared.Models.Api.<Feature>;
using Umbrella.Analyzers;
using Umbrella.Utilities.Text;
[UmbrellaInputModel]
public sealed partial record Update<Name>Model : CreateUpdate<Name>ModelBase, IUpdateModel<int>, IKeyedItem<int>, IUmbrellaTrimmable
{
public required int Id { get; init; }
[Required]
public required string ConcurrencyStamp { get; set; }
// add properties specific to update only (e.g., ReplaceExistingImage)
}
Note: on this request model ConcurrencyStamp must use set (not init), because IUpdateModel<TKey> inherits the mutable IConcurrencyStamp so a Blazor form can two-way bind it and re-stamp it after a save. Mark it required and add [Required] so client-side validation enforces it. Result models are the opposite — they use init, see below.
Create result model:
namespace <AppName>.Web.Shared.Models.Api.<Feature>;
public sealed record Create<Name>ResultModel : ICreateResultModel<int>, IReadOnlyConcurrencyStamp
{
public required int Id { get; init; }
public required string ConcurrencyStamp { get; init; }
// Dynamic Image pairs populated after saving, when used:
// [UmbrellaAllowNonRequiredProperty("Populated after the file is saved.")]
// [UmbrellaAllowMutableProperty("Populated after the file is saved.")]
// public string? ImageUrl { get; set; }
// [UmbrellaAllowNonRequiredProperty("Populated after the file is saved.")]
// [UmbrellaAllowMutableProperty("Populated after the file is saved.")]
// public string? ImageVersionToken { get; set; }
}
Update result model:
namespace <AppName>.Web.Shared.Models.Api.<Feature>;
public sealed record Update<Name>ResultModel : IUpdateResultModel
{
public required string ConcurrencyStamp { get; init; }
// any other values the server recomputes on update
}
Optional named paginated result model:
namespace <AppName>.Web.Shared.Models.Api.<Feature>;
public sealed record <Name>PaginatedResultModel : PaginatedResultModel<Slim<Name>Model>;
PaginatedResultModel<T> is a record in current Umbrella versions, so a derived named model must also be a record. Do not add this wrapper when the controller, data service, and client can use PaginatedResultModel<Slim<Name>Model> directly.
required keyword guidance
Use required on public settable properties unless an analyzer-recognized contract or a justified suppression exempts them. Shared mutable properties on an abstract model base use [UmbrellaAllowNonRequiredProperty] and [UmbrellaAllowMutableProperty] because concrete models populate them through binding or mapping. [UmbrellaInputModel] permits additional properties declared directly by a concrete form/request model. For any other exceptional non-input property, use [UmbrellaAllowNonRequiredProperty("reason")]; do not omit required merely because a default initializer exists.
A property being nullable or frequently absent is not a reason to drop required. public required string? Name { get; init; } is valid and forces the mapper to make an explicit decision. Reserve [UmbrellaAllowNonRequiredProperty] for properties genuinely assigned after construction, and make the reason say what assigns them.
Migrating an existing result model
Older result models were written as public string ConcurrencyStamp { get; set; } = null!; with [UmbrellaAllowNonRequiredProperty("Populated after parameterless construction by the generic repository controller.")]. That justification no longer holds: the controller base no longer constructs result models, so there is no parameterless-construction path and no new() constraint. To migrate:
Update<Name>ResultModel→public required string ConcurrencyStamp { get; init; }. Delete the suppression attribute and the= null!initialiser.Create<Name>ResultModel→public required TKey Id { get; init; }, plus the same change for the stamp.- If a read or create-result model lists
IConcurrencyStampin its base list, change it toIReadOnlyConcurrencyStamp. Leaving the mutable interface in place keeps the setter slot and blocksinit. - Leave
Update<Name>Model : IUpdateModel<TKey>alone — its stamp keeps{ get; set; }for Blazor two-way binding and for theupdateModel.ConcurrencyStamp = result.Result.ConcurrencyStamp;refresh after a save. - Any result-model property assigned by an
AfterCreateEntityAsync/AfterUpdateEntityAsyncoverride (e.g.result.ImageUrl) keeps{ get; set; }with[UmbrellaAllowMutableProperty("reason")]. Convert onlyIdandConcurrencyStamp. - If a type genuinely cannot move,
[UmbrellaAllowMutableProperty("reason")]suppresses the UA013 setter half.
Also remove any , new() constraint on TCreateResultModel / TUpdateResultModel in an app-specific controller base that wraps UmbrellaGenericRepositoryApiController — required members cannot satisfy new() (CS9040).
Verification
- Each model implements the correct interface(s) from the model types table.
- Every concrete model record class is
sealed, or carries a specific[UmbrellaAllowUnsealedModel("reason")]; abstract records and record structs are exempt. ICreateResultModel<int>implementations haverequired int Id { get; init; }— notset.- Read and result models implement
IReadOnlyConcurrencyStamp(directly or viaIUpdateResultModel) and declarerequired string ConcurrencyStamp { get; init; }— notset. Only entities and concrete update-input models implement mutableIConcurrencyStamp. - A list endpoint uses
PaginatedResultModel<Slim<Name>Model>directly or a justified sealed derived record; no unnecessary wrapper is introduced. - Properties shared by the full read, create, and update models live once on
<Name>ModelBase, together with their validation attributes and justified mutable/non-required suppressions. Slim<Name>Modeldoes not inherit from the shared base and declares[Display(Name = "<Friendly Singular>")]on the record.- No abstract type carries
[UmbrellaInputModel]; apply the marker directly only to concrete inputs that declare additional mutable properties requiring it. - A combined create/update UI uses
CreateUpdate<Name>ModelBase; noI<Name>InputModelis introduced for shared form typing. - Every concrete type that declares mutable trimmable strings directly implements
IUmbrellaTrimmable; only source-generated implementations force that type to bepartial. - Collection properties expose read-only contracts unless an individual property has a justified
[UmbrellaAllowMutableProperty("reason")]. - Conditional model-local rules use the narrowest available
Umbrella.DataAnnotationscontingent attribute; anyIValidatableObjector custom validator has a rule that those attributes cannot express. - Read
.ai-shared\bundles\umbrella\analyzer-compatibility.mdand build with the installed analyzers enabled.