Skip to content

a-t-k/ATK.Command.AspNet.ModelBinder

Repository files navigation

How to Use Model Binder in ASP.NET

This guide explains how to use the Model Binder to automatically bind JSON command objects from HTTP requests based on a command interface.

Installation

dotnet add package ATK.Command.AspNet.ModelBinder

Core Concept

The model binder works on the basis of a command interface that all commands implement. This enables:

  • ✅ Polymorphic binding of multiple command types
  • ✅ Centralized authentication and validation
  • ✅ Type-safe deserialization based on $type in JSON

Step-by-Step Guide

Step 1: Define Command Interface

Create an interface that all commands will implement:

namespace MyApp.Commands
{
    public interface IUserCommand
    {
        // All commands implementing IUserCommand can be bound
    }
}

Step 2: Create Concrete Commands

Create concrete command classes that implement the interface:

using System.ComponentModel.DataAnnotations;

public class CreateUserCommand : IUserCommand
{
    [Required]
    [StringLength(50)]
    public string Username { get; set; }

    [Required]
    [EmailAddress]
    public string Email { get; set; }

    public string FullName { get; set; }
}

public class UpdateUserCommand : IUserCommand
{
    [Required]
    public int UserId { get; set; }

    [StringLength(50)]
    public string Username { get; set; }

    [EmailAddress]
    public string Email { get; set; }
}

public class DeleteUserCommand : IUserCommand
{
    [Required]
    public int UserId { get; set; }
}

Step 3: Register Model Binder

Register the model binder for the interface in Program.cs:

using ATK.Command.AspNet.ModelBinder;
using ATK.Command.AspNet.ModelBinder.CommandAuthentications;

var builder = WebApplicationBuilder.CreateBuilder(args);

// Setup authentication
builder.Services.AddAuthentication("Bearer");
builder.Services.AddAuthorization();

// Register model binder with the command interface
var commandAuthentications = new List<ICommandAuthentication>
{
    new DefaultCommandAuthentication()
};

builder.Services.AddControllers(options =>
{
    // IMPORTANT: Register the INTERFACE, not individual command classes!
    options.ModelBinderProviders.Insert(0, 
        new RequestCommandModelBinderProvider<IUserCommand>(commandAuthentications));
});

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();

Step 4: Use in Controller

The controller accepts the interface as parameter. The model binder automatically deserializes to the correct command type:

[ApiController]
[Route("api/[controller]")]
public class UserController : ControllerBase
{
    [HttpPost]
    public IActionResult ProcessUserCommand(IUserCommand command)
    {
        // Check validation
        if (!ModelState.IsValid)
        {
            return BadRequest(ModelState);
        }

        // Use pattern matching to check the concrete type
        return command switch
        {
            CreateUserCommand createCmd => HandleCreateUser(createCmd),
            UpdateUserCommand updateCmd => HandleUpdateUser(updateCmd),
            DeleteUserCommand deleteCmd => HandleDeleteUser(deleteCmd),
            _ => BadRequest("Unknown command type")
        };
    }

    private IActionResult HandleCreateUser(CreateUserCommand command)
    {
        // Create new user
        return Ok(new { message = $"User {command.Username} created" });
    }

    private IActionResult HandleUpdateUser(UpdateUserCommand command)
    {
        // Update user
        return Ok(new { message = $"User {command.UserId} updated" });
    }

    private IActionResult HandleDeleteUser(DeleteUserCommand command)
    {
        // Delete user
        return Ok(new { message = $"User {command.UserId} deleted" });
    }
}

Step 5: Send JSON Request

The JSON must include the $type information so the model binder deserializes to the correct command type:

# CreateUserCommand
curl -X POST https://localhost:5001/api/user \
  -H "Content-Type: application/json" \
  -d '{
    "$type": "MyApp.Commands.CreateUserCommand, MyApp",
    "username": "john.doe",
    "email": "john@example.com",
    "fullName": "John Doe"
  }'

# UpdateUserCommand
curl -X POST https://localhost:5001/api/user \
  -H "Content-Type: application/json" \
  -d '{
    "$type": "MyApp.Commands.UpdateUserCommand, MyApp",
    "userId": 1,
    "username": "jane.doe",
    "email": "jane@example.com"
  }'

# DeleteUserCommand
curl -X POST https://localhost:5001/api/user \
  -H "Content-Type: application/json" \
  -d '{
    "$type": "MyApp.Commands.DeleteUserCommand, MyApp",
    "userId": 1
  }'

Authentication & Authorization

Allow Anonymous Commands

To allow a command without authentication, use the [AllowAnonymous] attribute:

[AllowAnonymous]
public class PublicSearchCommand : IUserCommand
{
    public string Query { get; set; }
}

Require Roles

Use the [Authorize] attribute to require specific roles:

[Authorize(Roles = "Admin")]
public class DeleteUserCommand : IUserCommand
{
    [Required]
    public int UserId { get; set; }
}

Require Claims

Use the [ClaimRequirement] attribute for claim-based authorization:

[ClaimRequirement("department", "hr")]
public class PromoteEmployeeCommand : IHRCommand
{
    public int EmployeeId { get; set; }
    public string NewPosition { get; set; }
}

Custom Authentication

Implement ICommandAuthentication for custom authentication logic:

