Inheritance or composition
This is going to be a long one, totally get it if nobody makes it to the end 😅
I'm writing an open-source library for writing durable workflows, similar in principle to Azure Durable Functions, where you can write persistent workflows using async/await. However, unlike Durable Functions, my library introduces a dedicated awaitable type called
DTask
.
Because DTask
requires infrastructural support, you can't just await one inside a regular async method. To run them, providers must implement its infrastructure components (for storage access, serialization, etc.). One of these components is called DAsyncHost
and, as of today, is an abstract class: it exposes and implements some methods to interact with these durable tasks (StartAsync
, ResumeAsync
), but also defines several abstract callbacks that implementations must override. For example, OnDelayAsync
determines how to handle DTask.Delay
, OnSucceedAsync
defines what to do when a durable task completes. Link to the class: https://github.com/gianvitodifilippo/DTasks/blob/main/src/DTasks/Infrastructure/DAsyncHost.cs
I went with an abstract class so I could provide virtual methods with default implementations and add new features in the future without breaking existing implementations (thanks to virtuals). However, I'm not a big fan of abstract classes and I usually prefer composition over inheritance, and I’m starting to wonder if DAsyncHost
should have been a concrete class that could instead be injected into those classes that today are its child classes. I'm struggling now even more because I want to add a new overload of the StartAsync
method that allows callers to pass a context object that the host can access during the whole execution. To do so, I thought of adding a type parameter to the type (DAsyncHost<TContext>
) and a new StartAsync(TContext context, ...)
method, but this started to feel like a smell to me.
What are your thoughts? Have you had some experience with similar designs?4 Replies
i may be wrong but adding a context seems like a big change, isn't it going to touch a lot of types?
A few. Today I have only
AspNetCoreDAsyncHost
, but I plan on implementing AzureFunctionsDAsyncHost
and external libraries may want to implement their own provider. I was wondering "what if they don't want/need the context" or "what if they want callers to pass extra arguments to their public methods"
Since the StartAsync
method returns ValueTask
, you can't run multiple DTask
objects in parallel using the same host, so I was also thinking of keeping it as it is and have implementations specify their own methods to pass extra values to the execution. Something like:
Your issue is that the implementation convenience is tied to the usage. Nothing stops you from having an abstract class for convenience of the users of your API. Internally, use an interface. Now these are two separate things and there's no more problem.
I think you pointed me in the right direction, I'll see how to apply this in my case. Thanks a lot!