17 minute read

23 min read 4783 words

Today, we’re diving into the fundamental building blocks of ASP.NET Core MVC in .NET 10. Whether you’re an absolute beginner or just looking for a quick review, this guide will walk you through the core concepts of Model-View-Controller (MVC), View Components, and how to interact with a database using Entity Framework (EF) Core.

1. The MVC Pattern

The Model-View-Controller (MVC) pattern is a design principle that separates an application into three main components:

  • Model: Represents the data and business logic.
  • View: Handles the presentation and user interface (HTML/Razor).
  • Controller: Acts as an intermediary, handling user requests, updating the Model, and selecting the View.

How it Works (The Request Flow)

In ASP.NET Core 10, the flow usually looks like this:

       +----------------+
       |      User      |
       +-------+--------+
               |
          (1) Request
               |
               v
       +-------+--------+                                   +-----------+
       |   Controller   | <---- (2) Fetch/Update Data ----> |   Model   |
       +-------+--------+                                   +-----+-----+
               |                                                  |
          (4) Passes Data                                   (3) DB Access
               |                                                  |
               v                                                  v
       +-------+--------+                                   +-----------+
       |      View      |                                   | SQL Server|
       +-------+--------+                                   +-----------+
               |
          (5) HTML Response
               |
               v
       +-------+--------+
       |      User      |
       +----------------+
  1. User Request: The user navigates to a URL (e.g., /Books/Index).
  2. Controller Logic: The Controller receives the request and talks to the Model.
  3. Database Access: The Model (via EF Core) fetches or updates data in the SQL Server.
  4. Selecting the View: The Controller passes that data to the View.
  5. Final Response: The View renders the HTML and sends it back to the User’s browser.

Why use MVC?

  • Separation of Concerns: Each component has a specific responsibility.
  • Testability: It’s easier to unit test individual parts (especially Controllers and Models).
  • Flexibility: You can swap the UI (View) without changing the business logic (Model).

2. Models: Defining Your Data

In .NET 10, Models are simple C# classes. Using Data Annotations, we can define validation rules directly on the properties:

using System.ComponentModel.DataAnnotations;

public class Book
{
    public int Id { get; set; }

    [Required]
    [StringLength(100)]
    public string Title { get; set; } = string.Empty;

    [Required]
    public string Author { get; set; } = string.Empty;

    [Range(0.01, 999.99)]
    public decimal Price { get; set; }
}

3. Controllers: The Orchestrators

Controllers handle incoming HTTP requests. They use Dependency Injection to access services like the DbContext.

public class BooksController : Controller
{
    private readonly ApplicationDbContext _context;

    public BooksController(ApplicationDbContext context)
    {
        _context = context;
    }

    public async Task<IActionResult> Index()
    {
        var books = await _context.Books.ToListAsync();
        return View(books);
    }

    public IActionResult Create()
    {
        return View();
    }

    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> Create(Book book)
    {
        if (ModelState.IsValid)
        {
            _context.Add(book);
            await _context.SaveChangesAsync();
            return RedirectToAction(nameof(Index));
        }
        return View(book);
    }
}

4. Views: The User Interface

Views in ASP.NET Core use Razor, a markup syntax that lets you embed C# code into HTML.

Views/Books/Index.cshtml:

@model IEnumerable<Book>

<h1>Books List</h1>
<table class="table">
    <thead>
        <tr>
            <th>Title</th>
            <th>Author</th>
            <th>Price</th>
        </tr>
    </thead>
    <tbody>
        @foreach (var item in Model) {
            <tr>
                <td>@item.Title</td>
                <td>@item.Author</td>
                <td>@item.Price.ToString("C")</td>
            </tr>
        }
    </tbody>
</table>

5. Passing Data to Views: Model, ViewData, and ViewBag

In MVC, the Controller is responsible for preparing the data and handing it over to the View. There are three primary ways to do this:

This is the most common and robust approach. You pass a specific object (or a list) directly into the View() method.

Controller:

