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
This approach allows re-using the service object.
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:
- The API becomes dispersed, some stuff in init, some in call. Where do I put a new parameter?
- Memoisation becomes impossible.
- 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
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
And now in action
ImportProducts.(
download_feed: download,
products_repo: repo,
feed: books_feed
)
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)
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:
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.
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.
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.
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.
Thank's for the comment, Paweł. You are right, of course - there are no solutions, only trade-offs.