Solon Validation: @Valid, 20+ Annotations, and Custom Validators Without the JSR 380 Baggage

작성자

카테고리:

← 피드로
DEV Community · Solon Framework · 2026-07-31 개발(SW)

I’ve been burned by JSR 380 enough times to appreciate a validation framework that doesn’t drag in a whole ecosystem of dependencies. Solon’s validation system lives in solon-security-validation — a single plugin that gives you 20+ annotations, entity validation, and custom validators, all without pulling in javax.validation or jakarta.validation.

Let me walk through what I’ve found useful.

The Two-Phase Validation Model

Solon separates validation into two phases:

  1. Context validation (pre-injection) — runs before method parameters are resolved. Annotations on the method itself check headers, cookies, IP, or request-level constraints.
  2. Parameter validation (post-injection) — runs after parameters are resolved. Annotations on individual parameters or entity fields.

This maps to two different implementation mechanisms:

  • Context validation uses @Addition(Filter.class) under the hood
  • Parameter validation uses @Around(Interceptor.class)

You don’t need to think about this most of the time, but it explains why some annotations go on the method and others on the parameter.

Getting Started: @valid on a Controller

Add @Valid to your controller class (or its base class), then annotate the parameters:

@Valid
@Controller
public class UserController {

    @Mapping("/user/add")
    public void addUser(
            @NotNull String name,
            @Email String email,
            @Pattern("^https?://") String avatarUrl,
            @Length(min = 6, max = 20) String password) {
        // All parameters validated before this runs
    }
}

Enter fullscreen mode Exit fullscreen mode

That’s it. No need to configure a ValidatorFactory or declare a bean. @Valid on the class enables validation for all handler methods.

@Validated for Entity (Nested) Validation

When your parameter is a DTO with its own validation rules, use @Validated:

@Valid
@Controller
public class UserController {

    @Mapping("/user/register")
    public void register(@Validated RegisterRequest req) {
        // req fields are validated
    }
}

@Data
public class RegisterRequest {
    @NotNull
    @Length(min = 2, max = 50)
    private String name;

    @Email
    @NotNull
    private String email;

    @Validated  // Nested entity validation
    @NotNull
    @Size(min = 1)
    private List<Order> orderList;
}

Enter fullscreen mode Exit fullscreen mode

Group Validation

For update vs create scenarios, use groups:

public interface UpdateGroup {}

@Data
public class User {
    @NotNull(groups = UpdateGroup.class)  // Only required on update
    private Long id;

    @NotNull
    private String name;
}

// Controller
@Valid
@Controller
public class UserController {
    @Mapping("/user/update")
    public void update(@Validated(UpdateGroup.class) User user) {
        // Only validates fields in UpdateGroup
    }
}

Enter fullscreen mode Exit fullscreen mode

The 20+ Annotations in One Table

Solon’s validation plugin ships with a comprehensive set of annotations. Here’s the full list:

Annotation Scope Purpose @Valid Controller class Enable validation @Validated Parameter/field Validate entity fields @NotNull Method/param/field Not null @Null Method/param/field Is null @NotBlank Method/param/field Not blank string @NotEmpty Method/param/field Not empty string @NotZero Method/param/field Not zero @Min(value) Parameter/field >= value @Max(value) Parameter/field <= value @DecimalMin(value) Parameter/field >= decimal value @DecimalMax(value) Parameter/field <= decimal value @Length(min, max) Parameter/field String length range @Size Parameter/field Collection size range @Email Parameter/field Email format @Pattern(value) Parameter/field Regex match @Date Parameter/field Date format @Numeric Parameter/field Numeric format @Logined Controller/method User is logged in @NoRepeatSubmit Controller/method No duplicate submission @Whitelist Controller/method IP in whitelist @NotBlacklist Controller/method IP not in blacklist

A few of these (@Logined, @NoRepeatSubmit, @Whitelist, @NotBlacklist) are “business-aware” — they need you to implement a checker interface. More on that below.

Validation on Non-Web Components

This surprised me: Solon’s validation also works on plain @Component classes, not just controllers:

@Valid
@Component
public class UserService {
    public void addUser(@NotNull String name, @Email String email) {
        // Validation works here too
    }
}

Enter fullscreen mode Exit fullscreen mode

Manual Validation with ValidUtils

You can also validate programmatically anywhere:

User user = new User();
user.setName(null);
ValidUtils.validateEntity(user);  // throws ValidatorException if invalid

Enter fullscreen mode Exit fullscreen mode

Handling Validation Errors

Catch ValidatorException in a filter:

@Component
public class ValidationFilter implements Filter {
    @Override
    public void doFilter(Context ctx, FilterChain chain) throws Throwable {
        try {
            chain.doFilter(ctx);
        } catch (ValidatorException e) {
            ctx.render(Result.failure(e.getCode(), e.getMessage()));
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

By default, validation stops at the first error. If you want to collect all errors, set:

solon.validation.validateAll: true

Enter fullscreen mode Exit fullscreen mode

Business-Aware Validators (The Four Checkers)

Four annotations need you to provide a checker implementation. This is where Solon’s design shines — the framework handles the interception, you just plug in the business logic.

@NoRepeatSubmit

@Component
public class NoRepeatSubmitCheckerImpl implements NoRepeatSubmitChecker {
    @Override
    public boolean check(NoRepeatSubmit anno, Context ctx,
                         String submitHash, int limitSeconds) {
        return LockUtils.tryLock(Solon.cfg().appName(), submitHash, limitSeconds);
    }
}

Enter fullscreen mode Exit fullscreen mode

@Whitelist

@Component
public class WhitelistCheckerImpl implements WhitelistChecker {
    @Override
    public boolean check(Whitelist anno, Context ctx) {
        String ip = ctx.realIp();
        return CloudClient.list().inListOfIp("whitelist", ip);
    }
}

Enter fullscreen mode Exit fullscreen mode

@NotBlacklist

@Component
public class NotBlacklistCheckerImpl implements NotBlacklistChecker {
    @Override
    public boolean check(NotBlacklist anno, Context ctx) {
        String ip = ctx.realIp();
        return !CloudClient.list().inListOfIp("blacklist", ip);
    }
}

Enter fullscreen mode Exit fullscreen mode

@Logined

@Component
public class LoginedCheckerImpl implements LoginedChecker {
    @Override
    public boolean check(Logined anno, Context ctx, String userKeyName) {
        return ctx.sessionAsLong("userId") > 0;
    }
}

Enter fullscreen mode Exit fullscreen mode

Custom Validators: Creating Your Own Annotation

The framework is designed to be extended. Here’s a complete example of creating a custom @Phone validator:

Step 1: Define the annotation

@Target({ElementType.PARAMETER, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Phone {
    String message() default "Invalid phone number";
    Class<?>[] groups() default {};
}

Enter fullscreen mode Exit fullscreen mode

Step 2: Implement the validator

public class PhoneValidator implements Validator<Phone> {
    private static final Pattern PHONE_PATTERN =
        Pattern.compile("^1[3-9]\\d{9}$");

    @Override
    public String message(Phone anno) {
        return anno.message();
    }

    @Override
    public Class<?>[] groups(Phone anno) {
        return anno.groups();
    }

    @Override
    public Result validateOfValue(Phone anno, Object val, StringBuilder tmp) {
        if (val == null) return Result.succeed();
        if (val instanceof String == false) return Result.failure();
        if (PHONE_PATTERN.matcher((String) val).matches()) {
            return Result.succeed();
        }
        return Result.failure();
    }

    @Override
    public Result validateOfContext(Context ctx, Phone anno,
                                    String name, StringBuilder tmp) {
        String val = ctx.param(name);
        if (val == null) return Result.succeed();
        if (PHONE_PATTERN.matcher(val).matches()) {
            return Result.succeed();
        }
        return Result.failure(name);
    }
}

Enter fullscreen mode Exit fullscreen mode

Step 3: Register it

@Configuration
public class ValidationConfig {
    @Bean
    public void registerValidators() {
        ValidatorManager.register(Phone.class, new PhoneValidator());
    }
}

Enter fullscreen mode Exit fullscreen mode

Step 4: Use it

@Valid
@Controller
public class UserController {
    @Mapping("/user/phone")
    public void setPhone(@Phone String phone) {
        // Validated
    }
}

Enter fullscreen mode Exit fullscreen mode

What I’d Change

Two things:

  1. No built-in @Phone or @URL — common ones you’d expect. Easy to write yourself, but would be nice as pre-built options.
  2. The @Validated annotation name — it does something different from both JSR 380’s @Valid and Spring’s @Validated. In Solon, @Validated is specifically for triggering entity field validation on a parameter. It’s fine once you know, but the naming overlap with JSR 380 caused me a moment of confusion.

Bottom Line

Solon’s validation is a complete, self-contained system. You get 20+ annotations out of the box, entity validation, group validation, and a clean extension point for custom validators — all without a single javax.validation or jakarta.validation dependency.

The business-aware annotations (@Whitelist, @NoRepeatSubmit, @Logined) are the standout feature. They let you enforce security and idempotency constraints at the validation layer rather than scattering checks throughout your handlers.

For a project that wants to keep its dependency graph lean, Solon’s validation is a solid choice.

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다