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

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

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.
  • GET by ID still returns soft-deleted entities (no silent 404 — the consumer can inspect IsDeleted on 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.

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