DEV Community

Michiharu Ono
Michiharu Ono

Posted on

When Controllers Take on Too Much Responsibility

In software development, following object-oriented programming (OOP) principles is key to building systems that are easy to maintain(If you were interested, I've covered this topic in this post so please have a look šŸ‘€)

But letā€™s face itā€”while we all recognize the importance of OOP, actually implementing these principles can be tricky, especially early in our careers or when weā€™re racing to meet deadlines.

One common pitfall in backend development is when controllers end up taking on too much responsibility. I believe this happens a lot especially when backend design is heavily shaped by frontend requirements, leading to bloated controllers that mix concerns and violate OOP principles.

In this post, Iā€™ll attempt to unpack why this tends to happen, explore the risks of overburdened controllers, and share how to design a more maintainable solution that keeps your codebase scalable. Letā€™s get started! šŸš€


Why Do Controllers Take on Too Much Responsibility?

Before diving into examples, letā€™s take a moment to reflect on why controllers often end up doing more than they should šŸ¤” In my observation, there are a couple of reasons why developers sometimes let controllers carry too much weight:

  1. The Pressure to Ship Quickly

    Deadlines can be relentless, and under that pressure, quick fixes sometimes win over thoughtful design. Itā€™s ā€œeasyā€ to write everything directly into the controllerā€”fetching data, applying business logic, and formatting JSONā€”because it feels like the fastest way to meet frontend requirements.

  2. Temporary Features That Overstay Their Welcome

    Sometimes, developers ship features intended to be temporaryā€”a quick fix for an event, promotion, or beta test. Because these features are labeled as "short-term," taking the time to structure the code properly often feels unnecessary. After all, why bother refactoring or adding extra layers for something thatā€™s supposed to disappear soon, right?

    But hereā€™s the catch āš ļø: those temporary features have a way of sticking around much longer than expected. Deadlines slip, priorities shift, or stakeholders decide to make the feature permanent. Suddenly, what started as a quick-and-dirty addition becomes a lasting part of the application, leaving behind tightly coupled controller code thatā€™s difficult to maintain, extend, or debug.

  3. ā€œIt works, so whatā€™s the problem?šŸ˜—ā€

    When you havenā€™t yet experienced the downsides of overburdened controllersā€”like wrestling with a tangled codebase, overhauling the frontend, chasing down elusive bugs, or enhancing existing featuresā€”itā€™s easy to overlook the importance of separation of concerns. Iā€™ll admit, Iā€™ve been guilty of this myself šŸ™‹ā€ā™‚ļø.

    Itā€™s a bit like boxing: you donā€™t fully appreciate the value of keeping your guard up until you take a punch to the face.

    Without the perspective of long-term projects, it can be tempting to focus on code that ā€œjust worksā€ in the moment, rather than considering how all the pieces will fit together down the line.


What Happens When Controllers Take on Too Much?

When controllers take on too many responsibilitiesā€”fetching data, applying business logic, and formatting responsesā€”it can often lead to two major issues. These problems not only make the code harder to work with but can also create a ripple effect of complications down the line:

  1. Tight Coupling Between Backend and Frontend Code

    When controllers handle both backend logic and frontend-specific requirements, it creates tight coupling between the two. For example, if the frontend expects a specific JSON format to display a productā€™s sale status, any change to that format might require updates to both the backend logic and the frontend code.

    This direct connection means that updates in one area can unexpectedly break or require adjustments in the other, making maintenance more complex. A more flexible approach is to decouple the backend and frontend, allowing each to evolve independently without creating unnecessary dependencies.

  2. Shifting from Object Interaction to Step-by-Step Procedural Flow

    In OOP, objects should manage their own behavior. For example, a Product object would know if it's on sale (on_sale?). However, when controllers become overly focused on frontend needs, they start manually assembling data in a step-by-step fashion. This results in procedural flow, where the controller handles all the logic instead of letting the objects themselves manage it.

    By shifting away from object interaction, we lose the benefits of encapsulation, reusability, and maintainability.

Example: Product Listing

Imagine youā€™re building a product listing page for your web app. The frontend needs details like product names, prices, and whether each product is on sale. A quick implementation might look like this:


class ProductController
  def list
    products = Product.all.map do |product|
      {
        name: product.name,
        price: product.price,
        is_on_sale: product.discount > 0
      }
    end

    render json: { products: products }
  end
end

Enter fullscreen mode Exit fullscreen mode

This implementation worksā€”but it might come with several challenges:

  1. Reduced Reusability:

    The sale determination logic is now tied to the context of this specific controller method. If another part of the application (e.g., a report generator or an API endpoint) needs to determine if a product is on sale, developers might copy the logic instead of reusing it, leading to more duplication.

  2. Tightly Coupled JSON Formatting

    When the controller directly defines how JSON is structured to meet frontend requirements, it mixes presentation logic with business logic. The controllerā€™s primary responsibility should be handling the request/response cycle, not deciding how the data should be presented. This makes the controller more complex and tightly coupled to the frontend, so any changes to the frontendā€™s data format will require updates in the controller, leading to unnecessary dependencies and making the system harder to maintain.

  3. Testing Challenges

    Do you write tests in controller level? In rails, most of the time, it is better to write business logic else where and controller should just focus on fetching objects. If you code like above, testing the controller now requires ensuring that the sale logic is correct in addition to verifying the controller's primary responsibility (e.g., routing and rendering). This increases the complexity of the test suite and makes the tests more fragile, as they are tied to both business logic and controller logic.


More "Maintainable" Solution

Instead of cramming everything into the controller, letā€™s spread the responsibilities across models and presenters. This approach keeps each piece of code focused on its specific role.

Hereā€™s a refactored version:

class Product
  def on_sale?
    discount > 0
  end
end

class ProductPresenter
  def initialize(product)
    @product = product
  end

  def as_json
    {
      name: @product.name,
      price: @product.price,
      is_on_sale: @product.on_sale?
    }
  end
end

class ProductController
  def list
    products = Product.all.map { |product| ProductPresenter.new(product).as_json }
    render json: { products: products }
  end
end

Enter fullscreen mode Exit fullscreen mode

šŸ’”Why This Works

By distributing responsibilities across models, presenters, and controllers, we achieve a cleaner and more maintainable solution. Each layer now focuses on its core responsibility, making the codebase more flexible, reusable, and testable. Here's why this approach is more maintainable:

  1. Encapsulation

    The Product model encapsulates the logic for determining if itā€™s on sale. Any changes to this logic only need to be made in one place.

  2. Abstraction

    The ProductPresenter handles JSON formatting, separating it from both the controller and the model.

  3. Reusability

    If you need to format products differently in another context (e.g., an admin dashboard), you can create a new presenter without touching the core logic.

  4. Testability

    Each layer can be tested independently šŸ˜Ž: models for business rules, presenters for formatting, and controllers for request handling. It is easy to write tests.


Conclusion

When controllers take on too much responsibility, they violate key principles of OOP and separation of concerns. This leads to tightly coupled, procedural-style code thatā€™s harder to maintain and extend.

By focusing on designing robust objects and delegating responsibilities appropriately, you can keep your codebase clean and adaptable to changing requirements. Remember: controllers arenā€™t meant to carry all the weight of your backendā€”theyā€™re just one part of a well-designed system šŸ˜Œ Keep them lean and focused, and your future self and teammates will thank you!

(BTW, Iā€™m not necessarily against so-called 'dirty coding.' Feel free to check out this post I wrote previously on the topic.)

Top comments (0)