public IActionResult Details()
{
    var myBook = new Book { Title = "ASP.NET Core Basics", Author = "John Doe" };
    return View(myBook); // Passing the model directly
}

View:

@model Book
<h1>@Model.Title</h1>
<p>Author: @Model.Author</p>
  • Pros: IntelliSense, compile-time type checking, and cleaner code.
  • Best for: The main data the page is designed to display.

B. ViewData

ViewData is a dictionary that stores data as object types. You access it using string keys.

Controller:

ViewData["CurrentTime"] = DateTime.Now.ToShortTimeString();

View:

<p>Server Time: @ViewData["CurrentTime"]</p>
  • Pros: Easy to pass extra metadata that isn’t part of the main model.
  • Cons: Requires casting for complex types, no IntelliSense, and prone to typos.

C. ViewBag

ViewBag is a dynamic wrapper around ViewData. It allows you to create properties on the fly without casting.

Controller:

ViewBag.WelcomeMessage = "Welcome to our Bookstore!";

View:

<h3>@ViewBag.WelcomeMessage</h3>
  • Pros: No casting required, cleaner syntax than ViewData.
  • Cons: No IntelliSense, no compile-time checking (errors only appear at runtime).

Comparison: When to use which?

Feature Model (Strongly-Typed) ViewData ViewBag
Type Safety High (Compile-time) Low (Casting needed) Low (Dynamic)
IntelliSense Yes No No
Usage Main data source Small metadata/settings Small metadata/settings
Recommendation Always use for core data Use sparingly Use sparingly

6. View Components: Reusable UI Blocks

View Components are more powerful than partial views. They have their own logic and can perform database queries independently of the Controller.

Creating a View Component

Components/RecommendedBooksViewComponent.cs:

public class RecommendedBooksViewComponent : ViewComponent
{
    private readonly ApplicationDbContext _context;

    public RecommendedBooksViewComponent(ApplicationDbContext context)
    {
        _context = context;
    }

    public async Task<IViewComponentResult> InvokeAsync(int count)
    {
        var items = await _context.Books.Take(count).ToListAsync();
        return View(items);
    }
}

Invoking it in a View

@await Component.InvokeAsync("RecommendedBooks", new { count = 3 })

7. Partial Views: Reusable UI Snippets

A Partial View is a Razor file (.cshtml) that renders a portion of HTML. Unlike a regular View, it doesn’t run _ViewStart.cshtml and is usually rendered within another view.

Why use Partial Views?

  • Dry (Don’t Repeat Yourself): Reuse the same HTML snippet (like a header, footer, or a card) across multiple pages.
  • Organization: Break down large, complex views into smaller, manageable pieces.

Example: Rendering a Book Row

Instead of writing the same table row logic in multiple places, we can create a partial view.

Views/Shared/_BookRow.cshtml:

@model Book

<tr>
    <td>@Model.Title</td>
    <td>@Model.Author</td>
    <td><strong>@Model.Price.ToString("C")</strong></td>
</tr>

How to use it in a View

You can use the <partial> Tag Helper (recommended) or the @await Html.PartialAsync() method.

@foreach (var item in Model) {
    <partial name="_BookRow" model="item" />
}

Partial Views vs. View Components

  • Partial Views: Best for simple UI snippets that only need the data passed to them. They depend on the parent view’s ViewData and Model.
  • View Components: Best for complex UI blocks that need their own logic or database access (e.g., a dynamic shopping cart or navigation menu).

8. Interacting with Database: EF Core in .NET 10

Entity Framework Core is the official ORM for .NET. In .NET 10, it continues to provide a seamless way to map your C# objects to database tables.

Prerequisites: Installing Extensions

To use Entity Framework Core for your database and scaffolding, you’ll need to install the following NuGet packages. For .NET 10, you’ll want to use the latest 10.x versions.

Add these to your project’s .csproj file, or install them via the NuGet Package Manager:

<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.5" />

