Skip to content

ZibStack.NET.Dto

NuGet Source

A C# source generator that produces strongly-typed Create, Update, Response, and Query DTOs from your domain models, and optionally generates full CRUD API endpoints (Minimal API + MVC Controllers). No reflection, no runtime overhead. Supports generics, inheritance, nested types, flattening, validation propagation, and more.

Two configuration styles, mixable per-class:

  • Attribute markers[CreateDto]/[UpdateDto]/[CrudApi]/etc. for locality.
  • Fluent IDtoConfigurator — central project-wide config with a typed builder. Useful for centralizing settings, configuring third-party types you can’t annotate, or overriding settings without editing the model. See Fluent configuration below.

See the working sample: SampleApi on GitHub

dotnet add package ZibStack.NET.Dto
dotnet add package ZibStack.NET.Core

ZibStack.NET.Core provides TypeScript-style utility types ([PartialFrom], [IntersectFrom], [PickFrom], [OmitFrom]).

Mark your model with [CreateDto] and/or [UpdateDto]:

using ZibStack.NET.Dto;
[CreateDto]
[UpdateDto]
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; }
}

Register the JSON converter:

// Minimal API:
builder.Services.ConfigureHttpJsonOptions(o =>
o.SerializerOptions.Converters.Add(new PatchFieldJsonConverterFactory()));
// Or MVC Controllers:
builder.Services.AddControllers()
.AddJsonOptions(o => o.JsonSerializerOptions.Converters.Add(
new PatchFieldJsonConverterFactory()));

Generates two records — CreatePlayerRequest and UpdatePlayerRequest:

[HttpPost]
public IActionResult Create([FromBody] CreatePlayerRequest request)
{
var validation = request.Validate();
if (!validation.IsValid)
return BadRequest(new { validation.Errors });
Player player = request.ToEntity();
return Ok(player);
}
[HttpPatch("{id}")]
public IActionResult Update(int id, [FromBody] UpdatePlayerRequest request)
{
var validation = request.Validate();
if (!validation.IsValid)
return BadRequest(new { validation.Errors });
request.ApplyTo(existingPlayer);
return Ok(existingPlayer);
}

Generates a single TeamRequest with ValidateForCreate() and ValidateForUpdate():

[CreateOrUpdateDto]
public class Team
{
[DtoIgnore(DtoTarget.Create | DtoTarget.Update | DtoTarget.Query)]
public int Id { get; set; }
public required string Name { get; set; }
public string? Description { get; set; }
public int MaxMembers { get; set; }
}
[HttpPost]
public IActionResult Create([FromBody] TeamRequest request)
{
var validation = request.ValidateForCreate();
if (!validation.IsValid)
return BadRequest(new { validation.Errors });
Team team = request.ToEntity();
return Ok(team);
}
[HttpPatch("{id}")]
public IActionResult Update(int id, [FromBody] TeamRequest request)
{
var validation = request.ValidateForUpdate();
if (!validation.IsValid)
return BadRequest(new { validation.Errors });
request.ApplyTo(existingTeam);
return Ok(existingTeam);
}
public record CreatePlayerRequest
{
public PatchField<string> Name { get; init; } // required in Validate()
public PatchField<int> Level { get; init; } // optional
public PatchField<string?> Email { get; init; } // optional, nullable
public DtoValidationResult Validate() { ... }
public Player ToEntity() { ... }
}
public record UpdatePlayerRequest
{
public PatchField<string> Name { get; init; } // optional, but non-null if sent
public PatchField<int> Level { get; init; }
public PatchField<string?> Email { get; init; }
public DtoValidationResult Validate() { ... }
public void ApplyTo(Player target) { ... }
}
public record TeamRequest
{
public PatchField<string> Name { get; init; }
public PatchField<string?> Description { get; init; }
public PatchField<int> MaxMembers { get; init; }
public DtoValidationResult ValidateForCreate() { ... }
public DtoValidationResult ValidateForUpdate() { ... }
public Team ToEntity() { ... }
public void ApplyTo(Team target) { ... }
}

PatchField<T> distinguishes three states: not sent (HasValue = false), sent with value, and sent as null. It has implicit operators so you can assign and read values directly:

// Assignment -- no need for new PatchField<string>("Bob")
var request = new CreatePlayerRequest { Name = "Bob", Level = 5 };
// Reading -- no need for .Value
string name = request.Name;

For custom logic (audit logging, conditional business rules), PatchField<T> works naturally with C# pattern matching:

switch (request.Email)
{
case { HasValue: false }: break; // not sent — leave unchanged
case { HasValue: true, Value: null }: player.Email = null; break; // explicit clear
case { HasValue: true, Value: var v }: player.Email = v; break; // new value
}

See the PatchField Tri-State guide for the full walkthrough — why nullable DTOs can’t distinguish “not sent” from “set to null”, and how PatchField<T> solves it.

Generated types implement generic interfaces for type-safe generic handlers:

InterfaceImplemented byMethod
ICanCreate<T>Create requests, CombinedT ToEntity()
ICanApply<T>Update requests, Combinedvoid ApplyTo(T target)
ICanValidateCreate/Update requests (not Combined)DtoValidationResult Validate()

Combined requests implement ICanCreate<T> and ICanApply<T> but not ICanValidate (they have ValidateForCreate()/ValidateForUpdate() instead).

// Generic handler using interfaces
public IActionResult HandleCreate<T>(ICanCreate<T> request) where T : class
{
if (request is ICanValidate validatable)
{
var validation = validatable.Validate();
if (!validation.IsValid) return BadRequest(validation.Errors);
}
var entity = request.ToEntity();
return Ok(entity);
}

This page covers the core mode + generated shape. Detailed reference and feature pages:

  • .NET Standard 2.0+ (generator runs in any C# 12+ project)
  • C# 12+ (required keyword, primary constructors)

MIT.