Search code examples
ruby-on-railsmodel-view-controllerdrycode-organization

Rails 4+: single resource with several associations, controller organization


This is one of those "real world project" problems that I am struggling to find a good solution to.

I have a task model that is associated to several different resources and I need to allow CRUD ability from each of those associated resources.

For instance, a project has many tasks and I need to update the tasks in context of the project.

Also, each project has many milestones, and each milestone also can have many tasks.

It's a situation where a task may or may not be associated to a milestone.

class Project < ApplicationRecord
  has_many :milestones
  has_many :tasks
end

class Milestone < ApplicationRecord
  belongs_to :project # required
  has_many :tasks
end

class Task < ApplicationRecord
  belongs_to :project # required
  belongs_to :milestone # optional
end

I have namespaced my controllers for better organization and control. That leads me with routes such as:

# routes.rb
resources :projects do
  namespace :projects do
    resources :milestones # app/controllers/projects/milestones_controller.rb
    resources :tasks # app/controllers/projects/tasks_controller.rb
  end
end

resources :milestones do
  namespace :milestones do
    resources :tasks # app/controllers/milestones/tasks_controller.rb
  end
end

I also am using a lot of AJAX to make the interface faster (with Turbolinks), so my actions follow the <action>.js.erb format where I use JavaScript to update the page instead of page refreshes. Note that I'm using a dialog/popup interface for the task forms as well, hence why I can't just do entire page refreshes.

While this "works", it ends up with a situation where I have a lot of duplicate code.

# projects/tasks_controller.rb
def new
  @project = Project.find(params[:project_id])
  @task = @project.tasks.new
end

def create
  @project = Project.find(params[:project_id])
  @task = @project.tasks.new(task_params)

  if @task.save
    # create.js.erb
  else
    render js: "alert('error');" # example...
  end
end

# app/views/projects/tasks/create.js.erb
$("#tasks_for_<%= dom_id(@project) %>").append("<%=j render(partial: 'projects/tasks/task') %>");

# milestones/tasks_controller.rb
def new
  @milestone = Milestone.find(params[:milestone_id])
  @task = @milestone.tasks.new
end

def create
  @milestone = Milestone.find(params[:milestone_id])
  @task = @milestone.tasks.new(task_params)

  if @task.save
    # create.js.erb
  else
    render js: "alert('error');" # example...
  end
end

# app/views/milestones/tasks/create.js.erb
$("#tasks_for_<%= dom_id(@milestone) %>").append("<%=j render(partial: 'milestones/tasks/task') %>");

This is just some sample code, the code from the actual system shows even more duplication. As you can tell, there's a lot of repeat code in each of the different resources that interact slightly different with the tasks resource.

Is there some standard format or Rails feature that assists with structuring resources that are manipulated by several other resources?

How could I decrease this duplication? It is directly leading to a complex system where everytime I add or change a feature, I have to go and change it in 3+ different places.


Solution

  • Let's assume you have a ServiceBase that looks something like this:

    # services/service_base.rb
    class ServiceBase
    
      attr_accessor :args,
                    :controller
    
      class << self
    
        def call(args={})
          new(args).call
        end
    
      end # Class Methods
    
      #======================================================================
      # Instance Methods
      #======================================================================
    
        def initialize(args)
          @args = args
        end
    
      private
    
        def params
          controller.params
        end
    
        def assign_args
          args.each do |k,v| 
            class_eval do 
              attr_accessor k
            end
            send("#{k}=",v)
          end
        end
    
    end
    

    And then a Tasks::NewService that looks something like this:

    # services/tasks/new_service.rb
    class Tasks::NewService < ServiceBase
    
        def call
          assign_args
          @haser = haser_klass.find(haser_id)
          @haser.tasks.new
        end
    
      private
    
        def haser_klass
          haser_base.constantize
        end
    
        def haser_instance_name
          haser_base.downcase
        end
    
        def haser_base
          controller.class.name.split("::")[0].singularize
        end
    
        def haser_id
          params["#{haser_instance_name}_id".to_sym]
        end
    
    end
    

    Then, in your controllers, you should be able to do something like:

    # milestones/tasks_controller.rb
    Milestones::TasksController < ApplicationController
    
      def new
        @task = Tasks::NewService.call(controller: self)
      end
    
    end
    
    # projects/tasks_controller.rb
    Projects::TasksController < ApplicationController
    
      def new
        @task = Tasks::NewService.call(controller: self)
      end
    
    end
    

    If you have a Tasks::CreateService that looks something like:

    # services/tasks/create_service.rb
    class Tasks::CreateService < ServiceBase
    
      delegate  :render,
                to: :controller
    
        def call 
          assign_args
          @haser = haser_klass.find(haser_id)
          @task = @haser.tasks.new(task_params)
          if @task.save
            # create.js.erb
          else
            render js: "alert('error');" # example...
          end      
        end
    
      private
    
        def task_params
          send("#{haser_instance_name}_params")
        end
    
        def milestone_params
          params.require(:milestone).permit(:foo)
        end
    
    end
    

    Then, in your controllers, you should be able to do something like:

    # milestones/tasks_controller.rb
    Milestones::TasksController < ApplicationController
    
      def new
        @task = Tasks::NewService.call(controller: self)
      end
    
      def create
        Tasks::CreateService.call(controller: self)
      end
    
    end
    
    # projects/tasks_controller.rb
    Projects::TasksController < ApplicationController
    
      def new
        @task = Tasks::NewService.call(controller: self)
      end
    
      def new
        Tasks::CreateService.call(controller: self)
      end
    
    end
    

    If you get to a point where, after abstraction, your controllers have identical methods, then you could start to do other fancy stuff like have Milestones::TasksController and Projects::TasksController both inherit from a common controller other than ApplicationController that has all the shared methods. So, let's say you're not using TasksController. Then, maybe your controllers could look something like:

    # tasks_controller.rb
    TasksController < ApplicationController
    
      def new
        @task = Tasks::NewService.call(controller: self)
      end
    
      def create
        Tasks::CreateService.call(controller: self)
      end
    
    end    
    
    # milestones/tasks_controller.rb
    Milestones::TasksController < TasksController; end
    
    # projects/tasks_controller.rb
    Projects::TasksController < TasksController; end
    

    Now, you only need to make changes in one place: in your services. Naturally, you can always override controller methods if you don't want to use the services.