DEV Community

Augusts Bautra
Augusts Bautra

Posted on

Use stateless services

This post is a response (comment, really) to Tim Riley's 2017 presentation on functional architecture, specifically, how to write truly stateless and functional services.

The bad example from him

Image description

This approach allows re-using the service object.
Image description

I believe the ability to do so is a negative, but even if you disagree, I will show how the reuse comes at too high a cost:

  1. The API becomes dispersed, some stuff in init, some in call. Where do I put a new parameter?
  2. Memoisation becomes impossible.
  3. Extract-method refactoring becomes cumbersome to impracticality because we have to pass everything as arguments.
def call(feed)
  # something very long
end

private

def split_out_of_call(feed)
end

def other_split(download_feed, some_loop_arg)
end
Enter fullscreen mode Exit fullscreen mode

The alternative

Let's lean into the functional aspect of a service, and disallow instantiation! This will remove the ability to reuse (an actual win in my book, YMMW), and allow us to use private instances as full black boxes:

class ImportProducts
  def self.call(...)
    new(...).call
  end

  # This is key. *Everything* about the instance is private.
  private  

  attr_reader :download_feed, :products_repo, :feed:

  # yes, even the initializer is private 
  def initialize(download_feed:, products_repo:, feed:)
    @download_feed = download_feed
    @products_repo = products_repo
    @feed = feed
  end

  # no params for #call
  def call
    # ...
  end

  def split_out_of_call
    # #feed implicitly available
  end

  def other_split(some_loop_arg)
    # #download_feed implicitly available
  end 
end
Enter fullscreen mode Exit fullscreen mode

And now in action

ImportProducts.(
  download_feed: download,
  products_repo: repo,
  feed: books_feed
)
Enter fullscreen mode Exit fullscreen mode

This way there is never an instance which might get some @errors populated to be accessed later. Everything needs to be in the #call return value, often a hash, but ideally a Struct.

Top comments (2)

Collapse
 
katafrakt profile image
Paweł Świątkowski • Edited

Your proposal on how to write service classes makes a lot of sense (I use this approach often as well). However, I'm not sure I agree that it's inherently better than what Tim suggested. And leaving better/worse debate aside, I'd say this way the services are actually less functional.

To your points:

The API becomes dispersed, some stuff in init, some in call. Where do I put a new parameter?

I think it's pretty simple distinction, really. Data as opposed to dependencies/infrastructure. When a new param comes, you just have to figure out which it is. This might actually lead to less convoluted APIs, if you have a lot of dependencies.

Memoisation becomes impossible.

That's good, actually. Memoization is overused in Ruby, leading to subtle errors. Most of the time you don't really need it, and if you do, you can overcome this limitation (for example with introducing another class, which is not a service class).

In any case, memoization is not really a functional thing, as it relies on an internal state of an object.

Extract-method refactoring becomes cumbersome to impracticality because we have to pass everything as arguments.

The argument-passing argument is true, can't disagree. However one might say that if that's a problem, perhaps you're not modeling enough (in functional terms). I mean, relying on object's internal state (but making it read-only) just to save some typing always felt quirky for me.

This way there is never an instance which might get some @errors populated to be accessed later.

True again, but also not very functional approach to populate classes internal state instead of putting error in returned value. I think both you and Tim would frown upon the private method just populating an instance variable during execution and something external relying on that.

That being said, I just stress again that I think both your approaches are good, just focus on slightly different aspects. And in the end it's just important to weigh the trade-offs and choose the one that works better for you.

Collapse
 
epigene profile image
Augusts Bautra

Thank's for the comment, Paweł. You are right, of course - there are no solutions, only trade-offs.