No history yet

Advanced MVC Patterns

Beyond Basic Controllers

In a simple application, passing your domain models directly to a view seems efficient. But as projects grow, this creates a tight coupling that becomes a liability. Your database structure starts dictating your UI design, and any change in one can break the other. Worse, you might unintentionally expose sensitive data or open yourself up to security risks like attacks, where a user submits extra data you didn't ask for.

The solution is to use specialized data transfer classes. For the UI layer, these are called ViewModels. A ViewModel is a simple class whose sole purpose is to provide the data a specific view needs, and nothing more. It's a contract between your controller and your view.

A ViewModel is shaped by the UI's requirements, not the database's structure. This separation gives you the freedom to evolve your back-end and front-end independently.

Let's say you have a Product domain model:

// Domain Model (e.g., in your Data/Entities folder)
public class Product
{
    public int Id { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
    public int StockLevel { get; set; }
    public bool IsArchived { get; set; }
}

A view for displaying the product in a public catalogue doesn't need StockLevel or IsArchived. So, you create a ProductViewModel:

// ViewModel (e.g., in a ViewModels folder)
public class ProductViewModel
{
    public string Name { get; set; }
    public string FormattedPrice { get; set; }
}

// In your Controller
public IActionResult Details(int id)
{
    var product = _productService.GetProduct(id);

    var viewModel = new ProductViewModel
    {
        Name = product.Name,
        FormattedPrice = product.Price.ToString("C") // Add view-specific logic
    };

    return View(viewModel);
}

Notice how the ViewModel contains formatting logic (FormattedPrice). This is a key benefit: you keep presentation concerns out of your domain models. For handling incoming data, like form submissions, you'd use a similar pattern called an InputModel or DTO (Data Transfer Object).

Mastering Dependency Injection

ASP.NET Core has baked in, which helps manage the dependencies between different parts of your application. The key to using it effectively is understanding service lifetimes. The lifetime determines how long an object instance lives and when a new one is created.

There are three primary service lifetimes:

LifetimeDescriptionWhen to Use
SingletonA single instance is created for the entire application lifetime.Caching services, application configuration, logging services.
ScopedA new instance is created once per client request (HTTP request).Database contexts (like EF Core's DbContext), user-specific services.
TransientA new instance is created every time it's requested.Lightweight, stateless services like email senders or calculators.

You register these services in your Program.cs file.

var builder = WebApplication.CreateBuilder(args);

// Register services
builder.Services.AddSingleton<ILoggerService, LoggerService>();
builder.Services.AddScoped<IProductRepository, ProductRepository>();
builder.Services.AddTransient<IEmailSender, EmailSender>();

var app = builder.Build();

Choosing the wrong lifetime can lead to subtle bugs. For example, registering a DbContext as a singleton can cause data from one user's request to leak into another's.

Custom Middleware

The ASP.NET Core request pipeline is a series of components called middleware, each processing an incoming request and deciding whether to pass it to the next component. You can write your own custom middleware to handle cross-cutting concerns like logging, exception handling, or performance monitoring.

A middleware component is a class with an InvokeAsync method. It receives the HttpContext and a delegate (RequestDelegate) to call the next piece of middleware in the pipeline.

using System.Diagnostics;

public class PerformanceLoggingMiddleware
{
    private readonly RequestDelegate _next;

    public PerformanceLoggingMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        var stopwatch = Stopwatch.StartNew();

        // Call the next middleware in the pipeline
        await _next(context);

        stopwatch.Stop();
        // Log the time taken after the response has been generated
        Console.WriteLine($"Request to {context.Request.Path} took {stopwatch.ElapsedMilliseconds}ms");
    }
}

To add this to the pipeline, you use an extension method in Program.cs.

// In Program.cs
var app = builder.Build();

// Custom middleware should be placed early in the pipeline
// to measure the full request time.
app.UseMiddleware<PerformanceLoggingMiddleware>();

app.UseRouting();
// ... other middleware

app.Run();

Filters and Advanced Routing

While middleware operates on the entire request pipeline, Action Filters give you a way to run code immediately before or after a specific controller action executes. They are perfect for logic that only applies to certain actions, like validation or caching.

You implement IActionFilter (or IAsyncActionFilter) and apply it as an attribute.

public class ValidateModelAttribute : ActionFilterAttribute
{
    public override void OnActionExecuting(ActionExecutingContext context)
    {
        if (!context.ModelState.IsValid)
        {
            context.Result = new BadRequestObjectResult(context.ModelState);
        }
        base.OnActionExecuting(context);
    }
}

// Apply it to an action or a whole controller
[HttpPost]
[ValidateModel]
public IActionResult Create(ProductInputModel model)
{
    // This code only runs if the model is valid
    return Ok();
}

Finally, you can make your routing more powerful and descriptive using with constraints. This allows you to define routes directly on your controller actions and enforce rules on the URL parameters.

[ApiController]
[Route("api/products")]
public class ProductsController : ControllerBase
{
    // Matches GET /api/products/123
    [HttpGet("{id:int}")] // :int is a route constraint
    public IActionResult GetById(int id)
    {
        // ...
    }

    // Matches GET /api/products/search?term=widget
    [HttpGet("search")]
    public IActionResult Search([FromQuery] string term)
    {
        // ...
    }

    // Matches GET /api/products/archives/2023/12
    // Combines multiple constraints
    [HttpGet("archives/{year:int:min(2000)}/{month:int:range(1,12)}")]
    public IActionResult GetArchives(int year, int month)
    {
        // ...
    }
}

By combining these advanced patterns—ViewModels, precise dependency lifetimes, custom middleware, and powerful filters—you can build ASP.NET Core applications that are not just functional, but also robust, maintainable, and scalable.

Quiz Questions 1/6

What is the primary purpose of using a ViewModel in an ASP.NET Core application?

Quiz Questions 2/6

In ASP.NET Core's dependency injection system, which service lifetime ensures that a new instance of a service is created for each HTTP request?