Response DTOs — Shaping API Output

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;
}
Note: Summary DTOs for list endpoints should be leaner than detail DTOs for single-item endpoints. A list of 50 posts does not need each post’s full body text (can be kilobytes each), all comments, or complete author profiles. A summary with title, slug, excerpt, and author name is typically sufficient. The client navigates to the detail endpoint for the full content. This pattern — lean list, full detail — significantly reduces bandwidth and improves list endpoint performance.
Tip: Include a 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.
Warning: Never include sensitive fields in response DTOs — password hashes, internal audit fields (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; } = [];

🧠 Test Yourself

A list endpoint returns 50 posts. Each post’s detail DTO includes the full body text (average 2KB). What happens to the response payload, and what should you use instead?