From 824e9a13cb1c26d83e23a33f368da5579529e968 Mon Sep 17 00:00:00 2001 From: Michael Kohl Date: Tue, 10 Nov 2020 09:09:35 +0700 Subject: [PATCH] Add service generator (#11265) * Add service generator * Finalize service generator * Add documentation * Explicitly require rails/generators * Update Zeitwerk initializer * Remove explicit require * Update docs/backend/service-objects.md Co-authored-by: Ridhwana Co-authored-by: Ridhwana --- config/initializers/zeitwerk.rb | 1 + docs/backend/readme.md | 1 + docs/backend/service-objects.md | 114 ++++++++++++++++++ lib/generators/service/USAGE | 71 +++++++++++ lib/generators/service/service_generator.rb | 24 ++++ lib/generators/service/templates/service.erb | 18 +++ .../service/templates/service_spec.erb | 5 + spec/generator/services_generator_spec.rb | 5 + 8 files changed, 239 insertions(+) create mode 100644 docs/backend/service-objects.md create mode 100644 lib/generators/service/USAGE create mode 100644 lib/generators/service/service_generator.rb create mode 100644 lib/generators/service/templates/service.erb create mode 100644 lib/generators/service/templates/service_spec.erb create mode 100644 spec/generator/services_generator_spec.rb diff --git a/config/initializers/zeitwerk.rb b/config/initializers/zeitwerk.rb index 88f3408fa..4907e638f 100644 --- a/config/initializers/zeitwerk.rb +++ b/config/initializers/zeitwerk.rb @@ -13,3 +13,4 @@ end # Ignoring folders that don't adhere to the new naming conventions Rails.autoloaders.main.ignore(Rails.root.join("lib/data_update_scripts")) Rails.autoloaders.main.ignore(Rails.root.join("lib/generators/data_update")) +Rails.autoloaders.main.ignore(Rails.root.join("lib/generators/service")) diff --git a/docs/backend/readme.md b/docs/backend/readme.md index 11afe1a0c..a60deb050 100644 --- a/docs/backend/readme.md +++ b/docs/backend/readme.md @@ -20,6 +20,7 @@ items: - scheduled-jobs.md - metrics.md - tracking.md + - service-objects.md --- # Backend Guide diff --git a/docs/backend/service-objects.md b/docs/backend/service-objects.md new file mode 100644 index 000000000..2f0b35c90 --- /dev/null +++ b/docs/backend/service-objects.md @@ -0,0 +1,114 @@ +--- +title: Service Objects +--- + +## What are Service Objects + +Service objects are Plain Old Ruby Objects (POROs) which encapsulate a whole +business process/user interaction. + +Our services are located in `app/services` with the corresponding specs in +`spec/services`. Their main interface is a class level method named `call`, as +in the following example: + +```ruby +class ImportUsers + def self.call(arg1) + new(arg1).call + end + + def initialize(arg1) + @arg1 = arg1 + end + + def call + # import code goes here + end +end +``` + +To distinguish services from models we often give them verb names vs noun +names, e.g. `ImportUsers` instead of `UserImporter`. + +## Generating Service Objects + +To make our services more consistent we use a custom Rails generator. Some usage +examples: + +**Generate a non-namespaced service without arguments** + +`$ rails generate service DoTheThing` + +```ruby +# app/services/do_the_thing.rb +class DoTheThing + def self.call + new.call + end + + def call + end +end +``` + +```ruby +# spec/services/do_the_thing_spec.rb +require "rails_helper" + +RSpec.describe DoTheThing, type: :service do + pending "add some examples to (or delete) #{__FILE__}" +end +``` + +**Generate a non-namespaced service with arguments:** + +`$ rails generate service DoTheThing arg1 arg2` + +```ruby +# app/services/do_the_thing.rb +class DoTheThing + def self.call(arg1, arg2) + new(arg1, arg2).call + end + + def initialize(arg1, arg2) + @arg1 = arg1 + @arg2 = arg2 + end + + def call + end +end +``` + +The generated spec is the same as above. + +**Generate a namespaced service with arguments** + +`$ rails generate service things/dothem arg1 arg2` + +```ruby +# app/services/things/do_them.rb +class Things::DoThem + def self.call(arg1, arg2) + new(arg1, arg2).call + end + + def initialize(arg1, arg2) + @arg1 = arg1 + @arg2 = arg2 + end + + def call + end +end +``` + +```ruby +# spec/services/things/do_them_spec.rb +require "rails_helper" + +RSpec.describe Things::DoThem, type: :service do + pending "add some examples to (or delete) #{__FILE__}" +end +``` diff --git a/lib/generators/service/USAGE b/lib/generators/service/USAGE new file mode 100644 index 000000000..e30e39d5c --- /dev/null +++ b/lib/generators/service/USAGE @@ -0,0 +1,71 @@ +Description: + Generates a service object. + +Examples: + * Generating a service with no arguments: + + $ rails generate service ImportUsers + + # app/services/import_users.rb + class ImportUsers + def self.call + new.call + end + + def call + end + end + + # spec/services/import_users_spec.rb + require "rails_helper" + + RSpec.describe ImportUsers, type: :service do + pending "add some examples to (or delete) #{__FILE__}" + end + + * Generating a service with arguments: + + $ rails generate service ImportUsers users + + # app/services/import_users.rb + class ImportUsers + def self.call(user) + new(user).call + end + + def initialize(user) + @user = user + end + + def call + end + end + + # spec/servics/import_users_spec.rb + # Same as above + + * Generating a namespaced service: + + $ rails generate service users/import users + + # app/services/users/import.rb + class Users::Import + def self.call(users) + new(users).call + end + + def initialize(users) + @users = users + end + + def call + end + end + + # spec/services/users/import_spec.rb + require "rails_helper" + + RSpec.describe Users::Import, type: :service do + pending "add some examples to (or delete) #{__FILE__}" + end + diff --git a/lib/generators/service/service_generator.rb b/lib/generators/service/service_generator.rb new file mode 100644 index 000000000..abb8c1273 --- /dev/null +++ b/lib/generators/service/service_generator.rb @@ -0,0 +1,24 @@ +class ServiceGenerator < Rails::Generators::NamedBase + source_root File.expand_path("templates", __dir__) + + argument :arguments, type: :array, default: [], banner: "arguments for .call" + class_option :spec, type: :boolean, default: true + + def create_service_file + template( + "service.erb", + File.join("app/services", class_path, "#{file_name}.rb"), + ) + + return unless options["spec"] + + template( + "service_spec.erb", + File.join("spec/services", class_path, "#{file_name}_spec.rb"), + ) + end + + def signature + @signature ||= arguments.present? ? "(#{arguments.join(', ')})" : nil + end +end diff --git a/lib/generators/service/templates/service.erb b/lib/generators/service/templates/service.erb new file mode 100644 index 000000000..ef01a4811 --- /dev/null +++ b/lib/generators/service/templates/service.erb @@ -0,0 +1,18 @@ +<% module_namespacing do -%> +class <%= class_name %> + def self.call<%= signature %> + new<%= signature %>.call + end + +<%- if arguments.present? -%> + def initialize<%= signature %> +<% arguments.each do |argument| -%> + <%= "@#{argument}" %> = <%= argument %> +<% end -%> + end + +<% end -%> + def call + end +end +<% end -%> diff --git a/lib/generators/service/templates/service_spec.erb b/lib/generators/service/templates/service_spec.erb new file mode 100644 index 000000000..5f2bd44ed --- /dev/null +++ b/lib/generators/service/templates/service_spec.erb @@ -0,0 +1,5 @@ +require "rails_helper" + +RSpec.describe <%= class_name %>, type: :service do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/generator/services_generator_spec.rb b/spec/generator/services_generator_spec.rb new file mode 100644 index 000000000..f13658cb8 --- /dev/null +++ b/spec/generator/services_generator_spec.rb @@ -0,0 +1,5 @@ +require "rails_helper" + +RSpec.describe "Services", type: :generator do + pending "add some scenarios (or delete) #{__FILE__}" +end