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)]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); }}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.- GET by ID still returns soft-deleted entities (no silent 404 — the consumer can inspect
IsDeletedon the response).
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.
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 |