| name | umbrella-dotnet-scaffold-api-server-models |
| description | Scaffold API model records (request/response types) for a new feature, following the Umbrella shared models pattern with optional base class hierarchies. |
Scaffold 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
required properties, init vs set, abstract record base types, and which validation attributes are standard (e.g., [Required], [ShortStringLength]).
- Check whether the project has a constants file pattern per feature (e.g.,
<Feature>Constants.cs) for error message strings used in validation attributes.
Model types reference
Choose only the types the feature needs. Skip types that do not apply.
| Type name | Interfaces | Purpose |
|---|
<Name>Model | IKeyedItem<int>, IConcurrencyStamp | 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 | extends PaginatedResultModel<Slim<Name>Model> | Paginated list response |
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 7 types |
| Read list only | Slim<Name>Model, <Name>PaginatedResultModel |
| Read list + detail | Slim<Name>Model, <Name>PaginatedResultModel, <Name>Model |
| Create only (e.g. analytics, session recording) | Create<Name>Model, Create<Name>ResultModel |
| Read list + create (no update, no detail) | Slim<Name>Model, <Name>PaginatedResultModel, Create<Name>Model, Create<Name>ResultModel |
| Read list + detail + create (no update) | Slim<Name>Model, <Name>PaginatedResultModel, <Name>Model, Create<Name>Model, Create<Name>ResultModel |
<Name>PaginatedResultModel is always paired with Slim<Name>Model. If there is no list endpoint, neither is needed.
Interface property requirements — accessor rules:
IKeyedItem<int> → int Id { get; } — init is fine here
IConcurrencyStamp → string ConcurrencyStamp { get; set; } — mutable set required; init does NOT satisfy this interface
ICreateResultModel<int> → int Id { get; set; } — mutable set required
IUpdateModel<int> inherits IKeyedItem<int> + IConcurrencyStamp → needs int Id { get; } and string ConcurrencyStamp { get; set; }
IUpdateResultModel inherits IConcurrencyStamp → needs string ConcurrencyStamp { get; set; }
Record declaration style (Blazor vs. standalone API)
The declaration style depends on the project type. Determine this during discovery.
Blazor app project:
All model types use public partial record. The partial keyword is required so the IUmbrellaTrimmable source generator can emit the trim implementation in a separate file. Input models (Create*Model, Update*Model) that contain or inherit string properties also implement IUmbrellaTrimmable. Add using Umbrella.Utilities.Text; to those files.
| Model type | Declaration |
|---|
<Name>ModelBase (abstract base) | public abstract partial record |
<Name>Model (GET detail) | public partial record |
Slim<Name>Model (GET list item) | public partial record |
Create<Name>Model | public partial record : IUmbrellaTrimmable (when has/inherits strings) |
Update<Name>Model | public partial record : IUmbrellaTrimmable (when has/inherits strings) |
Create<Name>ResultModel | public partial record |
Update<Name>ResultModel | public partial record |
Standalone API project (no Blazor):
All models use plain record — no partial, no IUmbrellaTrimmable. String trimming is handled at the transport/middleware level.
The code examples in Step 1 use the Blazor convention. For standalone API projects, remove partial and omit IUmbrellaTrimmable.
Base class hierarchies (use when they add value)
When create and update models share the same editable properties, or when the read model shares display properties with the request models, a base class hierarchy reduces duplication and centralises validation attributes.
When to introduce base classes:
- The feature has both create and update models that share most properties
- Validation attributes (e.g.,
[Required], [MaxLength], [Display]) would otherwise be duplicated across multiple models
- The full read model shares display properties with the request models
Typical hierarchy (used when warranted):
<Name>ModelBase (abstract record)
├── <Name>Model : <Name>ModelBase, IKeyedItem<int>, IConcurrencyStamp
└── CreateUpdate<Name>ModelBase : <Name>ModelBase (abstract record)
├── Create<Name>Model : CreateUpdate<Name>ModelBase
└── Update<Name>Model : CreateUpdate<Name>ModelBase, IUpdateModel<int>
CreateUpdate<Name>ResultModelBase (abstract record)
├── Create<Name>ResultModel : CreateUpdate<Name>ResultModelBase, ICreateResultModel<int>
└── Update<Name>ResultModel : CreateUpdate<Name>ResultModelBase, IUpdateResultModel
Notes:
<Name>ModelBase holds the shared display/editable properties and their validation attributes
CreateUpdate<Name>ModelBase inherits <Name>ModelBase and is the base for both request models — only needed if there are truly shared create/update properties beyond what's already on <Name>ModelBase; omit if it would be empty
Slim<Name>Model usually does NOT inherit from <Name>ModelBase — it exists independently with only the fields needed in list views
CreateUpdate<Name>ResultModelBase holds properties returned by both create and update results (e.g., ConcurrencyStamp, any computed properties like ImageUrl)
- 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):
namespace <AppName>.Web.Shared.Models.Api.<Feature>;
public abstract partial record <Name>ModelBase
{
[Required(ErrorMessage = "<Name>Constants.NameRequiredErrorMessage")]
[MaxLength(200)]
public string Name { get; set; } = null!;
}
Place validation attributes here so they are not repeated on each request model.
Full read model:
namespace <AppName>.Web.Shared.Models.Api.<Feature>;
public partial record <Name>Model : <Name>ModelBase, IKeyedItem<int>, IConcurrencyStamp
{
public required int Id { get; init; }
public required string ConcurrencyStamp { get; set; }
}
Slim read model (for paginated list items):
namespace <AppName>.Web.Shared.Models.Api.<Feature>;
public partial record Slim<Name>Model : IKeyedItem<int>
{
public required int Id { get; init; }
}
Create model:
using Umbrella.Utilities.Text;
namespace <AppName>.Web.Shared.Models.Api.<Feature>;
public partial record Create<Name>Model : <Name>ModelBase, IUmbrellaTrimmable
{
}
Update model:
using Umbrella.Utilities.Text;
namespace <AppName>.Web.Shared.Models.Api.<Feature>;
public partial record Update<Name>Model : <Name>ModelBase, IUpdateModel<int>, IUmbrellaTrimmable
{
public required int Id { get; init; }
[Required]
public required string ConcurrencyStamp { get; set; }
}
Note: ConcurrencyStamp must use set (not init) to satisfy IConcurrencyStamp. Mark it required and add [Required] so client-side validation enforces it.
Create result model:
namespace <AppName>.Web.Shared.Models.Api.<Feature>;
public partial record Create<Name>ResultModel : ICreateResultModel<int>
{
public int Id { get; set; }
public string ConcurrencyStamp { get; set; } = null!;
}
Note: Id must use set (not init) to satisfy ICreateResultModel<int>.
Update result model:
namespace <AppName>.Web.Shared.Models.Api.<Feature>;
public partial record Update<Name>ResultModel : IUpdateResultModel
{
public string ConcurrencyStamp { get; set; } = null!;
}
Paginated result model:
namespace <AppName>.Web.Shared.Models.Api.<Feature>;
public class <Name>PaginatedResultModel : PaginatedResultModel<Slim<Name>Model>;
Note: PaginatedResultModel<T> is a class, so the paginated result model must also be a class (not a record).
required keyword guidance
Use required on properties that the caller must always supply — typically Id, ConcurrencyStamp, and any non-nullable reference types that have no meaningful default. Skip required on properties with sensible defaults (= "", = null!) or on mutable properties that may be filled in after construction.
Verification
- Each model implements the correct interface(s) from the model types table.
ICreateResultModel<int> implementations have int Id { get; set; } — not init.
IConcurrencyStamp implementations have string ConcurrencyStamp { get; set; } — not init.
- The paginated result model is a
class extending PaginatedResultModel<Slim<Name>Model>.
- Validation attributes are placed on the base model (not duplicated on each request model).
Slim<Name>Model does not inherit from any base — it is independent.
- If base classes were introduced: no base class is empty (an empty base adds no value and should be removed).
- Blazor project: All record types are
public partial record; input models with strings implement IUmbrellaTrimmable with using Umbrella.Utilities.Text;.
- Standalone API: All record types are plain
record — no partial, no IUmbrellaTrimmable.