ViewModels — Designing Data Contracts for Views

A ViewModel is a C# class designed specifically for what a particular view needs to display or receive. It is neither a domain entity (which represents business data with invariants) nor a database model (which maps to a table structure). ViewModels are shaped for the HTTP layer: they contain exactly the properties a view renders or a form submits — no more, no less. This precision prevents security vulnerabilities (over-posting), makes the view’s data contract explicit, and keeps domain entities insulated from HTTP concerns.

ViewModel Design Principles

// ── Domain entity — has business rules and invariants ─────────────────────
public class Post
{
    public int      Id          { get; private set; }
    public string   Title       { get; private set; } = string.Empty;
    public string   Slug        { get; private set; } = string.Empty;
    public string   Body        { get; private set; } = string.Empty;
    public string   AuthorId    { get; private set; } = string.Empty;
    public bool     IsPublished { get; private set; }
    public DateTime? PublishedAt { get; private set; }
    public DateTime CreatedAt   { get; private set; }
    // private setters — entity controls its own state
}

// ── ViewModels — shaped for each specific use case ────────────────────────

// For the list page — only what the card needs
public class PostSummaryViewModel
{
    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; }

    // Computed display property — computed from data, not stored
    public string RelativeDate => PublishedAt < DateTime.UtcNow.AddDays(-7)
        ? PublishedAt.ToString("MMM d, yyyy")
        : $"{(DateTime.UtcNow - PublishedAt).Days} days ago";
}

// For the create form — only the fields the user fills in
public class CreatePostViewModel
{
    public string Title { get; set; } = string.Empty;
    public string Body  { get; set; } = string.Empty;
    public string Slug  { get; set; } = string.Empty;
    // No: Id, AuthorId, CreatedAt, IsPublished, PublishedAt
    // Those are set server-side — not user-submitted
}

// For the edit form — includes Id for the route, editable fields only
public class EditPostViewModel
{
    public int    Id    { get; set; }    // needed to know which post to update
    public string Title { get; set; } = string.Empty;
    public string Body  { get; set; } = string.Empty;
    public string Slug  { get; set; } = string.Empty;
    // No IsPublished — separate Publish/Unpublish actions for that
}
Note: Each view typically needs its own ViewModel even if the data overlap is high. An EditPostViewModel and a CreatePostViewModel might look nearly identical today, but they diverge over time: the edit form needs the post ID, may show creation date, may include approval status. Sharing one ViewModel between create and edit means it must satisfy both, leading to nullable fields that are required in one context but not the other, and validation rules that only apply in one scenario. Separate ViewModels for separate concerns is the correct approach.
Tip: Map between domain entities and ViewModels in extension methods or a dedicated mapping service — never in controllers or views. A clean pattern: public static PostSummaryViewModel ToSummaryViewModel(this Post post) => new PostSummaryViewModel { Id = post.Id, Title = post.Title, ... }. The controller action becomes: var vm = post.ToSummaryViewModel(); return View(vm);. This keeps mapping logic in one place, makes it testable, and keeps controllers thin. AutoMapper is an alternative for large codebases but adds a dependency for simple mapping scenarios.
Warning: Never use { get; set; } public setters on ViewModels returned to views without considering security. A ViewModel with public setters can have any property set during model binding on a POST — even properties you did not include in the form. Use { get; init; } for display-only ViewModels (read-only after construction). Use { get; set; } only on form ViewModels where model binding must set properties, and only include properties that should be user-settable.

Pagination ViewModel

// ── Generic pagination wrapper ─────────────────────────────────────────────
public class PagedViewModel<T>
{
    public IReadOnlyList<T> Items    { get; init; } = [];
    public int              Page     { get; init; }
    public int              PageSize { get; init; }
    public int              Total    { get; init; }

    // Computed paging properties
    public int  TotalPages   => (int)Math.Ceiling(Total / (double)PageSize);
    public bool HasPrevPage  => Page > 1;
    public bool HasNextPage  => Page < TotalPages;
    public int  PrevPage     => Page - 1;
    public int  NextPage     => Page + 1;
}

// Usage in controller
public async Task<IActionResult> Index(int page = 1)
{
    var posts  = await _service.GetPublishedAsync(page, 10);
    var total  = await _service.CountPublishedAsync();
    var vm     = new PagedViewModel<PostSummaryViewModel>
    {
        Items    = posts.Select(p => p.ToSummaryViewModel()).ToList(),
        Page     = page,
        PageSize = 10,
        Total    = total,
    };
    return View(vm);
}

Common Mistakes

Mistake 1 — Sharing one ViewModel between multiple views with different requirements

❌ Wrong — one CreateEditViewModel with nullable fields and conditional validation.

✅ Correct — separate CreatePostViewModel and EditPostViewModel for each view’s specific needs.

Mistake 2 — Putting mapping logic in controllers

❌ Wrong — every controller action has 10 lines of property assignment mapping:

var vm = new PostSummaryViewModel { Id = post.Id, Title = post.Title, ... };

✅ Correct — use extension methods or a mapping service; controllers call post.ToSummaryViewModel().

🧠 Test Yourself

Why should AuthorId and IsPublished NOT be included in a CreatePostViewModel used for a public “submit post” form?