Service Objects

Objectify Your Forms: Beyond Basic User Input

Objectify Your Forms: Beyond Basic User Input

by Danny Olson

In the video titled "Objectify Your Forms: Beyond Basic User Input," Danny Olson presents an insightful discussion on enhancing form management in Rails applications. The core topic focuses on transitioning from Rails defaults, particularly the accepts_nested_attributes_for method, to a more maintainable and organized approach using form objects. The speaker emphasizes how user input in web applications can become complex when managing multiple associations, which can lead to unmanageable code and maintenance difficulties.

Key Points Discussed:

- Complexity of User Input: Forms in web applications typically start simple but can become complicated, especially when dealing with CRUD operations that involve multiple database tables.

- Rails Defaults: While methods like accepts_nested_attributes_for simplify coding efforts, they introduce substantial magic that can lead to confusion and debugging challenges. The speaker cites how this method has received extensive attention on Stack Overflow, highlighting its common pitfalls.

- Single Responsibility Principle: The talk advocates for considering the single responsibility principle to prevent Rails models from accumulating too many responsibilities, which often leads to long, complex classes.

- Form Objects: Olson introduces form objects as a solution, allowing for better encapsulation of input logic and separation of concerns. This shift helps in maintaining a clearer and smaller public interface for handling user input.

- Implementation Example: The speaker illustrates a case where they create an online meme-based ice cream ordering service to demonstrate the differences between using Rails defaults versus implementing form objects. They explain how, with form objects, responsibilities are distributed more logically.

- Validation Logic: The speaker asserts that validations can still be context-specific within form objects without overwhelming the main model with responsibility and complexity.

- Reduced Magic and Better Testing: Utilizing form objects leads to reduced 'magic', simpler testing mechanisms, and a more maintainable codebase, especially when forms frequently change.

- Conclusion on Usage: Olson concludes by advising developers to consider implementing form objects when managing multiple Active Record models, regardless of the form's complexity. He also presents alternative tools like the RedTape gem and Active Form Rails gem for handling form behavior.

Overall, Danny Olson's presentation underscores the importance of using form objects to enhance clarity and maintainability in Rails applications, steering developers away from the pitfalls of Rails defaults and advocating for a more structured approach to form management.

