DEV Community

Pascal Vorwerk
Pascal Vorwerk

Posted on

Validation in Blazor

Blazor and validation

In the previous post we took a look at setting up validation, using Data Annotations and IValidatableObject. In this blog we will take a look at how we can use this validation in a Blazor project. We will define a few rules/requirements to make the sample more clear:


  1. We will be simulating a real database, but we will not set up a real connection.
  2. We will not take into account security.
  3. We will disable prerendering for our Blazor application. Prerendering causes additional headaches for our pages, which are really out of scope for this blog post!

Domain and rules

Our domain is pretty simple! We will have a list of users, which we can retrieve and create! A user will look like so:

[Index(nameof(Email), IsUnique = true)]
public class User
{
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public Guid Id { get; set; }

    [StringLength(AppConstants.EmailMaxLength, MinimumLength = 0)]
    public string Email { get; set; } = null!;

    [StringLength(AppConstants.NameMaxLength, MinimumLength = 0)]
    public string Name { get; set; } = null!;

    [StringLength(AppConstants.SurnameMaxLength, MinimumLength = 0)]
    public string Surname { get; set; } = null!;

    [Range(AppConstants.AgeMinValue, AppConstants.AgeMaxValue)]
    public int Age { get; set; }
}

public class AppConstants
{
    public const int EmailMaxLength = 256;
    public const int NameMaxLength = 50;
    public const int SurnameMaxLength = 50;
    public const int AgeMaxValue = 120;
    public const int AgeMinValue = 0;
}
Enter fullscreen mode Exit fullscreen mode

Note that we have defined Data annotations on these properties. These annotations will usually help define constraints for tools like Entity framework for the database. This is different per tool, but it is usually something you want to look at when working with ORM's.


We can define some of the following validation/business rules when we look at our User model:

  • We need to adhere to the length constraints that are defined by the model, otherwise we will likely get some database exceptions!
  • There is a unique index on the email, makes sense, however we would ideally deal with this before we reach the database and catch some nasty exceptions.
  • None of the properties are nullable, which means each property needs a value.

The request

Looking at these rules and constraints, we can define a model that we will receive when a user wants to create a user:

public class CreateUserRequest : IValidatableObject
{
    [Required]
    [StringLength(AppConstants.EmailMaxLength, MinimumLength = 0)]
    [EmailAddress]
    public string Email { get; set; } = null!;

    [Required]
    [StringLength(AppConstants.NameMaxLength, MinimumLength = 0)]
    public string Name { get; set; } = null!;

    [Required]
    [StringLength(AppConstants.SurnameMaxLength, MinimumLength = 0)]    
    public string Surname { get; set; } = null!;

    [Required]
    [Range(AppConstants.AgeMinValue, AppConstants.AgeMaxValue)]
    public int Age { get; set; }

    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        // How do we make sure the email is unique?

        return new List<ValidationResult>();
    }
}
Enter fullscreen mode Exit fullscreen mode

Some things to see in the previous code:

  • We can use the build-in [EmailAddress] enum to make sure the Emailaddress is an emailaddress.
  • We have to specify each property is required for validation.
  • We haven't dealt with the unique email address rule!

How do we deal with that last point? To check whether a email is unique, we will need to reach our 'database'.


One place for all business logic

Luckily, using the validation context in the validate method, we can access any service provider that was given to the validation context! In our ValidationFilter, the one we made in the previous blog, we do the following:

        var argument = context.Arguments.OfType<T>().FirstOrDefault();

        if (argument == null)
        {
            return Results.BadRequest("Invalid request payload");
        }

        var validationContext = new ValidationContext(argument, serviceProvider: context.HttpContext.RequestServices, items: null);
        var validationResults = new List<ValidationResult>();
Enter fullscreen mode Exit fullscreen mode

This adds the service provider that is known to the Http request pipeline to the validation context. This custom validation filter is something that is not needed anymore for dotnet 10, and in dotnet 10 any validation context should already contain the service collection needed!

This now means we can supply any service or database we have on the server side to the method!


