Is there a place for `dry-dsl`?

Developing the library I announced recently, I took inspiration from dry-transaction in order to design and implement its DSL layer. I started using the very same implementation in dry-transaction and refactored it further until I got a clear model and understanding of what I was doing.

It can be summarized with the following pattern generalized for a library of name A:

  • include A just delegates to including A.(), which in turns delegates to including an A::Builder instance.

  • Consequently, a way to pass extra arguments is to directly include A.(args), which will pipe the arguments to the A::Builder#initialize method.

  • The instance of A::Builder will do two things to the host class:

    • Extend with an instance of a A::ClassContext.
    • Include an InstanceMethods module.
  • A::ClassContext instance will define the state of the host class, which comes from two different sources:

    • Original options passed to A.().

    • Any DSL it can define, which at the same time can be extracted to a A::DSLContext instance.

    Both types of states will be defined as singleton methods in the A::ClassContext instance, this way the host class will have access to them as it is being extended by it.

  • A::InstanceMethods will define an initializer which, along with any other needed processing, will take all the state defined at the class level (which as I said comes from A::ClassContext) and copy it as its own instance state.

  • A will become usable by initializing it with any required argument and then calling any method defined in A::InstanceMethods.

I won’t blame you I you haven’t read until here :smile: Anyway, you can see an example in my library:

What I want to re-mark here is that maybe here we have a common pattern which can be reused. The only variables which would change from library A to library B are:

  • State defined as arguments of Module.().
  • State defined in Module::DSLContext.
  • Extra arguments and state processing of Module::InstanceMethods#initialize.
  • Extra methods defined in Module::InstanceMethods

So, this message is to gather other views on this. Do you think that this model would encompass all the needs that arise when developing a DSL or, at least, be a good starting point which could be easily extended? Or am I being short-sighted here focusing only in the use case I faced? Do you think it could implement DSL’s used across dry-rb libraries? It would be a good thing in order to be consistent across libraries (right now they differ: include Dry.Types(), include Dry::Transaction, extend Dry::Initializer… maybe even < Dry::Struct?). It is not something I could work on right now, but it could be an idea for the near future…

Another nice side effect would be to help decoupling functionality from DSL.

I thought about this in the past but eventually decided it’s not worth the effort. Unifying DSL implementations would be a big effort with unknown benefits. It would also introduce a dependency for most projects, and even if we put it into dry-core having such a dependency would make upgrades harder. I also think it would be much more difficult than it may seem to be.

I’m not saying “absolutely no” though, it’s something to keep in mind and consider in the future (from my pov at least).

I can see how Transaction vs Initializer are inconsistent, so we could address this at some point. When it comes to Dry::Types() though - it is pretty much consistent with other module-builders like Dry::Equalizer(:foo, :bar) etc.

Thanks for your answer @solnic. I mainly agree with you:

Unifying DSL implementations would be a big effort with unknown benefits.

The DSL libary seems very easy to be implemented but, as you say, refactoring all the other libraries would be a lot of work. But I think it would give a very nice benefit: decoupling core functionality from DSL. For example, you can use the library I developed without the DSL. Surely very few people will, but this decoupling makes the code easier to refactor and evolve.

It would also introduce a dependency for most projects, and even if we put it into dry-core having such a dependency would make upgrades harder.

Yep, it would add a dependency but, as always, we should study whether it pays off or not. In my view, if a DSL abstraction makes sense and it works for all the scenarios, it definitely pays off. It is the same case that dry-configurable: it is a dependency, but it covers a very specific need that, otherwise, you end up programming one time and another. Being dry-rb a collection of libraries with function-objects in mind, dry-dsl would be the perfect place where all the state handling is encapsulated. All the other libraries could be 100% state-free (except for injection on initialization, which is the benefit of function-objects). But my big hesitation is whether a library like that can really encompass all use cases in an abstraction that makes sense.

I also think it would be much more difficult than it may seem to be.

Yep, I’m also very afraid of that :slight_smile:

I’m not saying “absolutely no” though, it’s something to keep in mind and consider in the future (from my pov at least).

Yep, and I’m not saying “absolutely yes” :slight_smile: It deserves some playground sessions…

When it comes to Dry::Types() though - it is pretty much consistent with other module-builders like Dry::Equalizer(:foo, :bar) etc.

But in this case it is Dry.Types, notice . vs ::.

I would prefer to wait with this type of efforts until all libs are at 1.0.0. To me this seems to be a very low priority for now.

Right, we should decide which syntax we prefer and use it consistently (Dry::Types() and Dry.Types() do the same thing).

1 Like