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
}
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.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.{ 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().