public class ApiKeyCommandAuth : ICommandAuthentication
{
    public bool Execute(ModelBindingContext bindingContext, object model)
    {
        var httpContext = bindingContext.ActionContext.HttpContext;

        if (!httpContext.Request.Headers.TryGetValue("X-API-Key", out var apiKey))
        {
            bindingContext.ModelState.TryAddModelError("Unauthorized", "Missing API Key");
            return false;
        }

        if (!ValidateApiKey(apiKey.ToString()))
        {
            bindingContext.ModelState.TryAddModelError("Unauthorized", "Invalid API Key");
            return false;
        }

        bindingContext.Result = ModelBindingResult.Success(model);
        return true;
    }

    private bool ValidateApiKey(string apiKey)
    {
        // Your validation logic
        return !string.IsNullOrEmpty(apiKey);
    }
}

// Register in Program.cs
var authentications = new List<ICommandAuthentication>
{
    new ApiKeyCommandAuth(),
    new DefaultCommandAuthentication()
};

builder.Services.AddControllers(options =>
{
    options.ModelBinderProviders.Insert(0, 
        new RequestCommandModelBinderProvider<IUserCommand>(authentications));
});

Data Validation

Use DataAnnotations for automatic validation:

public class CreateUserCommand : IUserCommand
{
    [Required(ErrorMessage = "Username is required")]
    [StringLength(50, MinimumLength = 3, 
        ErrorMessage = "Username must be between 3 and 50 characters")]
    public string Username { get; set; }

    [Required]
    [EmailAddress(ErrorMessage = "Invalid email address")]
    public string Email { get; set; }

    [MinLength(5, ErrorMessage = "FullName must be at least 5 characters")]
    public string FullName { get; set; }
}

Validation in Controller

[HttpPost]
public IActionResult ProcessCommand(IUserCommand command)
{
    if (!ModelState.IsValid)
    {
        var errors = ModelState.Values
            .SelectMany(v => v.Errors)
            .Select(e => e.ErrorMessage);

        return BadRequest(new { errors = errors });
    }

    // Process command
    return Ok();
}

Multiple Command Interfaces

You can register multiple command interfaces:

builder.Services.AddControllers(options =>
{
    var defaultAuth = new List<ICommandAuthentication>
    {
        new DefaultCommandAuthentication()
    };

    // Register different command interfaces
    options.ModelBinderProviders.Insert(0, 
        new RequestCommandModelBinderProvider<IUserCommand>(defaultAuth));
    options.ModelBinderProviders.Insert(1, 
        new RequestCommandModelBinderProvider<IOrderCommand>(defaultAuth));
    options.ModelBinderProviders.Insert(2, 
        new RequestCommandModelBinderProvider<IProductCommand>(defaultAuth));
});

Error Handling

The model binder provides informative error messages in ModelState:

Error Meaning
"no command." Request body is empty
"not valid json." Request body is not valid JSON
"Cant parse to object." JSON does not match the command type
"Unauthorized" User is not authenticated

Handle errors in your controller:

[HttpPost]
public IActionResult ProcessCommand(IUserCommand command)
{
    if (!ModelState.IsValid)
    {
        return BadRequest(new 
        { 
            errors = ModelState
                .Where(ms => ms.Value.Errors.Count > 0)
                .ToDictionary(
                    kvp => kvp.Key,
                    kvp => kvp.Value.Errors.Select(e => e.ErrorMessage))
        });
    }

    // Process command
    return Ok();
}

Complete Configuration Example

using ATK.Command.AspNet.ModelBinder;
using ATK.Command.AspNet.ModelBinder.CommandAuthentications;
using MyApp.Commands;

var builder = WebApplicationBuilder.CreateBuilder(args);

// Setup authentication
builder.Services.AddAuthentication("Bearer");
builder.Services.AddAuthorization();

// Configure model binder
var commandAuthentications = new List<ICommandAuthentication>
{
    new DefaultCommandAuthentication()
};

builder.Services.AddControllers(options =>
{
    // Register the command interface, not individual classes!
    options.ModelBinderProviders.Insert(0, 
        new RequestCommandModelBinderProvider<IUserCommand>(commandAuthentications));
    options.ModelBinderProviders.Insert(1, 
        new RequestCommandModelBinderProvider<IOrderCommand>(commandAuthentications));
});

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();

Best Practices

  1. Always register the interface - Not individual command classes

    // ✅ Correct
    new RequestCommandModelBinderProvider<IUserCommand>(auth)
    
    // ❌ Wrong
    new RequestCommandModelBinderProvider<CreateUserCommand>(auth)
  2. Include $type in JSON - The model binder needs this information

    {
      "$type": "MyApp.Commands.CreateUserCommand, MyApp",
      "username": "john"
    }
  3. Use pattern matching - For dispatching to the right handlers

    return command switch
    {
        CreateUserCommand cmd => Handle(cmd),
        UpdateUserCommand cmd => Handle(cmd),
        _ => BadRequest()
    };
  4. Always validate ModelState - Before processing the command

    if (!ModelState.IsValid)
        return BadRequest(ModelState);
  5. Authentication is required - Commands require authentication unless [AllowAnonymous] is set

    [AllowAnonymous]
    public class PublicCommand : IUserCommand { }

Requirements

  • .NET 10.0 or higher
  • ASP.NET Core 9.0 or higher
  • Dependencies: Newtonsoft.Json, Microsoft.AspNetCore.Mvc.Core

Made with ❤️ for ASP.NET Core developers

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages