10

I've been trying to figure out what the best practice is for form submission with spring and what the minimum boilerplate is to achieve that.

I think of the following as best practise traits

  • Validation enabled and form values preserved on validation failure
  • Disable form re-submission F5 (i.e. use redirects)
  • Prevent the model values to appear in the URL between redirects (model.clear())

So far I've come up with this.

@Controller
@RequestMapping("/")
public class MyModelController {

    @ModelAttribute("myModel")
    public MyModel myModel() {
        return new MyModel();
    }

    @GetMapping
    public String showPage() {
        return "thepage";
    }

    @PostMapping
    public String doAction(
            @Valid @ModelAttribute("myModel") MyModel myModel,
            BindingResult bindingResult,
            Map<String, Object> model,
            RedirectAttributes redirectAttrs) throws Exception {
        model.clear();
        if (bindingResult.hasErrors()) {
            redirectAttrs.addFlashAttribute("org.springframework.validation.BindingResult.myModel", bindingResult);
            redirectAttrs.addFlashAttribute("myModel", myModel);
        } else {
            // service logic
        }
        return "redirect:/thepage";
    }
}

Is there a way to do this with less boilerplate code or is this the least amount of code required to achieve this?

Johan Sjöberg
  • 47,929
  • 21
  • 130
  • 148

3 Answers3

4

First, I wouldn't violate the Post/Redirect/Get (PRG) pattern, meaning I would only redirect if the form is posted successfully.

Second, I would get rid of the BindingResult style altogether. It is fine for simple cases, but once you need more complex notifications to reach the user from service/domain/business logic, things get hairy. Also, your services are not much reusable.

What I would do is pass the bound DTO directly to the service, which would validate the DTO and put a notification in case of errors/warning. This way you can combine business logic validation with JSR 303: Bean Validation. For that, you can use the Notification Pattern in the service.

Following the Notification Pattern, you would need a generic notification wrapper:

public class Notification<T> {
    private List<String> errors = new ArrayList<>();
    private T model; // model for which the notifications apply

    public Notification<T> pushError(String message) {
        this.errors.add(message);
        return this;
    }

    public boolean hasErrors() {
        return !this.errors.isEmpty();
    }

    public void clearErrors() {
        this.errors.clear();
    }

    public String getFirstError() {
        if (!hasErrors()) {
            return "";
        }
        return errors.get(0);
    }

    public List<String> getAllErrors() {
        return this.errors;
    }

    public T getModel() {
        return model;
    }

    public void setModel(T model) {
        this.model = model;
    }
}

Your service would be something like:

public Notification<MyModel> addMyModel(MyModelDTO myModelDTO){
    Notification<MyModel> notification = new Notification();
    //if(JSR 303 bean validation errors) -> notification.pushError(...); return notification;
    //if(business logic violations) -> notification.pushError(...); return notification;
    return notification;
}

And then your controller would be something like:

Notification<MyModel> addAction = service.addMyModel(myModelDTO);
if (addAction.hasErrors()) {
    model.addAttribute("myModel", addAction.getModel());
    model.addAttribute("notifications", addAction.getAllErrors());
    return "myModelView"; // no redirect if errors
} 
redirectAttrs.addFlashAttribute("success", "My Model was added successfully");
return "redirect:/thepage";

Although the hasErrors() check is still there, this solution is more extensible as your service can continue evolving with new business rules notifications.

Another approach which I will keep very short, is to throw a custom RuntimeException from your services, this custom RuntimeException can contain the necessary messages/models, and use @ControllerAdvice to catch this generic exception, extract the models and messages from the exception and put them in the model. This way, your controller does nothing but forward the bound DTO to service.

hooknc
  • 4,854
  • 5
  • 31
  • 60
isah
  • 5,221
  • 3
  • 26
  • 36
  • I like the suggestion of not breaking Post/Redirect/Get (PRG). However, I am personally not a fan of having validation occur in the service layer and having the service throw an exception seems to break the [Don't Use Exceptions For Flow Control](http://wiki.c2.com/?DontUseExceptionsForFlowControl) premise. Good thoughtful answer though. – hooknc Mar 23 '18 at 16:14
  • 1
    That was the second approach...I also favor the first one(with Notification Pattern which does not use Exceptions). I'm aware of Exception usage for control flow anti-pattern, but, in such cases, I would consider, as the pros outweigh the cons. Having zero boilerplate in controllers and a centralized handler for this "ValidationException" seems second best option to me. Regarding validation in the service layer, I mentioned reusability/business rule evolution, if you don't have it there, your services are hardly reusable. – isah Mar 23 '18 at 19:36
2

Based on the answer by @isah, if redirect happens only after successful validation the code can be simplified to this:

@Controller
@RequestMapping("/")
public class MyModelController {

    @ModelAttribute("myModel")
    public MyModel myModel() {
        return new MyModel();
    }

    @GetMapping
    public String showPage() {
        return "thepage";
    }

    @PostMapping
    public String doAction(
            @Valid @ModelAttribute("myModel") MyModel myModel,
            BindingResult bindingResult,
            RedirectAttributes redirectAttrs) throws Exception {
        if (bindingResult.hasErrors()) {
            return "thepage";
        }
        // service logic
        redirectAttrs.addFlashAttribute("success", "My Model was added successfully");
        return "redirect:/thepage";
    }
}
hooknc
  • 4,854
  • 5
  • 31
  • 60
Johan Sjöberg
  • 47,929
  • 21
  • 130
  • 148
  • I like your answer and do not totally know your domain or what your application is doing, but something to keep in mind is what to do if a database write error occurs, for whatever reason. We actually ignore this use case in our code (exactly like you're doing here), but if that is something that is important to your end users, you'll most likely need to add boilerplate code to handle that issue as well. Not super fun code to write, but it might be needed for what you're doing. – hooknc Mar 23 '18 at 16:18
1

One possible way is to use Archetype for Web forms, Instead of creating simple project, you can choose to create project from existing archetype of web forms. It will provide you with sufficient broiler plate code. You can also make your own archetype. Have a look at this link to get deeper insight into archetypes. Link To Archetypes in Java Spring

Rezwan
  • 1,203
  • 1
  • 7
  • 22
  • if archetype solves your problem, do let me know, I can help you in making custom archetype. – Rezwan Mar 19 '18 at 10:10
  • 2
    Thanks for your suggestion. I'm not looking to generate boilerplate but to find the minimum necessary boilerplate to a repetitive use case – Johan Sjöberg Mar 19 '18 at 12:27