Service Object Approach


Table of Contents


References

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:

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?