Why are these needed?

  • Microsoft.EntityFrameworkCore: The core package that contains the essential APIs for querying and saving data.
  • Microsoft.EntityFrameworkCore.SqlServer: The specific provider that enables EF Core to communicate with Microsoft SQL Server.
  • Microsoft.EntityFrameworkCore.Relational: Contains shared code for all relational database providers (like SQL Server, SQLite, etc.).
  • Microsoft.EntityFrameworkCore.Tools: Adds support for Entity Framework commands (like Add-Migration) within the Visual Studio Package Manager Console.
  • Microsoft.EntityFrameworkCore.Design: Includes the design-time logic (e.g., dotnet ef CLI) used to scaffold models from a database or generate migrations.

DbContext Configuration

public class ApplicationDbContext : DbContext
{
    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
        : base(options)
    {
    }

    public DbSet<Book> Books { get; set; }
}

Registering the Service (Program.cs)

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<ApplicationDbContext>(options => options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));

builder.Services.AddControllersWithViews();

var app = builder.Build();

9. Development Approaches: Code First vs. Database First

When working with Entity Framework Core, you can choose between two main development approaches.

In this approach, you write your C# classes (Models) first, and EF Core automatically creates the Database for you based on those classes using Migrations.

  • Example: You already created the Book model in Section 2.
  • Step 1: Define the model (Book.cs).
  • Step 2: Create a Migration: dotnet ef migrations add InitialCreate.
  • Step 3: Update the Database: dotnet ef database update.

Benefits: You have full control over the code, and the database schema is version-controlled alongside your application logic.

B. Database First Approach (Existing Databases)

This approach is used when you already have an Existing Database. You use EF Core tools to Reverse Engineering the database and generate the C# Models and DbContext automatically.

  • Example: If you have a Products table in SQL Server.
  • Step 1: Run the Scaffold command (see Section 13).
  • Step 2: EF Core generates the Product.cs class and ApplicationDbContext.cs for you.

Benefits: Ideal for legacy systems or when the database is managed by a separate DBA team.


10. Dependency Injection: The “Delivery Service”

Dependency Injection (DI) is a fancy way of saying “I need someone to bring me my tools.”

ELI5: The Paintbrush Analogy

Imagine you are a painter. Instead of going to the store yourself to find a paintbrush, you just say, “I need a paintbrush!” and someone (the DI Container) magically puts it in your hand. You don’t care how it was made or where it came from; you just use it to paint.

In ASP.NET Core, the DI Container is like a delivery service. When a Controller needs a DbContext, it doesn’t create one; it just asks for it in its constructor, and the system delivers it.

Why use it?

  • Saves Work: You don’t have to write new MyService() everywhere.
  • Easy to Test: You can easily swap a real database service for a “fake” one during testing.

11. Service Lifetimes: Transient, Scoped, and Singleton

In ASP.NET Core, when you register a service in Program.cs, you must decide its Lifetime. This determines how often the service is created and how long it lives.

ELI5: A Simple Comparison

  • Transient (A new piece of paper): Every time you want to draw, you get a fresh, brand-new piece of paper.
  • Scoped (Sharing a toy at a party): During one party (one web request), all the kids use the same toy. When the next party starts, they get a new toy.
  • Singleton (The sun): There is only one sun. Everyone in every city sees the same sun, and it stays there forever.

A. Transient (AddTransient)

Transient services are created every time they are requested from the service container. This lifetime works best for lightweight, stateless services.

Visualizing the Transient Lifetime: Imagine a Coffee Shop. Every time you order a coffee (request a service), the barista makes a brand-new cup just for you. Even if you ask for another coffee a minute later, you get a second, different cup.

       Request A (User 1)
    +-----------------------+
    |  [ Start Request ]    |
    |          |            |
    |  (1) Controller asks  |
    |      for Service      |
    |      -> Create ID: 01 |
    |          |            |
    |  (2) Repository asks  |
    |      for Service      |
    |      -> Create ID: 02 |
    |          |            |
    |  [ End Request ]      |
    +-----------------------+
  • Example: A service that generates a random number or performs a simple calculation.
  • Registration:
    builder.Services.AddTransient<IMyService, MyService>();
    

