Let’s Extract a Class: The Single Responsibility Principle and Design Patterns

Summarized using AI

Let’s Extract a Class: The Single Responsibility Principle and Design Patterns

Jon Evans • October 26, 2023 • Boulder, CO • Talk

This video, titled "Let’s Extract a Class: The Single Responsibility Principle and Design Patterns," presented by Jon Evans at the Rocky Mountain Ruby 2023 event, focuses on the importance of the Single Responsibility Principle (SRP) in software design and code organization. The talk emphasizes how SRP aids in creating readable, maintainable code and discusses various design patterns that help encapsulate logic more efficiently rather than accumulating responsibilities in models or controllers.

Key points discussed include:

  • Rails Application Structure: The default MVC (Model-View-Controller) architecture in Rails is introduced, which serves as a foundation but can become complex as applications scale.

    • Controller Responsibilities: The initial method for user sign-up within a controller is presented, but as business needs change (e.g., requiring email confirmations), this leads to an unwieldy controller structure if modifications are made directly within it.
  • Extracting Logic: The concept of extracting logic out of models and controllers to facilitate maintainability is critical.

    • Example of User Signup: An example is provided where user sign-up logic is extracted into a 'UserSigner' class, centralizing the user creation and email confirmation functionalities away from the controller and model.
  • Single Responsibility Principle: SRP is highlighted as ensuring that classes should only have one reason to change, thus promoting better maintainability and testing ease.

  • Design Patterns: Various design patterns are reviewed to give structure to code and make it more manageable. These include:

    • Presenters/Decorators: To isolate presentation logic from models.
    • Form Objects: For handling complex forms with multiple attributes.
    • Service Objects: To encapsulate business logic, ensuring reusability and clarity in code.
    • Policy and Query Objects: To manage access control and complex database queries while promoting better visibility and clarity in code.
    • Value Objects: For explicit data handling which improves readability.
    • Repositories: To provide a structured way of retrieving and managing data access.
  • Implementing Change in Teams: Evans discusses how to introduce new patterns or changes within a team environment, suggesting clarity in motivation and creating tangible evidence of proposed changes, such as proofs of concept and architectural decision records (ADRs) to document discussions and decisions.

Conclusion:

The emphasis throughout the talk is on the importance of encapsulating logic into distinct classes to enhance readability, maintainability, and testability. A strong understanding of design patterns, adherence to SRP, and collaborative practices within teams are vital for achieving effective software architecture.

Let’s Extract a Class: The Single Responsibility Principle and Design Patterns
Jon Evans • October 26, 2023 • Boulder, CO • Talk

Rocky Mountain Ruby 2023 - Let’s Extract a Class: The Single Responsibility Principle and Design Patterns by Jon Evans

We’ll talk about going beyond “fat model, skinny controller” and the importance of following the Single Responsibility Principle. Several common design patterns will be discussed, but the emphasis will be on the importance of encapsulating your logic in whatever way you choose that makes the code readable and maintainable. Finally, how do you get your team on board?

Rocky Mountain Ruby 2023

