Skip to content

Adding validation

Validate a command with [Validate] and IValidationTarget, run through ValidationBehavior.

2 min read

Immediate.Validations validates a request before the handler runs, as a behavior in the pipeline. You annotate properties with validator attributes; the generator writes the validation method.

A command that needs validating

Features/CreateTodoCommand.cs
using Immediate.Handlers.Shared;
using Immediate.Validations.Shared;

namespace Todo.Features;

[Handler]
public sealed partial class CreateTodoCommand(TodoRepository repository)
{
	[Validate]
	public sealed partial record Command : IValidationTarget<Command>
	{
		[NotEmpty]
		[MaxLength(200)]
		public required string Title { get; init; }
	}

	public sealed record Response(TodoItem Item);

	private ValueTask<Response> HandleAsync(
		Command command,
		CancellationToken token
	) => ValueTask.FromResult(new Response(repository.Add(command.Title)));
}

Three pieces make this work:

  • [Validate] tells the generator to emit a validation method for this type.
  • partial — the generated method is added to your type.
  • IValidationTarget<Command> is what the behavior looks for at compile time. Leaving it off produces IV0013, with a code fix that adds it.

[NotEmpty] and [MaxLength(200)] are two of fifteen built-in validators — see the full list.

Register the behavior

Validation runs because ValidationBehavior<,> is in the pipeline. Add it assembly-wide:

Program.cs
[assembly: Behaviors(typeof(ValidationBehavior<,>))]

Put that at the top of Program.cs, above the top-level statements, or in an AssemblyInfo.cs. Then register the behaviors:

Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSingleton<TodoRepository>();
builder.Services.AddTodoBehaviors();
builder.Services.AddTodoHandlers();

What a failure looks like

ValidationBehavior<,> throws ValidationException when the request is invalid. The handler method never runs.

Program.cs
app.MapPost("/", async (
	string title,
	CreateTodoCommand.Handler handler,
	CancellationToken token) =>
{
	try
	{
		var response = await handler.HandleAsync(new() { Title = title }, token);
		return Results.Ok(response.Item);
	}
	catch (ValidationException ex)
	{
		return Results.BadRequest(ex.Errors);
	}
});

Each entry in ex.Errors is a ValidationError carrying the property name and the rendered message:

[{ "PropertyName": "Title", "ErrorMessage": "'Title' must not be empty." }]

Property names use a dotted path for nested types and an indexed suffix for collections — Items[0].Title. The exact rules matter if you’re rendering field-level errors in a UI; they’re spelled out in Nested and collection validation.

Catching the exception per-endpoint like this is fine for now. The next page replaces it with proper ProblemDetails wiring that covers every endpoint at once.

Validating without a handler

The generated method is also callable directly, which is handy in tests:

var errors = CreateTodoCommand.Command.Validate(command);
if (!errors.IsValid)
{
	// inspect errors
}

See Validating instances for the ThrowIfInvalid overloads.

What you have

Requests validated before your code runs, with the validation logic generated from attributes and no validator classes to maintain.

Up next

Exposing an HTTP endpoint — all four handlers become minimal-API endpoints, and validation failures turn into a proper 400.