B. Scoped (AddScoped)

Scoped services are created once per client request (e.g., within a single HTTP request). All components processing that specific request share the same instance.

Visualizing the Scope: Imagine a single web request (like you clicking “Details” on a book) as a single “Shopping Trip”.

  • If you need a Cart (Scoped), you get one cart at the start of your trip.
  • No matter how many aisles you visit (Controller, Repository, Service), you keep using that same cart.
  • Once you checkout and leave the store (Request Ends), that cart is returned.
  • When you come back later (New Request), you get a new cart.
       Request A (User 1)              Request B (User 2)
    +-----------------------+       +-----------------------+
    |  [ Start Request ]    |       |  [ Start Request ]    |
    |          |            |       |          |            |
    |  (1) Controller asks  |       |  (1) Controller asks  |
    |      for Service      |       |      for Service      |
    |      -> Create ID: 01 |       |      -> Create ID: 02 |
    |          |            |       |          |            |
    |  (2) Repository asks  |       |  (2) Repository asks  |
    |      for Service      |       |      for Service      |
    |      -> Reuse ID: 01  |       |      -> Reuse ID: 02  |
    |          |            |       |          |            |
    |  [ End Request ]      |       |  [ End Request ]      |
    |   (ID: 01 Deleted)    |       |   (ID: 02 Deleted)    |
    +-----------------------+       +-----------------------+
  • Example: The DbContext is registered as Scoped by default because you want to use the same database connection for the entire duration of a single web request.
  • Registration:
    builder.Services.AddScoped<IMyService, MyService>();
    

C. Singleton (AddSingleton)

Singleton services are created the first time they are requested (or when the app starts) and then shared by all users and all requests for the entire lifetime of the application.

Visualizing the Singleton Lifetime: Imagine the Clock on a Library Wall. There is only one clock. Everyone who walks into the library sees the same clock. When you leave and someone else walks in, they see that same clock. It stays there forever (as long as the library is open).

       Request A (User 1)              Request B (User 2)
    +-----------------------+       +-----------------------+
    |  [ Start Request ]    |       |  [ Start Request ]    |
    |          |            |       |          |            |
    |  (1) Controller asks  |       |  (1) Controller asks  |
    |      for Service      |       |      for Service      |
    |      -> Reuse ID: 01  |       |      -> Reuse ID: 01  |
    |          |            |       |          |            |
    |  [ End Request ]      |       |  [ End Request ]      |
    |   (ID: 01 Stays)      |       |   (ID: 01 Stays)      |
    +-----------------------+       +-----------------------+
  • Example: A service that handles application-wide caching or global configuration settings.
  • Registration:
    builder.Services.AddSingleton<IMyService, MyService>();
    

Seeing it in Action: The Guid Example

The best way to understand these lifetimes is to see them in action using a Unique ID (Guid). When a service is created, it gets a new ID. If the ID stays the same, it’s the same object!

1. Define the Services

We’ll create three interfaces and one implementation class that generates a new ID in its constructor.

public interface ITransientService { Guid Id { get; } }
public interface IScopedService { Guid Id { get; } }
public interface ISingletonService { Guid Id { get; } }

public class MyGuidService : ITransientService, IScopedService, ISingletonService
{
    public Guid Id { get; } = Guid.NewGuid();
}

2. Register them in Program.cs

builder.Services.AddTransient<ITransientService, MyGuidService>();
builder.Services.AddScoped<IScopedService, MyGuidService>();
builder.Services.AddSingleton<ISingletonService, MyGuidService>();

3. Request them in a Controller

If we inject two of each into a Controller:

public class LifetimeController : Controller
{
    private readonly ITransientService _t1, _t2;
    private readonly IScopedService _s1, _s2;
    private readonly ISingletonService _sin1, _sin2;