00:00:14.080 All right, well hi everybody! I'm Jon. I heard you liked design patterns, so we're going to keep talking about those.
00:00:24.519 I got my first Ruby job in 2011, and I've seen various things about how people decide to organize their code. I live in Lyons, have a couple of kids and dogs, and work at Gusto. We're here sponsoring; talk to us!
00:00:36.719 This is Annie Maggie, and I would love to show you pictures of my dogs, but today we're going to talk about ways to organize code, the holy wars that accompany it, and why naming is the only thing that really matters.
00:00:52.320 So just to make sure everybody is on the same page, let's discuss the default organization for Rails applications. These are sort of the architectural components that Rails comes with, which include the model, view, and controller, or MVC.
00:01:06.119 A model wraps the database table and provides an abstraction for loading and persisting data. The view is the template that gets turned into HTML and is sent to the user, while the controller acts as a middleman that interprets the request from the browser, often performing actions on a model to get or persist data, and then presenting the results to the user via the view.
00:01:29.199 This structure is sufficient for many applications, but we're going to talk about ways to augment this foundation, which can help as your application grows in complexity, your team grows in size, and making changes becomes increasingly difficult and risky.
00:01:44.719 We'll start with something that most applications have to deal with: allowing users to sign up. We will begin with a basic approach and then look at some other options.
00:02:03.439 Here’s our controller. We have this create method where we get the user’s name, email, and password, then immediately create the record and sign them in. Great, that works and is easy to follow, but surprise, surprise, requirements are going to change.
00:02:25.720 We have spammers coming in, and we need to make users confirm their email before they can sign in. So instead, of signing them in immediately, we will now send them a confirmation email and redirect them to check their email.
00:02:38.519 That works well, but what if there’s a different endpoint from which we also want to create users? Now, we could copy and paste this code everywhere, but then maintenance quickly becomes a nightmare because changing requirements mean doing shotgun surgery; you'd have to change a lot of code.
00:02:44.879 Starting with everything in the controller is simple and easy to follow, but it becomes harder to test because controller tests involve more moving parts than just testing your plain old Ruby objects. They are not reusable, nor do they absorb complexity well.
00:03:02.360 What if we put it in the model instead? Now we have this after create hook on our model. Whenever we create a user, we will also send the confirmation email. Great! But now, every time we create a user, the tests will run a little slower because we’re sending this email.
00:03:17.920 It's guaranteed in software development that changing requirements will lead to increasing complexity, and it's rare for a project manager to come in and simplify things. Callbacks are powerful but dangerous tools, and venturing down that path can lead to madness.
00:03:34.439 As you continue to add callbacks to your models, your tests will become slower. More importantly, your code will become harder to reason about, as they add implicit behavior to your application, which complicates things. Explicit behavior is always easier to reason about.
00:03:56.480 Continuing on this path makes changes difficult and can lead to increasingly convoluted code. Thick models may seem simple, but they result in slower tests, forced reuse, and do not absorb complexity well.
00:04:15.480 While I’m emphasizing that this approach does not absorb complexity well, it’s challenging to demonstrate thoroughly with an example that fits on a slide. Let’s look at a real-world example of what can happen if you don’t proactively architect your application to handle added complexity.
00:04:35.920 Here’s a real-world user model, and I think this has gone beyond thick to being a full-fledged God model. Bear with me as I scroll through this model.
00:05:07.679 Alright, so apologies if you’ve worked on this kind of model—no judgment really, just sincere sympathy—but it's a demonstration of why addressing this is important. Rails ships with basic architectural components, which are great, but you don't have to stop there.
00:05:55.040 As software developers, we should be more than just individuals who run 'rails generate scaffold' commands. Ruby is great with classes and methods; we can utilize these fundamental building blocks to introduce new ways of encapsulating our business logic.
00:06:03.800 It would be beneficial if Rails included a business logic folder alongside models and controllers, but we have to create our own. So let’s extract a class. You know, it’s like when you’re watching a movie and they say the name of the movie in the movie—that’s this moment.
00:06:44.160 Here’s a basic example of an extracted class: a UserSigner class that takes in user parameters. It contains a 'do it' method that sends the confirmation email if we successfully save the user, and then returns the user.
00:07:05.360 With this extraction, our model goes back to knowing nothing about emails, and our controller gets simplified. This creates clear dividing lines: we only need to update the controller when we want to change something that the controller is responsible for, like the redirect after sign-up.
00:07:27.840 The UserSignup class is now in charge of all the details of what happens when a user signs up. Consequently, when we decide that we want to post new signups in Slack or email relevant administrators, this will absorb that complexity with minimal complaints.
00:07:54.200 It's still easy to reason about what's going on. Since I've named this talk the Single Responsibility Principle (SRP), let's emphasize that the core idea is that each class should only have one reason to change.
00:08:13.760 This requires us to think about code like humans who are aware of business requirements—that’s what is likely to change based on business needs. It involves making conscious choices regarding trade-offs of SRP, cohesion, and coupling.
00:08:44.720 Taking SRP too far might mean simple flows in your application require holding dozens of classes in your head, while not taking it far enough could mean that you have to reason about methods that are dozens of lines long or a user class that’s thousands of lines.
00:09:02.480 By establishing reasonable dividing lines for classes, you set yourself up to enable easier testing. It’s simpler to test something when it does not have a bunch of side effects, while promoting reuse, making it easier for new contributors to get up to speed, and making your application more pleasant to maintain.
00:09:29.200 Let’s look now at some common design patterns. You might have heard of a few today, but we’ll review them again. Design patterns are just standard, repeatable approaches to common problems that developers encounter.
00:09:50.000 They’re not magic, and there's seldom one correct pattern for any particular issue. Design patterns are useful tools to have at your disposal, and being familiar with them can make jumping into other codebases less painful.
00:10:11.600 For example, when you're just passing an instance of your ActiveRecord class to your view, you might be tempted to add presentation-specific methods to your ActiveRecord class or do too much in your view templates, which can lead to clutter.
00:10:47.040 Looking at this, you might think about adding full name or a display sign-up method to the user model. However, this will quickly fill your model with presentation-specific logic, potentially conflicting with the Single Responsibility Principle.
00:11:03.680 If we're changing the model, which should be concerned only with data fetching and persistence, but we're really just adjusting presentation-level concerns, then we might want to consider using a presenter, decorator, or view component. This way, we encapsulate presentation-specific logic.
00:11:27.000 This gives us a clear and simple view while keeping our presentation concerns isolated from the model in their own class. The only reason to change this presenter class would be if we needed to present something different for our user.
00:11:48.520 Form objects are another key pattern. They can be really powerful when creating multiple records from the same form. Imagine a scenario where you want to create both a user account and a waiver acceptance.
00:12:06.120 The Rails way might involve using nested attributes on your user model, which may work for simple cases. However, as your controller grows complex, switching to form objects keeps your model and controller clear of clutter, while allowing for custom validations across both objects.
00:12:30.760 Here’s how a form object example can work: we take in user attributes and waiver attributes. In our save method, we create both the user and the waiver within a transaction. This ensures that if one fails, the data doesn't get orphaned.
00:12:50.640 We now have a class where we can explicitly set up our associations and delegate validations to instances, ensuring that persistence is transactional.
00:13:02.720 Moving on to service objects, I've already shown a service object with the previous UserSignup class. The core idea behind service objects is to encapsulate functionality, making it explicit, easy to test, and reusable.
00:13:27.200 You can delve into strict forms like command objects, which require only a public perform method. This approach ensures a consistent interface but might limit your creativity—essentially restricting how you write your code.
00:13:50.560 Service objects generally solve the problem of keeping business logic centralized. They provide a single point of contact for your flow, promoting reusability and making your code more testable.
00:14:07.640 Policy classes encapsulate rules, such as determining if a user can access a certain post or if a post can be deleted. For instance, if only posts that are less than two days old, have no comments, or are owned by an administrator can be deleted, it’s helpful to encapsulate this logic in a policy class.
00:14:25.760 With a post deletion policy class, we pass in a user and a post to check permissions. This way, we encourage reuse across different areas of the application, avoiding repeated logic throughout various parts of the code.
00:14:50.760 Query objects are often just handled by scopes, but if you have a complicated set of scopes that frequently recur across your application, creating a nameable query object can help clarify its purpose.
00:15:09.320 For example, if 'sleepy puppies' is a central theme in our application, promoting it to a first-class citizen allows us to abstract away complexities and maintain clear communication in our codebase.
00:15:26.160 Value objects, also referred to as data objects or virtual domain objects, provide a defined interface for data management. They make your interfaces explicit, limiting the data passed around in a codebase. This is increasingly valuable as more developers contribute to a project.
00:15:47.680 If you return an ActiveRecord instance, the calling code can do anything with it; however, if you return a value object, they only receive the data you've intentionally provided. This approach enhances clarity and control within a codebase.
00:16:09.360 Repositories are the final pattern I'll discuss. They offer a structured way to retrieve data, primarily by limiting access to data that users are authorized to view and enabling eager loading to avoid performance pitfalls like N+1 queries.
00:16:24.120 We can create a repository that ensures users access only their pets or public pets, preventing unauthorized snooping through a neat wrapper around ActiveRecord’s interface.
00:16:42.240 Now, consider augmenting the information we retrieve with additional context, such as a pet's breed, which may involve calling an external API. By wrapping this logic in a repository, we can keep it isolated and easy to maintain.
00:17:05.760 To avoid performance issues, let's optimally fetch breed information for multiple pets in one call. Pulling this logic into a repository allows for optimizations and adjustments in a single place, improving maintainability.
00:17:28.720 While I apologize for possibly missing your favorite design pattern, I encourage discussion afterwards if you have suggestions. There are numerous design patterns, and each has its unique utility in making codebases easier to comprehend.
00:17:52.840 Remember that design patterns aren't a panacea—they should serve your application’s needs. The main takeaway from this talk is that if you can identify a concept that's currently hidden within another class, consider extracting it into its own class.
00:18:26.080 We’ve looked at common ways people tend to architect these extractions. If it doesn't fit into a specific design pattern, you can simply refer to it as a service class. However, take caution—instead of hurrying to extract code, it’s often best to do so once you've seen a concept repeat organically.
00:18:52.600 Preemptive refactoring can lead to extricating code that appears should belong to its own class but doesn’t represent an accurate abstraction. Recognizing true business concepts needs careful thought.
00:19:12.760 Strive to extract only once you've observed a concept or pattern consistently emerge multiple times. Dealing with naturally recurring code is generally more manageable than attempting to restructure concepts mistaken for business logic.
00:19:29.280 Don’t constrain yourself by trying to fit every element into a design pattern. A simply named class enhances encapsulation of your business logic, promoting reusability, testability, and ease of understanding.
00:19:48.960 This leads to a vital consideration in programming: naming. While a significant portion of your career might not involve thoughts about caching and validation, you cannot escape this challenging aspect of computer science.
00:20:05.480 Now, pivoting from code to people, how can you introduce a new design pattern to your codebase? Most of us don't work in isolation, and it’s essential to bring your team along as you propose architectural changes.
00:20:16.400 First, clarify in your mind why you want to introduce this design pattern. Identify the pain points motivating you, explain how the proposed solutions address these challenges.
00:20:37.440 There are countless ways to brew coffee, numerous routes to achieve a solution. Articulate why your proposal is superior compared to other options.
00:20:50.560 It’s vital that you and your team find a way forward, ensuring the application remains manageable for the long term. Avoid adopting an attitude of seeing yourself as a prophet with the only true path to maintainable code.
00:21:16.640 When discussing a proposal, consider having something tangible to reference. A quick proof of concept can help illustrate how the new approach functions in a targeted context.
00:21:44.960 It’s much simpler to discuss specific code rather than abstract concepts. Additionally, creating an architectural decision record (ADR) can be invaluable.
00:21:57.200 This involves documenting your motivations, alternatives considered, and the proposed solution. This gives tangible material for your team to analyze together.
00:22:22.680 If your company doesn't currently utilize an ADR process, introducing one can be beneficial, though it could warrant its own discussion later.
00:22:46.760 Having a record of past ADRs helps in preserving the reasoning behind decisions for future reference. Being able to revisit the motivations for architectural choices can be enlightening for new team members.
00:23:09.760 This practice can also encode these choices as guidelines for future development as your application evolves.
00:23:32.640 Here’s your checklist for change agents: a change agent is someone who seeks to introduce significant changes within your organization.
00:23:49.200 Draft your proposal, outlining motivations and alternatives, and carry out a proof of concept. Send out meeting invitations and encourage your team members to do pre-reading.
00:24:04.320 Let’s decide how to move forward and get right into it!
00:24:33.760 Thank you! That's about it.
00:24:53.000 Does anyone have questions?”
00:25:16.000 The question is about using Google Docs for ADRs. Yes, we do that; we have a template to fill out and post in Slack, soliciting feedback before a certain date.
00:25:22.120 If there’s a lot of discussion, let’s have a meeting to flesh it out.
00:25:29.360 So, the question is about prematurely extracting a class. If you extract a class too early, it might not reflect your domain accurately.
00:25:38.520 Yes, that can be dangerous because once it's part of your codebase, it tends to remain there. You've written tests for it and used it in multiple places.
00:25:50.120 There are methods to introduce the correct class gradually, but you might end up with two classes living in your codebase simultaneously.
00:25:56.880 It’s a challenge, no doubt.
00:26:06.040 Thank you.
Explore all talks recorded at Rocky Mountain Ruby 2023
+10