This guide explains how to use the Model Binder to automatically bind JSON command objects from HTTP requests based on a command interface.
dotnet add package ATK.Command.AspNet.ModelBinderThe 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
$typein JSON
Create an interface that all commands will implement:
namespace MyApp.Commands
{
public interface IUserCommand
{
// All commands implementing IUserCommand can be bound
}
}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; }
}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();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" });
}
}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
}'To allow a command without authentication, use the [AllowAnonymous] attribute:
[AllowAnonymous]
public class PublicSearchCommand : IUserCommand
{
public string Query { get; set; }
}Use the [Authorize] attribute to require specific roles:
[Authorize(Roles = "Admin")]
public class DeleteUserCommand : IUserCommand
{
[Required]
public int UserId { get; set; }
}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; }
}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));
});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; }
}[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();
}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));
});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();
}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();-
Always register the interface - Not individual command classes
// ✅ Correct new RequestCommandModelBinderProvider<IUserCommand>(auth) // ❌ Wrong new RequestCommandModelBinderProvider<CreateUserCommand>(auth)
-
Include $type in JSON - The model binder needs this information
{ "$type": "MyApp.Commands.CreateUserCommand, MyApp", "username": "john" } -
Use pattern matching - For dispatching to the right handlers
return command switch { CreateUserCommand cmd => Handle(cmd), UpdateUserCommand cmd => Handle(cmd), _ => BadRequest() };
-
Always validate ModelState - Before processing the command
if (!ModelState.IsValid) return BadRequest(ModelState);
-
Authentication is required - Commands require authentication unless
[AllowAnonymous]is set[AllowAnonymous] public class PublicCommand : IUserCommand { }
- .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