    public LifetimeController(
        ITransientService t1, ITransientService t2,
        IScopedService s1, IScopedService s2,
        ISingletonService sin1, ISingletonService sin2)
    {
        _t1 = t1; _t2 = t2;
        _s1 = s1; _s2 = s2;
        _sin1 = sin1; _sin2 = sin2;
    }
}

The Result:

  • Transient (_t1 vs _t2): The IDs will be different. A new one is made every time you ask for it.
  • Scoped (_s1 vs _s2): The IDs will be identical within the same request, but will change if you refresh the page.
  • Singleton (_sin1 vs _sin2): The IDs will be identical and will never change, even if you restart the browser!

Comparison Summary

Lifetime Created… Best for…
Transient Every time requested Lightweight, stateless tasks
Scoped Once per HTTP request Database contexts, user-specific services
Singleton Once (shared by all) Caching, App-wide settings

12. The Repository Pattern: Your Data Librarian

The Repository Pattern is a design pattern that sits between your Controller and your Database. It acts as an abstraction layer to manage your data logic in one place.

ELI5: The Librarian Analogy

Imagine the Database is a huge library with millions of books in the basement. You are the Controller. Instead of you going down to the basement, searching through thousands of shelves, and grabbing a book yourself, you just ask the Librarian (the Repository): “Can I have the ‘Harry Potter’ book?”

The Librarian knows exactly where it is and brings it to you. You don’t care if they have to climb a ladder or use a flashlight; you just get the book you asked for.

Which Service Lifetime should I use?

For a Repository, you should almost always use Scoped (AddScoped).

Why?

  • Database Context Dependency: Repositories depend on the DbContext to talk to the database. Since DbContext is Scoped, your Repository must also be Scoped (or Transient). You cannot make a Repository a Singleton because it would try to use a DbContext that might have already been closed (disposed).
  • Efficiency: Scoped ensures that you use the same Repository instance throughout a single web request, which is efficient and keeps your data operations consistent.

Simple Example

1. Create the Interface and Class:

public interface IBookRepository
{
    Task<IEnumerable<Book>> GetAllBooksAsync();
}

public class BookRepository : IBookRepository
{
    private readonly ApplicationDbContext _context;

    public BookRepository(ApplicationDbContext context)
    {
        _context = context;
    }

    public async Task<IEnumerable<Book>> GetAllBooksAsync()
    {
        return await _context.Books.ToListAsync();
    }
}

2. Register it in Program.cs:

builder.Services.AddScoped<IBookRepository, BookRepository>();

13. Scaffolding: Reverse Engineering an Existing Database

If you have an existing database and want to generate Models and a DbContext automatically, you can use Scaffolding.

A. Using the Command Line (CLI)

The dotnet ef tool allows you to scaffold your database from the terminal.

  1. Prerequisites: Ensure you have the EF Core tools installed:
    dotnet tool install --global dotnet-ef
    

    And add the Design package to your project:

    dotnet add package Microsoft.EntityFrameworkCore.Design
    
  2. The Scaffold Command: Run the following command to generate models from a SQL Server database:
    dotnet ef dbcontext scaffold "Server=YOUR_SERVER;Database=YOUR_DB;Trusted_Connection=True;TrustServerCertificate=True;" Microsoft.EntityFrameworkCore.SqlServer --output-dir Models
    

Key Parameters:

  • --output-dir (or -o): The folder where models will be generated.
  • --context (or -c): The name of the generated DbContext class.
  • --force (or -f): Overwrite existing files.
  • --table: Scaffold only specific tables.

B. EF Core Power Tools

For a more “powerful” and visual experience, the EF Core Power Tools extension for Visual Studio is highly recommended. It provides a GUI to:

  • Select specific tables, views, and stored procedures.
  • Customize namespaces and file naming.
  • Preview the generated code before applying changes.

To use it, right-click your project in Visual Studio and select EF Core Power Tools > Reverse Engineer.


14. Visual Studio: New Scaffolded Item

