Request DTOs (Data Transfer Objects) define what data an API endpoint accepts. They are the input contract between the client and the server. A well-designed request DTO contains exactly what the client should send — no more, no less. It uses DataAnnotations for validation rules, and has a shape that makes the endpoint’s requirements self-documenting in the Swagger UI. The key principle: never bind domain entities directly to request parameters — always create dedicated DTOs to control exactly what clients can set.
Request DTO Design
// ── Immutable record-based request DTO ────────────────────────────────────
public record CreatePostRequest
{
[Required]
[StringLength(200, MinimumLength = 5,
ErrorMessage = "Title must be 5-200 characters.")]
public string Title { get; init; } = string.Empty;
[Required]
[MinLength(50, ErrorMessage = "Body must be at least 50 characters.")]
public string Body { get; init; } = string.Empty;
[Required]
[StringLength(100)]
[RegularExpression(@"^[a-z0-9-]+$",
ErrorMessage = "Slug must be lowercase letters, numbers, and hyphens only.")]
public string Slug { get; init; } = string.Empty;
[StringLength(250)]
public string? Excerpt { get; init; }
// Collection of tag names to assign
[MaxLength(10, ErrorMessage = "A post can have at most 10 tags.")]
public IReadOnlyList<string> Tags { get; init; } = [];
}
// ── Separate DTO for update (different validation rules) ──────────────────
public record UpdatePostRequest
{
// All nullable — null means "do not change"
[StringLength(200, MinimumLength = 5)]
public string? Title { get; init; }
[MinLength(50)]
public string? Body { get; init; }
[StringLength(100)]
[RegularExpression(@"^[a-z0-9-]+$")]
public string? Slug { get; init; }
[StringLength(250)]
public string? Excerpt { get; init; }
public IReadOnlyList<string>? Tags { get; init; }
}
// ── Nested DTO for complex requests ───────────────────────────────────────
public record CreateUserRequest
{
[Required, EmailAddress]
public string Email { get; init; } = string.Empty;
[Required, MinLength(8)]
public string Password { get; init; } = string.Empty;
public required AddressDto Address { get; init; }
[Required]
public required ProfileDto Profile { get; init; }
}
public record AddressDto(
string Street, string City, string Country,
[property: RegularExpression(@"^\d{5}(-\d{4})?$")] string? PostalCode);
public record ProfileDto(
[property: StringLength(100)] string DisplayName,
[property: StringLength(500)] string? Bio);
record types for request DTOs enforces immutability by default (init properties) — once the request is bound from the HTTP body, its values cannot be changed. This prevents accidental mutation of request data inside service methods, which can lead to subtle bugs when the same object is passed through multiple layers. Records also provide built-in value equality, which is useful for caching and testing. For mutable update patterns, you can mix records with nullable properties as shown in UpdatePostRequest.Post is your EF Core entity and you bind it from the request body, model binding populates all public properties including Id, AuthorId, CreatedAt, and IsPublished — fields that the client should never set. An attacker can then send { "id": 1, "authorId": "admin-id", "isPublished": true } to create an admin-attributed published post. Use dedicated request DTOs with only the fields clients should control.Request DTO for Complex Operations
// ── Bulk operation request ─────────────────────────────────────────────────
public record BulkDeleteRequest
{
[Required]
[MinLength(1, ErrorMessage = "At least one ID is required.")]
[MaxLength(100, ErrorMessage = "Cannot delete more than 100 items at once.")]
public required IReadOnlyList<int> Ids { get; init; }
}
// ── Search/filter request ─────────────────────────────────────────────────
// For complex filters, bind from query string with [FromQuery]
public record PostSearchQuery
{
[FromQuery(Name = "q")]
public string? SearchText { get; init; }
[FromQuery]
[RegularExpression(@"^[a-z0-9-]+$")]
public string? Category { get; init; }
[FromQuery]
[Range(1, int.MaxValue)]
public int Page { get; init; } = 1;
[FromQuery]
[Range(1, 100)]
public int Size { get; init; } = 10;
[FromQuery]
public DateOnly? PublishedAfter { get; init; }
}
// Usage in action: [HttpGet] public async Task<IActionResult> Search([FromQuery] PostSearchQuery query)
Common Mistakes
Mistake 1 — Binding entity models directly (over-posting vulnerability)
❌ Wrong — client can set any entity property including sensitive ones:
[HttpPost] public async Task<IActionResult> Create([FromBody] Post post) { }
✅ Correct — always bind a dedicated request DTO: CreatePostRequest.
Mistake 2 — One DTO for both create and update (wrong validation in each direction)
❌ Wrong — [Required] on Title is correct for create but wrong for update (PATCH should allow omitting Title).
✅ Correct — use CreatePostRequest with Required and UpdatePostRequest with nullable fields.