The user service

Our user service is really simpel, as we are not REALLY going to a database. This could easily be replaced with any EF Core repository pattern:

public interface IUserService
{
    Task<List<GetUserResponse>> GetUsersAsync();
    Task<GetUserResponse> GetUserAsync(Guid id);
    Task<GetUserResponse?> GetUserByEmailAsync(string email);
    Task<Guid> CreateUserAsync(CreateUserRequest request);
}

public class UserService : IUserService
{
    public Task<List<GetUserResponse>> GetUsersAsync()
    {
        var users = UsersDatabase.GetUsers()
            .Select(x => new GetUserResponse(x.Id, x.Email, x.Name, x.Surname, x.Age))
            .ToList();

        return Task.FromResult(users);
    }

    public Task<GetUserResponse> GetUserAsync(Guid id)
    {
        var user = UsersDatabase.GetUser(id);

        if (user == null)
            throw new Exception("User not found");

        var userResponse = new GetUserResponse(user.Id, user.Email, user.Name, user.Surname, user.Age);

        return Task.FromResult(userResponse);
    }

    public Task<GetUserResponse?> GetUserByEmailAsync(string email)
    {
        var user = UsersDatabase.GetUsers().FirstOrDefault(x => x.Email == email);

        return Task.FromResult(user != null ? new GetUserResponse(user.Id, user.Email, user.Name, user.Surname, user.Age) : null);
    }

    public Task<Guid> CreateUserAsync(CreateUserRequest request)
    {
        var user = new User
        {
            Id = Guid.NewGuid(),
            Email = request.Email,
            Name = request.Name,
            Surname = request.Surname,
            Age = request.Age
        };

        UsersDatabase.AddUser(user);

        return Task.FromResult(user.Id);
    }
}
Enter fullscreen mode Exit fullscreen mode

The new validation method

Now that we have defined our new user service, we can use it in our validation context service provider like so:

public class CreateUserRequest : IValidatableObject
{
    [Required]
    [StringLength(AppConstants.EmailMaxLength, MinimumLength = 0)]
    [EmailAddress]
    public string Email { get; set; } = null!;

    [Required]
    [StringLength(AppConstants.NameMaxLength, MinimumLength = 0)]
    public string Name { get; set; } = null!;

    [Required]
    [StringLength(AppConstants.SurnameMaxLength, MinimumLength = 0)]    
    public string Surname { get; set; } = null!;

    [Required]
    [Range(AppConstants.AgeMinValue, AppConstants.AgeMaxValue)]
    public int Age { get; set; }

    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        // The userService is resolved on the server side, from the Service provider.
        var userService = validationContext.GetService<IUserService>();

        // Check if the email already exists
        var emailExists = userService?.GetUserByEmail(Email);

