Service Object Approach
Table of Contents
References
- Example: AddCollectionToCollection
Overview and Background
A suggestion was made at Wednesday's scrum that instead of spending extensive time on validation code, that we use Service Objects to encapsulate business logic such that PCDM::Collection, Objects, and Files do not get created or manipulated incorrectly. I'm not sure that everyone is on board with the idea of service objects, so the following explores expected benefits and a few scenarios. Others, please comment pro or against with your reasoning for your position.
Service Objects in Ruby
The primary reference used for the service object architecture followed the pattern described in the following resource:
Below are a few links to other blog posts and references that talk about the concept of Service Objects in the context of Ruby and/or Rails:
- Using Services to Keep Your Rails Controllers Clean and DRY
- Adam Niedzielski's take on services in Rails
- In 140 characters or less "They're basically concerns without the machinery"
- Why I Don't Use ActiveSupport::Concern or, why I actively replace ActiveSupport::Concern with Ruby in codebases I work on
Characteristics of a Service
- The service does only one thing.
- The name of the service is a verb that is describes what the service does. (e.g. RetrieveLatestFileVersion)
- The service has one and only one entry point. Some resources suggest this entry point should be a method named call. We are not requiring this in our implementation.
Potential Benefits
encapsulate business logic
- Service objects were suggested as a design pattern for enforcing business logic thus lessening, but not eliminating, the burden of validation as the likelihood of PCDM::Collections, Objects, and Files being used incorrectly is reduced if they were created and manipulated through Service Objects.
hide low level implementation details
- Service objects hide some of the low level implementation details. For example, service CreateGenericWork hides that GenericWork is not a class but is a module mixin that limits the full functionality of PCDM::Object. From the callers perspective, what gets returned is a GenericWork.
simulate super calls
- With service objects, similarly named and purposed services could be used to similuate calling super. For example, GenericWork's AddFileToGenericWork service can call the Object's AddObjectToObject service after validating that the object to be added is a GenericFile.
provide unchanging API
- providing an API that remains unchanged even if low level implementation details change
models are data focused only
- removal of business logic from models maintains models as data focused allowing them to be clean implementations of persistence handling
Implementation Recommendations
Location of Services
- base location of services for pcdm: /lib/hydra/pcdm/services
- base location of services for works: /lib/hydra/works/services
- services live in a directory based on what they affect
- ex. AddObjectToCollection updates collection and so lives in services/collection
- ex. UploadFileToGenericFile updates generic_file and so lives in services/generic_file
- expected directories for pcdm: collection, object, file
- expected directories for hydra-works: collection, generic_work, generic_file, file
- some services may need to live outside one of these directories
Naming Conventions
service class name should be an action verb
If the service puts something X into something Y, I use ActionverbXToY (e.g. AddObjectToCollection)
If the service gets something X from something Y, I use ActionverbXFromY (e.g. GetObjectsFromCollection)
If the service lives in Y, I don’t include it in the file name (ex. AddObjectToCollection lives in directory service/collection and has file name add_object.rb with the to_collection part implied by the directory)
Parameter Conventions
- if the services puts something X into something Y, then the parameters start with ( y, x, more_params) , where more_params can be any list of additional parameters
- if the services get something X from something Y, then the parameters start with ( y, more_params) , where more_params can be any list of additional parameters
Discussion
Raised Issues
Two main issues have been raised...
- how would app level implementations of a collection extend behaviors and metadata of the base PCDM::Collection (raised by Hector on the scrum call)
- use of CreateCollection creates and persists the Collection without any metadata (raised by Trey in Issue #67 add and test service: CreateCollection)
Extending PCDM::Collection
Pseudocode showing classes and modules for Collections
module Hydra::PCDM class Collection include Hydra::PCDM::CollectionBehavior end end # Hydra::PCDM::CollectionBehavior defines... # * rdf type: RDFVocab::PCDMterms.Collection # * groupings: collections and objects via members (aggregation) # * methods: collections=, collections, objects=, objects # * metadata: none module Hydra::Works class Collection < ActiveFedora::Base include Hydra::PCDM::CollectionBehavior include Hydra::Works::CollectionBehavior end end # Hydra::Works::CollectionBehavior defines... # * rdf type: RDFVocab::WorksTerms.Collection (adds, not replace) # * groupings: N/A (uses members defined previously) # * methods: generic_works=, generic_work # * metadata: none (or default set) module Sufia::Models class Collection < ActiveFedora::Base include Hydra::PCDM::CollectionBehavior include Hydra::Works::CollectionBehavior include Sufia::Models::CollectionBehavior end end # Sufia::Models::CollectionBehavior defines... # * rdf type: RDFVocab::SufiaTerms.Collection (adds, not replace) # * groupings: N/A (uses members defined previously) # * methods: N/A (uses previously defined); could be some specialized methods here if needed # * metadata: title, description, etc. (some may come from default set if defined at Hydra::Works level)
Pseudocode showing classes and modules for Object, GenericWork, and GenericFile
module Hydra::PCDM class Object include Hydra::PCDM::ObjectBehavior end end # Hydra::PCDM::ObjectBehavior defines... # * rdf type: RDFVocab::PCDMterms.Object # * groupings: objects via members (aggregation), files (contains) # * methods: objects=, objects, files=, files # * metadata: none # --------------------- module Hydra::Works class GenericWork < ActiveFedora::Base include Hydra::PCDM::ObjectBehavior include Hydra::Works::GenericWorkBehavior end end # Hydra::Works::GenericWorkBehavior defines... # * rdf type: RDFVocab::WorksTerms.GenericWork (adds, not replace) # * groupings: generic_works and generic_files via members (defined previously); (SHOULD NOT use files defined previously) # * methods: generic_works=, generic_works, override contains to prevent files from being added # * metadata: none (or default set) module Sufia::Models class GenericWork < ActiveFedora::Base include Hydra::PCDM::ObjectBehavior include Hydra::Works::GenericWorkBehavior include Sufia::Models::GenericWorkBehavior end end # Sufia::Models::GenericWorkBehavior defines... # * rdf type: RDFVocab::SufiaTerms.GenericWork (adds, not replace) # * groupings: N/A (uses members and files defined previously) # * methods: N/A (uses previously defined); could be some specialized methods here if needed # * metadata: title, description, etc. (some may come from default set if defined at Hydra::Works level) # --------------------- module Hydra::Works class GenericFile < ActiveFedora::Base include Hydra::PCDM::ObjectBehavior include Hydra::Works::GenericFileBehavior end end # Hydra::Works::GenericFileBehavior defines... # * rdf type: RDFVocab::WorksTerms.GenericFile (adds, not replace) # * groupings: generic_files via members (defined previously, debated, see NOTE); (uses files defined previously) # * methods: generic_files=, generic_files (debated, see NOTE) # * metadata: none (or default set) #### NOTE: Whether GenericFile hasMember GenericFile is a red-herring. Please don't discuss this until after the sprint. module Sufia::Models class GenericFile < ActiveFedora::Base include Hydra::PCDM::ObjectBehavior include Hydra::Works::GenericFileBehavior include Sufia::Models::GenericFileBehavior end end # Sufia::Models::GenericFileBehavior defines... # * rdf type: RDFVocab::SufiaTerms.GenericFile (adds, not replace) # * groupings: N/A (uses members and files defined previously) # * methods: adds upload file, auto-generate thumbnail and extracted text, file characterization (the code for these may live in another gem, but get called in this class when creating a new GenericFile) # * metadata: title, description, etc. (some may come from default set if defined at Hydra::Works level)
What would service objects calls look like?
coll1 = Sufia::Models::CreateCollection.call # service call coll1.title = 'My Collection' coll1.description = 'My collection description.' coll1.save gwork1 = Sufia::Models::CreateGenericWork.call # service call gwork1.title = 'My Work' gwork1.description = 'My work description.' gwork1.save Sufia::Models::AddWorkToCollection.call( coll1, gwork1 ) # service call # handles all validation that coll1 is a Collection and that gwork1 is a GenericWork gfile_metadata = { :title = 'My File' } gfile1 = Sufia::Models::AddFileToWork.call( gwork1, path_to_file, gfile_metadata ) # service call # creates generic file by calling Sufia::Models::CreateGenericFile.call( path_to_file ) # service call # uploads file # creates sufia auto-generated files # calls Sufia::Models::CreateFile.call( path_to_file ) # service call # runs file characterization to set technical metadata on File (or this is scheduled to run asynchronously) # gfile1.title = gfile_metadata[:title] # defaults to filename # gfile1.save
Pseudocode of a complex service object class
Shows starting from a Sufia service object and calling Works and PCDM service objects to insure business logic is applied at each level.
module Sufia::Models class UploadFileToWork ## # Add a file to a work. # # @param [Hydra::Works::GenericWork] :work to which to add a file # @param [String] :path_to_file path to a file to upload # @param [Hash] :file_metadata # :title - title metadatat # # @returns [Hydra::Works::GenericFile] the created generic file holding the uploaded file def self.call( work, path_to_file, file_metadata={} ) # NOTE: I may not have the code just right. I don't know what code actually does these tasks and if some are combined. # NOTE: The predicate establishing use is TBA pending decisions by the technical metadata working group. file1 = Hydra::Works::CreateFile.call( path_to_file, :characterize => true ) # service call # upload file # schedule file characterization file1.use = RDFVocab::WorksTerms.primary_content path_to_thumbnail = auto-generate_thumbnail file1_TN = Hydra::Works::CreateFile.call( path_to_thumbnail, :characterize => false ) # service call file1_TN.use = RDFVocab::WorksTerms.thumbnail path_to_extracted = auto-generate_extracted_text file1_EXT = Hydra::Works::CreateFile.call( path_to_extracted, :characterize => false ) # service call file1_EXT.use = RDFVocab::WorksTerms.extracted_text gfile1 = Hydra::Works::CreateGenericFile.call Hydra::Works::AddFileToGenericFile( gfile1 ) # uses default predicate hasFile Hydra::Works::AddFileToGenericFile( gfile1_TN ) # uses default predicate hasFile Hydra::Works::AddFileToGenericFile( gfile1_EXT ) # uses default predicate hasFile # runs file characterization to set technical metadata on File (or this is scheduled to run asynchronously) gfile1.title = file_metadata[:title] # defaults to filename gfile1.save Hydra::Works::AddGenericFileToGenericWork( work, gfile1 ) gfile1 end end end
module Hydra::Works class AddGenericFileToGenericWork ## # Add a generic file to a generic work. # # @param [Hydra::Works::GenericWork] :gwork to which to add a file # @param [Hydra::Works::GenericFile] :gfile to be added # # @returns [Hydra::Works::GenericWork] updated generic work def self.call( gwork, gfile ) raise ArgumentError, "gwork must be a Hydra works generic work" unless Hydra::Works.generic_work? gwork raise ArgumentError, "gfile must be a Hydra works generic file" unless Hydra::Works.generic_file? gfile Hydra::PCDM::AddObjectToObject( gwork, gfile ) end end end
module Hydra::PCDM class AddObjectToObject ## # Add an object to an object # # @param [Hydra::PCDM::Object] :parent_object to which to add an object # @param [Hydra::PCDM::Object] :child_object to be added # # @returns [Hydra::PCDM::Object] updated parent_object def self.call( parent_object, child_object ) raise ArgumentError, "parent_object must be a pcdm object" unless Hydra::PCDM.object? parent_object raise ArgumentError, "child_object must be a pcdm object" unless Hydra::PCDM.object? child_object parent_object << child_object end end end
Create - Persist - Modify - Persist
Observation: I believe that the way the code is set up now that the issue of create - persist - modify - persist is an issue with or without service objects. See What would service objects calls look like? above. Contrast with pseudo code without service objects.
coll1 = Sufia::Models::Collection.create # creates and persists coll1.title = 'My Collection' coll1.description = 'My collection description.' coll1.save gwork1 = Sufia::Models::GenericWork.create # creates and persists gwork1.title = 'My Work' gwork1.description = 'My work description.' gwork1.save gfile_metadata = { :title = 'My File' } gfile1 = Sufia::Models::GenericFile.create( path_to_file ) # creates and persists; overloads create # uploads file # creates sufia auto-generated files # creates files for each gfile1.title = 'My File' gfile1.save gfile1 = gwork1.addFile( gwork1, gfile1 ) gwork1.save
NOTE: The only real difference is that the code to perform validation and the file manipulations all lives in the model classes instead of in service objects. The problem of create and persist, followed by modify (primarily to set metadata) and persist still happens as soon as you call the create method. It is my understanding that the persistence happens when you call create on any ActiveFedora::Base subclass.
Alternative example creating a collection and setting metadata prior to the first persist.
TBA - Trey can you add what you think the code should be here?
NOTE: If the create causes persist issue is resolved and requires that the creation process to happen outside a service object, we could still use service objects for other complex actions reaping the benefits of removing business logic from models and maintaining data persistence focus of models.
Are there other issues?