Method validation

Method validation checks the arguments and the return value of a bean's methods on every call, using constraint annotations on the parameters and the method.

Why you need it

A service is called from many places: controllers, scheduled jobs, other services, tests. Checking its inputs in each caller is repetitive, and one caller will forget. With method validation the service states its rules once, on its own parameters, and every call is checked, whoever makes it.

You do not need it for objects you already validate on the way in, for example a @Valid() request body. You also do not need it inside private helper methods: only calls that go through the bean's proxy are checked.

How to turn it on

  1. Annotate the bean class with @Validated().
  2. Put constraints on parameters (they check the arguments) and on the method (they check the return value). Use @Valid() to check the constraints inside an argument or result object.
  3. Have a MethodValidationPostProcessor bean. With spring_boot_dart it is registered for you. With spring_core_dart alone, declare it yourself.
import 'package:spring_core_dart/spring_core_dart.dart';

// Callers cannot register an empty or malformed address,
// and the service never returns a blank id.
@Service()
@Validated()
class Registration {
  @NotBlank()
  String register(@Email() @NotBlank() String email) => 'id-$email';

  // @Valid() checks the constraints declared inside SignupForm.
  void signUp(@Valid() SignupForm form) {
    // ...
  }
}

// Without spring_boot_dart: the bean that applies method validation.
@Configuration()
class ValidationConfig {
  @Bean()
  MethodValidationPostProcessor methodValidationPostProcessor(
    BeanIntrospector introspector,
  ) => MethodValidationPostProcessor(BeanValidator(introspector: introspector));
}

Pass the context's BeanIntrospector to the BeanValidator: @Valid() arguments are application classes, and the default introspector does not know them.

What a violation looks like

When the arguments are invalid, the method does not run and the call throws a ConstraintViolationException. When the return value is invalid, the method has run, and the exception is thrown after it. For a method that returns a Future, the future completes with the exception.

const log = SpringLog('registration');

void main() {
  final context = ApplicationContext.run([appBeans]);
  final registration = context.getBean<Registration>();
  try {
    registration.register('');
  } on ConstraintViolationException catch (e) {
    log.warning(e.message); // register.email: must not be blank
    for (final v in e.constraintViolations) {
      log.info('${v.propertyPath} -> ${v.constraint.name}'); // register.email -> NotBlank
    }
  }
  context.close();
}

Property paths start with the method name: register.email for a parameter, register.<return value> for the result, signUp.form.name inside a @Valid() argument.

The web starter does not turn a ConstraintViolationException into a 400 response by itself. If a client can cause one, handle it in an @ExceptionHandler.

Groups

@Validated takes the groups to check. On the class it sets them for every method; on a method it sets them for that method only. A method-level @Validated does not turn validation on by itself: the class needs @Validated() too.

abstract final class OnUpdate {}

@Service()
@Validated()
class Users {
  // Only the constraints of the OnUpdate group are checked here.
  @Validated([OnUpdate])
  void update(@Valid() UserForm form) {
    // ...
  }
}

@Validated

Name Type Default Meaning
value (positional) List<Type> const [] Groups to check. Empty means only the Default group.

Groups are found on the method first, then on a declaration it overrides (the nearest first), then on the bean class.

Constraints on interfaces and superclasses

Constraints on the parameters and return value of an overridden declaration apply too: an implemented interface, a superclass, a mixin. Callers that only know the interface see the rules where they read them.

abstract interface class Accounts {
  String register(@NotBlank() String email);
}

@Service()
@Validated()
class DefaultAccounts implements Accounts {
  // The @NotBlank of the interface is checked on this method.
  @override
  String register(String email) => 'registered';
}

MethodValidationPostProcessor

The bean that applies method validation to every bean whose class is annotated @Validated(). Despite its name it is an advisor: proxies are generated at build time, and the context applies it like any other advisor.

Name Type Default Meaning
validator (positional, optional) BeanValidator? BeanValidator() The validator that checks arguments and results. Its message source and clock apply.
excludeFilters (positional, optional) List<bool Function(List<Object> classAnnotations)> const [] Tests on a bean's class annotations; a bean is skipped when one returns true.

Other members:

Member Meaning
advice The MethodValidationInterceptor that does the checks.
order Ordered.lowestPrecedence: validation runs innermost, after other advice such as caching.

spring_boot_dart registers one as methodValidationPostProcessor, using the application's BeanValidator and excluding @ConfigurationProperties classes. Declare your own to change the validator or the exclusions; Boot then backs off.

MethodValidationInterceptor is the advice itself. You only build one to apply method validation with your own pointcut.

Limits

In Spring (Java)

@Service
@Validated
class Registration {
  @NotBlank String register(@Email @NotBlank String email) { ... }
}

@Bean
static MethodValidationPostProcessor methodValidationPostProcessor() {
  return new MethodValidationPostProcessor();
}

Differences that matter:

See also