        if (emailExists != null)
        {
            yield return new ValidationResult("Email already exists", [nameof(Email)]);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

As you can see, we are explicitly making sure the user service is available! This is because with Blazor, the validation can be performed on both the client and the server!

We only want to perform the email 'unique' check whenever we have reached the server and the user service is available!!

Note: I have tried implementing a client side user service that performs an API call to check for email uniques on the server, however, the current implementation of IValidateableObject is not compatible with async calls.. This would be a great feature to add I think!

With this approach, any client side validation that can happen before we reach the server will happen, but also any validation that can only happen once we reach the server can also happen! Now the next step.. how can we use this in Blazor and EditForms??


Using the validation in Blazor

Using the validation we have defined on our request in a Blazor is pretty simple, although it does require some setting up.

First, we must setup the razor components to receive our input. We can use the default components blazor provides for this!

<div style="display: flex; align-items: center; flex-direction: column; width: 100%">
    <h3 class="text-xl font-bold mb-4">Create User</h3>

    <div style="width: 500px;">
        <EditForm EditContext="@_editContext" OnValidSubmit="HandleValidSubmit">
            <DataAnnotationsValidator />

            <div class="mb-3">
                <label>Email:</label>
                <InputText @bind-Value="_user.Email" class="form-control" />
                <ValidationMessage For="@(() => _user.Email)" />
            </div>

            <div class="mb-3">
                <label>Name:</label>
                <InputText @bind-Value="_user.Name" class="form-control" />
                <ValidationMessage For="@(() => _user.Name)" />
            </div>

            <div class="mb-3">
                <label>Surname:</label>
                <InputText @bind-Value="_user.Surname" class="form-control" />
                <ValidationMessage For="@(() => _user.Surname)" />
            </div>

            <div class="mb-3">
                <label>Age:</label>
                <InputNumber @bind-Value="_user.Age" class="form-control" />
                <ValidationMessage For="@(() => _user.Age)" />
            </div>

            <button class="btn btn-primary" type="submit">Submit</button>
        </EditForm>
    </div>
</div>

@code {
    private readonly CreateUserRequest _user = new();
    private EditContext? _editContext;
    private ValidationMessageStore? _messageStore;

    protected override void OnInitialized()
    {
        _editContext = new EditContext(_user);
        _messageStore = new ValidationMessageStore(_editContext);
    }

    private async Task HandleValidSubmit()
    {
        // Clear previous messages
        _messageStore?.Clear();
        _editContext?.NotifyValidationStateChanged();

        var response = await HttpClient.PostAsJsonAsync("api/users", _user);

        if (response.IsSuccessStatusCode)
        {
            var result = await response.Content.ReadFromJsonAsync<Guid>();
            Console.WriteLine($"User created with ID: {result}");
            return;
        }

        if (response.StatusCode == HttpStatusCode.BadRequest)
        {
            var content = await response.Content.ReadAsStringAsync();

            // Minimal inline deserialization to avoid referencing ASP.NET Core types
            var problemDetails = System.Text.Json.JsonSerializer.Deserialize<ClientValidationProblemDetails>(content,
                new System.Text.Json.JsonSerializerOptions
                {
                    PropertyNameCaseInsensitive = true
                });

            if (problemDetails?.Errors is not null)
            {
                foreach (var (field, messages) in problemDetails.Errors)
                {
                    var fieldIdentifier = new FieldIdentifier(_user, field);

                    foreach (var message in messages)
                    {
                        _messageStore?.Add(fieldIdentifier, message);
                    }
                }

                _editContext?.NotifyValidationStateChanged();
            }

            return;
        }

        // Handle other errors
        var errorContent = await response.Content.ReadAsStringAsync();
        Console.Error.WriteLine($"Unexpected error: {(int)response.StatusCode} - {errorContent}");
    }
}
Enter fullscreen mode Exit fullscreen mode

In this code, we are using the default DataAnnotationsValidator to perform the validation on our fields and when the form is submitted. We will show a validation message for each field below the input of that field!

Great! Let's look at the result and what happens if we type in some wrong inputs!

User validation with wrong email and wrong age

Great! This works as expected! Now, let's see what happens when we create a new user with valid input.

Correct user input and validation

This results in a nice response with the new user id as a result.

Response code
User ID

Our form doesn't really handle the correct response at all, it simply logs the user ID. Normally, you would likely show some result message and navigate to the newly created user!

However, let's make sure we can't add this user twice! The user is now added to our static list (or database), so the server should return a error whenever we submit the same email. Let's check.

Duplicate email result

Duplicate email response

As you can see, the server returned a validation problem details and we correctly parsed it to add the validation problems to our message store!

Now we can use all of our validation logic, that is defined in one place, in both Blazor AND our API!

Summary

In this post, we:

  • Reused our shared validation logic (Data Annotations + IValidatableObject) in both Web API's and Blazor.

  • Showed how to access IServiceProvider in ValidationContext to perform server-side checks (like email uniqueness).

  • Demonstrated how to display server-side validation errors in a Blazor EditForm using ValidationMessageStore.

All the code for this example is available in the (ValidationBlazor folder on GitHub)[https://github.com/PascalVorwerk/blogs/tree/main/ValidationBlazor].

Feel free to explore the code, ask questions, and suggest improvements!!

Top comments (0)