For the fastest development, you can use Visual Studio’s built-in New Scaffolded Item feature. This tool automatically generates the Controller and all associated Views (Create, Edit, Delete, Details, Index) based on an existing Model class.

  1. Right-click the Controllers folder in Solution Explorer.
  2. Select Add > New Scaffolded Item…
  3. Choose MVC Controller with views, using Entity Framework and click Add.
  4. In the configuration dialog:
    • Model class: Select the class you want to generate views for (e.g., Book).
    • Db context class: Select your database context (e.g., ApplicationDbContext).
  5. Click Add.

Visual Studio will then generate the C# code for the controller and the Razor HTML for the views, fully wired up with EF Core.


15. Working with Forms: Creating Data

To add a new book to the database, we need a View that contains a form. In ASP.NET Core, we use Tag Helpers (asp-for, asp-action) to simplify the binding between the HTML form and our C# Model.

A. The Create View

Views/Books/Create.cshtml:

@model Book

<h1>Add New Book</h1>
<form asp-action="Create">
    <div asp-validation-summary="ModelOnly" class="text-danger"></div>
    <div class="form-group">
        <label asp-for="Title"></label>
        <input asp-for="Title" class="form-control" />
        <span asp-validation-for="Title" class="text-danger"></span>
    </div>
    <div class="form-group">
        <label asp-for="Author"></label>
        <input asp-for="Author" class="form-control" />
        <span asp-validation-for="Author" class="text-danger"></span>
    </div>
    <div class="form-group">
        <label asp-for="Price"></label>
        <input asp-for="Price" class="form-control" />
        <span asp-validation-for="Price" class="text-danger"></span>
    </div>
    <button type="submit" class="btn btn-primary">Save</button>
</form>

@section Scripts {
    @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

B. The CRUD Workflow

  1. Request Form: The user navigates to /Books/Create, which triggers the GET Create action in the controller.
  2. Post Data: When the user clicks “Save,” the form data is sent back to the server via an HTTP POST request.
  3. Model Binding: ASP.NET Core automatically maps the form fields to the Book object’s properties.
  4. Data Persistence:
    • _context.Add(book) tracks the new object in EF Core.
    • _context.SaveChangesAsync() generates and executes the INSERT INTO SQL Server command.
  5. Redirect: If successful, the user is redirected to the Index page to see the new record.

16. Tag Helpers: Simplifying Your HTML

Tag Helpers enable server-side code to participate in creating and rendering HTML elements in Razor files. They make your views cleaner and more intuitive.

Common Tag Helpers

  • asp-for: Binds an element (like <label> or <input>) to a model property. It automatically handles names, IDs, and validation.
    <input asp-for="Title" class="form-control" />
    
  • asp-controller: Specifies the controller to target. If omitted, it defaults to the current controller.
    <a asp-controller="Books" asp-action="Index">Back to List</a>
    
  • asp-action: Specifies the action method to target (e.g., Index, Create, Edit).
    <form asp-action="Create"> ... </form>
    
  • asp-route-{value}: Adds route parameters to the URL. For example, asp-route-id passes an id.
    <a asp-action="Details" asp-route-id="@item.Id">View Details</a>
    

17. Form Validation: Ensuring Data Quality

Validation ensures the user provides correct information before it reaches the database. In ASP.NET Core MVC, validation happens in three places:

  1. Model (Data Annotations): As shown in Section 2, attributes like [Required] define the rules.
  2. View (Client-Side): By including _ValidationScriptsPartial in the View, jQuery Validation runs instantly on the client side, providing a faster user experience.
  3. Controller (Server-Side): The ModelState.IsValid check in the [HttpPost] Create method (see Section 3) is the final gatekeeper. If validation fails, the Controller returns the View with the error messages.

Key Validation Tag Helpers:

  • asp-validation-summary: Displays a list of all validation errors at the top of the form.
  • asp-validation-for: Displays the validation error message for a specific property (e.g., right under an input field).

18. References

Leave a comment