CRUD API ([CrudApi])
CRUD API generation ([CrudApi])
Section titled “CRUD API generation ([CrudApi])”Add [CrudApi] to your entity to generate complete CRUD API endpoints. A single attribute is enough — CreateDto, UpdateDto, and ResponseDto are auto-implied when missing:
[CrudApi]public class Player{ [DtoIgnore(DtoTarget.Create | DtoTarget.Update | DtoTarget.Query)] public int Id { get; set; } public required string Name { get; set; } public int Level { get; set; } public string? Email { get; set; } [DtoIgnore] public DateTime CreatedAt { get; set; }}This generates CreatePlayerRequest, UpdatePlayerRequest, PlayerResponse, and full CRUD endpoints — all from one attribute. Add explicit DTO attributes when you need custom names, sorting, or fine-grained control:
[CrudApi(Style = ApiStyle.Both)][CreateDto(Name = "NewPlayerDto")] // custom request name[QueryDto(DefaultSort = "Name")] // filtering + sorting (Sortable is true by default)public class Player { ... }This generates two classes (depending on Style):
PlayerEndpoints— Minimal API endpoints viaMapPlayerEndpoints()extension methodPlayerCrudController— MVC[ApiController](partial, so you can extend it)
Both inject ICrudStore<Player, int> from DI and wire up: validation → entity mapping → store → response mapping → pagination.
Generated endpoints
Section titled “Generated endpoints”| Method | Route | Description |
|---|---|---|
GET | /api/players/{id} | Get by ID → PlayerResponse |
GET | /api/players?name=...&sortBy=level&page=2 | List with filter + sort + pagination → PaginatedResponse<PlayerResponse> |
POST | /api/players | Create → validate → ToEntity() → store → 201 |
PATCH | /api/players/{id} | Update → validate → ApplyTo() → store → 200 |
DELETE | /api/players/{id} | Delete → 204 |
POST | /api/players/bulk | Bulk create (requires CrudOperations.BulkCreate) |
POST | /api/players/bulk-delete | Bulk delete by IDs (requires CrudOperations.BulkDelete) |
Generated code (Minimal API)
Section titled “Generated code (Minimal API)”Here’s what the generator actually produces (simplified):
// <auto-generated />public static class PlayerEndpoints{ public static RouteGroupBuilder MapPlayerEndpoints( this IEndpointRouteBuilder app, string? prefix = null, Action<RouteGroupBuilder>? configure = null) { var group = app.MapGroup(prefix ?? "api/players").WithTags("Player"); configure?.Invoke(group);
// GET /api/players/{id} group.MapGet("{id}", async (int id, ICrudStore<Player, int> store, CancellationToken ct) => { var entity = await store.GetByIdAsync(id, ct); if (entity is null) return Results.Problem(statusCode: 404, title: "Not Found"); return Results.Ok(PlayerResponse.FromEntity(entity)); }).WithName("GetPlayer");
// GET /api/players?name=...&sortBy=level&page=2 group.MapGet("", ([AsParameters] PlayerQuery query, ICrudStore<Player, int> store, int page = 1, int pageSize = 20, CancellationToken ct = default) => { var q = query.Apply(store.Query()); var projected = PlayerResponse.ProjectFrom(q); return PaginatedResponse<PlayerResponse>.CreateAsync(projected, page, pageSize, ct); }).WithName("GetPlayerList");
// POST /api/players group.MapPost("", async (CreatePlayerRequest request, ICrudStore<Player, int> store, CancellationToken ct) => { var validation = request.Validate(); if (!validation.IsValid) return Results.ValidationProblem(validation.ToDictionary()); var entity = request.ToEntity(); await store.CreateAsync(entity, ct); return Results.CreatedAtRoute("GetPlayer", new { id = entity.Id }, PlayerResponse.FromEntity(entity)); });
// PATCH /api/players/{id} group.MapPatch("{id}", async (int id, UpdatePlayerRequest request, ICrudStore<Player, int> store, CancellationToken ct) => { var entity = await store.GetByIdAsync(id, ct); if (entity is null) return Results.Problem(statusCode: 404, title: "Not Found"); var validation = request.Validate(); if (!validation.IsValid) return Results.ValidationProblem(validation.ToDictionary()); request.ApplyTo(entity); await store.UpdateAsync(entity, ct); return Results.Ok(PlayerResponse.FromEntity(entity)); });
// DELETE /api/players/{id} group.MapDelete("{id}", async (int id, ICrudStore<Player, int> store, CancellationToken ct) => { var entity = await store.GetByIdAsync(id, ct); if (entity is null) return Results.Problem(statusCode: 404, title: "Not Found"); await store.DeleteAsync(entity, ct); return Results.NoContent(); });
return group; }}The generated MVC Controller follows the same pattern with [HttpGet], [HttpPost], [HttpPatch], [HttpDelete] attributes and is partial so you can extend it.
var builder = WebApplication.CreateBuilder(args);builder.Services.AddControllers() .AddJsonOptions(o => o.JsonSerializerOptions.Converters.Add(new PatchFieldJsonConverterFactory()));
// Register your data store implementationbuilder.Services.AddScoped<ICrudStore<Player, int>, PlayerStore>();
var app = builder.Build();app.MapPlayerEndpoints(); // generated Minimal APIapp.MapControllers(); // picks up generated PlayerCrudControllerapp.Run();ICrudStore<TEntity, TKey>
Section titled “ICrudStore<TEntity, TKey>”The generated endpoints depend on this interface for data access. Implement it for your storage layer:
public interface ICrudStore<TEntity, TKey>{ ValueTask<TEntity?> GetByIdAsync(TKey id, CancellationToken ct = default); IQueryable<TEntity> Query(); ValueTask CreateAsync(TEntity entity, CancellationToken ct = default); ValueTask UpdateAsync(TEntity entity, CancellationToken ct = default); ValueTask DeleteAsync(TEntity entity, CancellationToken ct = default);}Storage integrations
Section titled “Storage integrations”Ready-made implementations are available as separate packages:
| Package | Description |
|---|---|
ZibStack.NET.EntityFramework | EF Core — auto-generates stores + DI registration from DbContext |
ZibStack.NET.Dapper | Dapper — base class with auto-generated SQL |
EF Core — add [GenerateCrudStores] to your DbContext and the store implementations + DI registration are generated automatically:
dotnet add package ZibStack.NET.EntityFrameworkdotnet add package Microsoft.EntityFrameworkCore.Sqlite[GenerateCrudStores]public class AppDbContext : DbContext{ public DbSet<Player> Players => Set<Player>(); public DbSet<Team> Teams => Set<Team>(); // ...}// In Program.cs — one line registers all storesbuilder.Services.AddDbContext<AppDbContext>(o => o.UseSqlite("Data Source=app.db"));builder.Services.AddAppDbContextCrudStores(); // auto-generated extension methodYou can also implement ICrudStore<T,K> manually or inherit EfCrudStore<T,K,TContext> for custom behavior.
Auto-implied DTOs
Section titled “Auto-implied DTOs”[CrudApi] alone is enough for a full CRUD API. Missing DTO attributes are auto-generated with sensible defaults:
[CrudApi] // generates CreatePlayerRequest, UpdatePlayerRequest, PlayerResponse + endpointspublic class Player{ [DtoIgnore(DtoTarget.Create | DtoTarget.Update | DtoTarget.Query)] public int Id { get; set; } public required string Name { get; set; } public int Level { get; set; }}Explicit DTO attributes always take priority when present:
| What’s on the class | What gets generated |
|---|---|
[CrudApi] alone | Auto: CreateXxxRequest, UpdateXxxRequest, XxxResponse |
[CrudApi] + [CreateDto(Name = "NewPlayer")] | Explicit NewPlayer + auto: Update, Response |
[CrudApi] + [CreateOrUpdateDto] | Explicit combined DTO + auto: Response |
[CrudApi] + all DTO attrs | All explicit — auto-imply disabled |
Attribute options
Section titled “Attribute options”[CrudApi( Route = "api/v2/players", // full route override (default: api/{pluralized-name}) RoutePrefix = "v2", // prefix only → api/v2/players (ignored when Route is set) KeyProperty = "PlayerId", // key property name (default: "Id") Operations = CrudOperations.AllWithBulk, // which operations (default: All) Style = ApiStyle.MinimalApi, // MinimalApi, Controller, or Both AuthorizePolicy = "read:players", // default policy for all operations CreatePolicy = "write:players", // override for POST only UpdatePolicy = "write:players", // override for PATCH only DeletePolicy = "admin", // override for DELETE only GetByIdPolicy = "read:players", // override for GET by ID GetListPolicy = "read:players", // override for GET list SoftDelete = true, // DELETE flags IsDeleted instead of removing Concurrency = true, // optimistic concurrency via ETag / If-Match Audit = true // auto-stamp CreatedAt/UpdatedAt/CreatedBy/UpdatedBy)]Per-operation policies override the default AuthorizePolicy for specific operations. This allows read-only access for some users while requiring elevated permissions for writes.
CrudOperations flags:
| Flag | Value | Description |
|---|---|---|
GetById | 1 | GET by ID |
GetList | 2 | GET list |
Create | 4 | POST |
Update | 8 | PATCH |
Delete | 16 | DELETE |
BulkCreate | 32 | POST /bulk |
BulkDelete | 64 | POST /bulk-delete |
Read | 3 | GetById + GetList |
Write | 28 | Create + Update + Delete |
Bulk | 96 | BulkCreate + BulkDelete |
All | 31 | Read + Write (no bulk) |
AllWithBulk | 127 | All + Bulk |
Customizing endpoints
Section titled “Customizing endpoints”Property-level control — attributes on model properties affect what appears in generated requests/responses:
| Attribute | Effect on CRUD |
|---|---|
[DtoIgnore] | Excluded from all request and response DTOs |
[DtoIgnore(DtoTarget.X)] | Excluded from specific DTO targets (e.g. DtoTarget.Response, DtoTarget.Query, DtoTarget.List) |
[DtoOnly(DtoTarget.X)] | Included only in the specified target (e.g. DtoTarget.Create for POST-only, DtoTarget.Update for PATCH-only) |
[Flatten] | Nested object properties flattened into response |
required | Validated as mandatory in POST, optional in PATCH |
[ZRequired], [ZMinLength], [ZMaxLength], [ZRange], [ZEmail], [ZMatch] | Propagated to generated validation |
Custom DTO names:
[CrudApi][CreateDto(Name = "NewPlayerDto")] // override default CreatePlayerRequest[ResponseDto(Name = "PlayerView")] // override default PlayerResponseMinimal API route group configuration — the generated MapXxxEndpoints accepts a configure callback:
app.MapPlayerEndpoints(configure: group =>{ group.RequireRateLimiting("fixed"); group.AddEndpointFilter<MyLoggingFilter>(); group.WithTags("Players", "V1");});Custom route prefix — override the generated route or add a version prefix:
app.MapPlayerEndpoints(prefix: "api/v2/players"); // override route at runtimeExtending generated controllers — generated controllers are partial, so you can add extra endpoints:
public partial class PlayerCrudController{ [HttpPost("bulk")] public async Task<IActionResult> BulkCreate([FromBody] List<CreatePlayerRequest> requests, CancellationToken ct) { // custom bulk logic }}Custom data access — override methods in EfCrudStore for custom behavior:
public class PlayerStore : EfCrudStore<Player, int, AppDbContext>{ public PlayerStore(AppDbContext db) : base(db) { } protected override DbSet<Player> Set => Db.Players;
// Soft delete instead of hard delete public override async ValueTask DeleteAsync(Player entity, CancellationToken ct = default) { entity.IsDeleted = true; await Db.SaveChangesAsync(ct); }
// Auto-set timestamps public override async ValueTask CreateAsync(Player entity, CancellationToken ct = default) { entity.CreatedAt = DateTime.UtcNow; await base.CreateAsync(entity, ct); }}Column-level permissions ([ColumnPermission])
Section titled “Column-level permissions ([ColumnPermission])”[ColumnPermission("Column", "permission")] (from ZibStack.NET.UI) is enforced automatically by the generated endpoints — no manual wiring. A caller must hold a permission claim with that value or be in a role of that name; otherwise the column is masked:
[CrudApi][ColumnPermission("Salary", "finance.read")]public partial class Player { ... }GET /{id},POST,PATCH,POST /bulk—Salaryis set todefaultin the response unless the caller holdsfinance.read.GET /(list) — every page item is masked the same way (also when a separateListItemDTO is in play, as long as the column survives[DtoIgnore(DtoTarget.List)]).GET /?select=Name,Salary— restricted fields are silently dropped from the projection.
Anonymous and unauthenticated callers are treated as having no permissions (safe by default). The masking logic lives in a generated {Entity}ColumnPermissions class — call PlayerColumnPermissions.Apply(response, user) yourself if you build custom endpoints on top of the generated DTOs.
Error responses
Section titled “Error responses”All error responses use the RFC 9110 ProblemDetails format (application/problem+json):
// Validation error (400){ "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1", "title": "One or more validation errors occurred.", "status": 400, "errors": { "name": ["is required."], "password": ["is required."] }}
// Not found (404){ "type": "https://tools.ietf.org/html/rfc9110#section-15.5.5", "title": "Not Found", "status": 404}List vs Detail responses ([DtoIgnore(DtoTarget.List)])
Section titled “List vs Detail responses ([DtoIgnore(DtoTarget.List)])”Mark properties with [DtoIgnore(DtoTarget.List)] to exclude them from the GET list response while keeping them in the GET by ID response. The generator creates a separate {Entity}ListItem DTO for list endpoints:
[CrudApi]public class Player{ [DtoIgnore(DtoTarget.Create | DtoTarget.Update | DtoTarget.Query)] public int Id { get; set; } public required string Name { get; set; } public int Level { get; set; }
[DtoIgnore(DtoTarget.List)] public string? Bio { get; set; } // only in GET /api/players/{id}
[DtoIgnore(DtoTarget.List)] public Address? Address { get; set; } // only in GET /api/players/{id}}Generated: PlayerResponse (all fields, for detail) and PlayerListItem (without Bio/Address, for list).
Bulk operations
Section titled “Bulk operations”Enable bulk create/delete with CrudOperations.Bulk or CrudOperations.AllWithBulk:
[CrudApi(Operations = CrudOperations.AllWithBulk)]public class Player { ... }| Endpoint | Method | Description |
|---|---|---|
/api/players/bulk | POST | Create multiple entities. Body: [{...}, {...}]. Validates all before creating. |
/api/players/bulk-delete | POST | Delete by IDs. Body: [1, 3, 5]. Returns {"deleted": 2}. |
Bulk endpoints inherit the same per-operation auth policies (CreatePolicy for bulk create, DeletePolicy for bulk delete).
Soft delete
Section titled “Soft delete”Set SoftDelete = true on [CrudApi] to turn hard deletes into flag-based soft deletes. No manual store overrides needed — the generator handles everything:
[CrudApi(SoftDelete = true)]public class Player{ [DtoIgnore(DtoTarget.Create | DtoTarget.Update | DtoTarget.Query)] public int Id { get; set; } public required string Name { get; set; } public int Level { get; set; }}What gets generated:
IsDeletedandDeletedAtproperties are added to the entity (via a generated partial class) if not already present.- DELETE endpoints (
DELETE /api/players/{id}andPOST /api/players/bulk-delete) setIsDeleted = trueandDeletedAt = DateTime.UtcNowinstead of removing the row. - GET list (
GET /api/players) filters out soft-deleted entities by default — aWHERE IsDeleted = falseclause is appended to the query automatically. ?includeDeleted=truequery parameter on the GET list endpoint bypasses the filter and returns all entities including deleted ones.?cursor=on the GET list endpoint switches to keyset pagination — see PaginatedResponse → Cursor pagination.- GET by ID returns
404 Not Foundfor soft-deleted entities — same as the list view, a deleted record is invisible through the API. Query the store directly (store.Query()is unfiltered) when you need to inspect archived rows.
Works with all API styles — Minimal API, Controller, and bulk operations. The bulk-delete endpoint also applies the soft-delete logic per entity instead of issuing a hard delete.
// Combine with per-operation policies:[CrudApi(SoftDelete = true, DeletePolicy = "admin")]public class Player { ... }If you already have IsDeleted / DeletedAt properties on your entity, the generator reuses them and does not emit duplicates.
Optimistic concurrency (ETags)
Section titled “Optimistic concurrency (ETags)”Set Concurrency = true on [CrudApi] to protect updates against lost writes with standard HTTP preconditions:
[CrudApi(Concurrency = true)]public partial class Document{ [DtoIgnore(DtoTarget.Create | DtoTarget.Update | DtoTarget.Query)] public int Id { get; set; } public required string Title { get; set; }}What gets generated:
RowVersion(long) property is added to the entity via a generated partial (skipped if you already declare one).ETagresponse header —GET /{id},POSTandPATCHrespond with a weak ETag:ETag: W/"3".- PATCH requires
If-Match— missing header →428 Precondition Required; stale version →412 Precondition Failed. On success the version is incremented and the fresh ETag returned. - DELETE honors
If-Matchwhen provided (412 on mismatch); without the header it deletes unconditionally. If-Match: *explicitly bypasses the version check (RFC 9110 semantics, comma-separated lists supported).
Typical client flow:
GET /api/documents/7 → 200, ETag: W/"2"PATCH /api/documents/7If-Match: W/"2" → 200, ETag: W/"3" (someone else's W/"2" now gets 412)The version compare-and-increment happens in the endpoint, which protects API-level workflows. For hard database-level guarantees under concurrent writers, additionally mark RowVersion as a concurrency token in your EF model configuration (builder.Property(x => x.RowVersion).IsConcurrencyToken()). Bulk endpoints intentionally skip precondition checks.
Audit fields
Section titled “Audit fields”Set Audit = true on [CrudApi] to have the generated endpoints maintain audit metadata automatically:
[CrudApi(Audit = true)]public partial class Document { ... }What gets generated:
- Missing audit properties are added to the entity partial:
CreatedAt/UpdatedAt(DateTime) andCreatedBy/UpdatedBy(string?). Properties you already declare are reused (declare them yourself if you want them in response DTOs — generated partials are invisible to DTO extraction). - POST (and bulk create) stamps
CreatedAt/UpdatedAtwithDateTime.UtcNowandCreatedBy/UpdatedBywith the caller’s identity name (nullfor anonymous callers). - PATCH refreshes
UpdatedAt/UpdatedBy. - Soft DELETE (when combined with
SoftDelete = true) also refreshesUpdatedAt/UpdatedBy, so you can tell who archived the record.
The stamping happens in the endpoint layer, so it works identically with the EF store, the Dapper store, and any custom ICrudStore implementation. (The EF-generated store additionally fills CreatedAt/UpdatedAt by convention when those properties exist on the entity — the two mechanisms agree, endpoint values simply win.)
Conditional emission
Section titled “Conditional emission”CRUD endpoints are only generated when the consuming project references ASP.NET Core (detected at compile time). This means:
- Library projects that only use DTOs → no endpoint code emitted
- Web API projects → full CRUD generation
- No extra dependencies needed in the generator package
Diagnostics
Section titled “Diagnostics”| Code | Severity | Description |
|---|---|---|
| SDTO011 | Error | KeyProperty value does not match any property on the type |