00:00:13.679 Is this thing on? Now, alright! Hello everyone. Today we're going to discuss the Rails defaults. It's important to note that they are not always the best way to go. This will be a consistent theme throughout the previous talks you've heard. My name is Danny Olson and I am from San Francisco. I work for Sharethrough, an in-feed advertising exchange. We're hiring, so if you want to move to San Francisco, come talk to me.
00:00:22.119 A little background: forms are common in web applications. They often start off simple but can quickly become complicated. We often end up saving data to multiple database tables, and Rails provides us with some defaults to handle this. The `accepts_nested_attributes_for` method can reduce the amount of code we need to write, but it introduces a lot of magic, which can lead to confusion.
00:00:34.800 As of now, there are over 5,000 results on Stack Overflow for this one method. If you look right now, it’s probably even greater. The point of this talk is that we don’t always have to do things the Rails way. We are going to cover some similar ideas discussed before, specifically Adam's talk yesterday, but we are going to go more in-depth on form objects.
00:01:09.479 Let's look at an example. First, we all like ice cream and memes; the Doge meme happens to be one of my favorites. In our scenario, our 20-year-old Silicon Valley startup founder says that this is the next billion-dollar business. We're going to create an online meme-based ice cream ordering service so customers can order ice cream, choose a flavor, decide how many scoops they want, how many toppings to add, and of course, select some memes to go along with their ice cream. We'll set the price based on the number of scoops; this is our monetization strategy. Here's a quick class diagram to illustrate some domain objects and their relationships, but we will mainly focus on the ice creams and memes.
00:01:39.840 If we implement the Rails default way, it would probably look something like this. We have our Ice Cream controller, where we save the ice cream, and then create a default set of memes to streamline things. This is a basic workflow that all Rails developers are familiar with. If we look at the markup, once a customer creates their order, they can edit their form. In this case, the parent model is the ice cream, while the memes are the nested children. By using `accepts_nested_attributes_for`, we're able to use some nice Rails helpers to facilitate this process. However, we then get into our Ice Cream model, which has many responsibilities; it sets up the nested attribute relationship, calculates the price every time it's saved, modifies user input, and validates the data.
00:02:56.440 Let's take a closer look at each of these responsibilities. Setting up the nested attribute relationship and associated model requires a significant amount of code, which does a lot of work behind the scenes. While this is great, there is a lot of magic involved, and it can be challenging to debug. We also need to know the specifics of the associated model. Since the customer can edit the number of ice cream scoops, we need to recalculate the price each time we save the order. Throughout this process, we also want to modify and clean up some of the data before saving it.
00:03:49.000 For instance, we ensure that the chosen toppings exist to prevent customers from selecting something that we never offer, like pineapple. We also need default validations and some custom validations. This setup using Rails defaults may feel familiar, but let's consider the single responsibility principle, which states that every class should have one and only one reason to change. Looking at our Ice Cream model, we realize it must format and save the data, check the values of the associated objects using `accepts_nested_attributes_for`, and perform validations. This is too much responsibility.
00:04:17.720 This scenario occurs quite often. The Code Climate blog has a nice post that discusses how early on, the single responsibility principle is easy to apply; Active Record classes handle persistence and associations, but over time, they can grow to take on additional business logic. A year or two later, you may find yourself with a class that has over 500 lines of code and hundreds of methods in its public interface. Rails encourages this kind of coding style, which can lead to major maintenance headaches.
00:05:27.000 Evan Light points out that `accepts_nested_attributes_for` is used in Active Record classes to reduce the amount of code needing to be written for creating and updating records across multiple tables in Rails applications. However, this convention-driven approach can result in brittle code. Let's add a feature and see how our current code handles it. Right now, we want to change our monetization strategy to base the price off both the memes and the number of scoops. We’re moving from our original implementation to a new one, which will mean summing the meme costs after the ice cream has been saved.
00:06:35.479 In the `set_price` method, we have to include more logic as it will overwrite previous attempts to modify prices. While there is not a lot of new code, this demonstrates how Active Record models can grow and acquire too many responsibilities. However, everything is going to be okay. A form object encapsulates context-specific logic for user input. It has only the attributes displayed in the form, allowing us to keep our public API small. We can set up our own data, validate it, and delegate persistence without needing to know the underlying specifics.
00:07:04.000 The form object operates within one context, which reduces the amount of business logic it needs to understand at any given time. Let’s perform a second implementation using a form object and see what that looks like. Here, we’ll use a gem, familiar to everyone, which will add some attribute-like functionality, allowing our form to behave similarly to an Active Record class. We’ll also include Active Model for validations.
00:07:35.760 The validations remain the same. So, in this case, the object behaves like an Active Record class. This workflow is quite straightforward if the user input is valid. We save the data, knowing that it is valid within this context. The Active Record models can trust that it will save successfully. Additionally, we’ll notice that we can use a service object to handle more complex persistence logic, simplifying the form object, as it doesn’t need to know how to save user input.
00:08:10.279 What about the associated meme attributes? We can implement a nested form in a similar manner by setting up attributes and validations for editing the ice cream order. Here, we can create another form, as it operates in a different context and thus requires a different setup and input handling. We can streamline the similarities between the create and edit forms, focusing on the unique aspects of each.
00:08:44.240 As a result, we renamed our controller from Ice Creams to Orders since we're working with one conceptual order that comprises many domain objects. Rails encourages us to treat each Active Record model as a domain object, which is why it's easy to end up with business logic within them. By creating another object, we shift away from that approach and gain a clearer understanding of the application domain. This is also more RESTful, as we’re now working with an order resource rather than an ice cream resource.
00:09:12.000 While the controller flow remains the same, we lose some of the out-of-the-box Rails magic. However, we can still create a custom form builder or utilize the Rails helpers available. In this model, we are merely establishing the relationships and handling persistence. Since the form object acts as a gatekeeper to its underlying Active Record models, we do not need validations in both places.
00:09:45.359 By the time we save user input, it is already valid. This approach is easier to understand and test, preventing our form class from becoming a god object or junk drawer. Now, let’s revisit our new feature request with the form object implementation. We are currently moving from our original implementation with the form object towards a more complex version.
00:10:13.680 We are adding new functionality to another service object since we have moved away from the Rails default mindset. It becomes significantly easier to think about how to incorporate an additional service object once we have our initial form and service objects set up. As a bonus, this new service object can be reused when updating as well.
00:10:57.280 So what are some of the benefits? We have a clearer domain model, significantly less magic, and simplicity in testing. We do not need to depend on integration tests, which are known for being unreliable for testing user input. Additionally, we can precisely focus on the specific context of our code, which is self-contained and utilizes encapsulation, making forms easier to maintain and modify.
00:11:39.679 Forms frequently change, so they need to be straightforward to modify. However, it's not always clear when to implement a form object instead of using Rails defaults. Here’s a rule to remember: we can use a form object when we’re handling multiple Active Record models. It’s an easy principle to keep in mind.
00:12:01.440 Now, what options do we have? We have the RedTape gem and Active Form Rails gem, both of which provide similar functionalities. I encourage you to look into them, as nothing about their implementation is particularly complicated. Our online meme-based ice cream ordering service is a big success now, and we just need to wait for that acquisition.
00:12:56.000 Hi, so I’m concerned about moving validations to my form object instead of keeping them in the model. I'm worried that I might make a mistake in my background task, for example, and then end up inserting invalid data into the database. How can I protect against that?" "That's definitely a valid concern. You don't necessarily have to move all validations out. You could also set up constraints in the database to help ensure bad data isn't entered. In terms of user input, using the form object allows for context-specific validations. If you have application-wide validations needed, you can keep those in the model.
00:16:00.000 In your presentation, I noticed the form is responsible for persistence by calling some other objects inside to save the data. However, shouldn't this be reversed? Shouldn't the service object take the form instead of the form calling the service object?" "That’s certainly another perspective! The delegation to persistence has worked well for me, but there are certainly multiple approaches to achieve this. Adam provided a thorough explanation about that in his talk, so I recommend checking that out." "Hello! I wanted to know if you would use the form object technique for trivial forms or only for more complex ones." "Absolutely, you can use it for trivial forms as well. In some cases, even simple forms can grow complex over time. Rails does offer rapid development capabilities, and the defaults are effective in specific contexts, particularly when dealing with a single model.
00:17:29.000 You mentioned that the form objects are context-specific, but is there a way to reuse the code of some forms? Having a new order and an edit order form seems to create a lot of duplicated code.