Response DTOs shape what the API returns to clients. A good response DTO contains exactly what clients need — not more (over-fetching), not less (under-fetching). It hides internal implementation details (database IDs that reveal schema, sensitive fields like password hashes, internal audit fields) and can include computed properties that would otherwise require client-side calculation. Designing response DTOs carefully is the difference between an API that clients love and one that forces clients to do excessive data transformation.
Response DTO Design
// ── Summary DTO — for list endpoints (lean, fast) ──────────────────────────
public record PostSummaryDto
{
public int Id { get; init; }
public string Title { get; init; } = string.Empty;
public string Slug { get; init; } = string.Empty;
public string AuthorName { get; init; } = string.Empty;
public DateTime PublishedAt { get; init; }
public int ViewCount { get; init; }
public int CommentCount { get; init; }
// Computed properties — calculated server-side, ready for display
public string RelativeDate => PublishedAt < DateTime.UtcNow.AddDays(-7)
? PublishedAt.ToString("MMMM d, yyyy")
: $"{(int)(DateTime.UtcNow - PublishedAt).TotalDays}d ago";
public IReadOnlyList<string> Tags { get; init; } = [];
}
// ── Detail DTO — for single resource endpoint (complete) ──────────────────
public record PostDto
{
public int Id { get; init; }
public string Title { get; init; } = string.Empty;
public string Slug { get; init; } = string.Empty;
public string Body { get; init; } = string.Empty;
public string? Excerpt { get; init; }
public bool IsPublished { get; init; }
public DateTime? PublishedAt { get; init; }
public DateTime CreatedAt { get; init; }
public DateTime? UpdatedAt { get; init; }
public AuthorDto Author { get; init; } = null!;
public IReadOnlyList<string> Tags { get; init; } = [];
// NOT included: PasswordHash, AuthorId (internal), EF navigation properties
}
// ── Nested author DTO ─────────────────────────────────────────────────────
public record AuthorDto(int Id, string DisplayName, string? AvatarUrl);
// ── Paginated envelope ─────────────────────────────────────────────────────
public record PagedResult<T>(
IReadOnlyList<T> Items,
int Page,
int PageSize,
int Total)
{
public int TotalPages => (int)Math.Ceiling(Total / (double)PageSize);
public bool HasNextPage => Page < TotalPages;
public bool HasPrevPage => Page > 1;
public int NextPage => HasNextPage ? Page + 1 : Page;
public int PrevPage => HasPrevPage ? Page - 1 : Page;
}
links property in your DTOs for HATEOAS (Hypermedia as the Engine of Application State) to make the API self-discoverable: { "id": 42, "title": "Hello", "links": { "self": "/api/posts/42", "comments": "/api/posts/42/comments", "author": "/api/users/7" } }. Angular clients can follow these links rather than constructing URLs client-side. Full HATEOAS is rarely needed for most applications, but including self-referencing links (links.self) is a simple, high-value addition that any Angular service can use.ModifiedByIpAddress), system flags (IsBanned, InternalNotes), or fields meant for internal use only. Even if the client does not display them, they appear in browser developer tools, network logs, and any API response logging. Design your response DTOs as the public API contract — if a field should not be client-visible, it should not be in the DTO.Consistent Null and Empty Collection Handling
// ── Collection responses — always an array, never null ────────────────────
// Bad: tags: null ← Angular must check for null before iterating
// Good: tags: [] ← Angular can always iterate
public record PostDto
{
// Always initialise collections to empty — never null
public IReadOnlyList<string> Tags { get; init; } = [];
public IReadOnlyList<CommentDto> RecentComments { get; init; } = [];
}
// ── Null vs absent in JSON ─────────────────────────────────────────────────
// With DefaultIgnoreCondition = WhenWritingNull:
// - Optional fields with null value are OMITTED from JSON (bandwidth saving)
// - Angular must handle missing fields the same as null
// With DefaultIgnoreCondition = Never (default):
// - Null fields appear as "field": null in JSON (explicit, predictable)
// Choose one approach and be consistent — mixing causes Angular handling bugs
Common Mistakes
Mistake 1 — Using the same DTO for list and detail endpoints (over-fetching in lists)
❌ Wrong — list endpoint returns full post body (KB each) for 50 posts; slow and wasteful.
✅ Correct — PostSummaryDto for list; PostDto with full body for GET /api/posts/{id}.
Mistake 2 — Returning null for collection properties (Angular forEach crashes)
❌ Wrong — tags: null in JSON; Angular post.tags.forEach() throws TypeError.
✅ Correct — always initialise collection properties to empty arrays: IReadOnlyList<string> Tags { get; init; } = [];