Skip to content

CRUD API ([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 via MapPlayerEndpoints() extension method
  • PlayerCrudController — 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.

MethodRouteDescription
GET/api/players/{id}Get by ID → PlayerResponse
GET/api/players?name=...&sortBy=level&page=2List with filter + sort + pagination → PaginatedResponse<PlayerResponse>
POST/api/playersCreate → validate → ToEntity() → store → 201
PATCH/api/players/{id}Update → validate → ApplyTo() → store → 200
DELETE/api/players/{id}Delete → 204
POST/api/players/bulkBulk create (requires CrudOperations.BulkCreate)
POST/api/players/bulk-deleteBulk delete by IDs (requires CrudOperations.BulkDelete)

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 implementation
builder.Services.AddScoped<ICrudStore<Player, int>, PlayerStore>();
var app = builder.Build();
app.MapPlayerEndpoints(); // generated Minimal API
app.MapControllers(); // picks up generated PlayerCrudController
app.Run();

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);
}

Ready-made implementations are available as separate packages:

PackageDescription
ZibStack.NET.EntityFrameworkEF Core — auto-generates stores + DI registration from DbContext
ZibStack.NET.DapperDapper — 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.EntityFramework
dotnet 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 stores
builder.Services.AddDbContext<AppDbContext>(o => o.UseSqlite("Data Source=app.db"));
builder.Services.AddAppDbContextCrudStores(); // auto-generated extension method

You can also implement ICrudStore<T,K> manually or inherit EfCrudStore<T,K,TContext> for custom behavior.

[CrudApi] alone is enough for a full CRUD API. Missing DTO attributes are auto-generated with sensible defaults:

[CrudApi] // generates CreatePlayerRequest, UpdatePlayerRequest, PlayerResponse + endpoints
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; }
}

Explicit DTO attributes always take priority when present:

What’s on the classWhat gets generated
[CrudApi] aloneAuto: CreateXxxRequest, UpdateXxxRequest, XxxResponse
[CrudApi] + [CreateDto(Name = "NewPlayer")]Explicit NewPlayer + auto: Update, Response
[CrudApi] + [CreateOrUpdateDto]Explicit combined DTO + auto: Response
[CrudApi] + all DTO attrsAll explicit — auto-imply disabled
[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:

FlagValueDescription
GetById1GET by ID
GetList2GET list
Create4POST
Update8PATCH
Delete16DELETE
BulkCreate32POST /bulk
BulkDelete64POST /bulk-delete
Read3GetById + GetList
Write28Create + Update + Delete
Bulk96BulkCreate + BulkDelete
All31Read + Write (no bulk)
AllWithBulk127All + Bulk

Property-level control — attributes on model properties affect what appears in generated requests/responses:

AttributeEffect 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
requiredValidated 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 PlayerResponse

Minimal 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 runtime

Extending 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 /bulkSalary is set to default in the response unless the caller holds finance.read.
  • GET / (list) — every page item is masked the same way (also when a separate ListItem DTO 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.

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).

Enable bulk create/delete with CrudOperations.Bulk or CrudOperations.AllWithBulk:

[CrudApi(Operations = CrudOperations.AllWithBulk)]
public class Player { ... }
EndpointMethodDescription
/api/players/bulkPOSTCreate multiple entities. Body: [{...}, {...}]. Validates all before creating.
/api/players/bulk-deletePOSTDelete 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).

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:

  • IsDeleted and DeletedAt properties are added to the entity (via a generated partial class) if not already present.
  • DELETE endpoints (DELETE /api/players/{id} and POST /api/players/bulk-delete) set IsDeleted = true and DeletedAt = DateTime.UtcNow instead of removing the row.
  • GET list (GET /api/players) filters out soft-deleted entities by default — a WHERE IsDeleted = false clause is appended to the query automatically.
  • ?includeDeleted=true query 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 Found for 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.

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).
  • ETag response headerGET /{id}, POST and PATCH respond 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-Match when 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/7
If-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.

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) and CreatedBy/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/UpdatedAt with DateTime.UtcNow and CreatedBy/UpdatedBy with the caller’s identity name (null for anonymous callers).
  • PATCH refreshes UpdatedAt/UpdatedBy.
  • Soft DELETE (when combined with SoftDelete = true) also refreshes UpdatedAt/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.)

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
CodeSeverityDescription
SDTO011ErrorKeyProperty value does not match any property on the type