Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Flexible service dependencies #14

Closed
mecha opened this issue Nov 24, 2022 · 3 comments · Fixed by #15
Closed

Flexible service dependencies #14

mecha opened this issue Nov 24, 2022 · 3 comments · Fixed by #15
Labels
question Further information is requested

Comments

@mecha
Copy link
Member

mecha commented Nov 24, 2022

Use Case

Scenario: You need to create multiple services that are class instances.

Example class:

class Link
{
    protected LinkStyle $style;
	protected string $url;
    protected bool $newTab;
}

Class instance services can be easily created using the Constructor class. However, this class only accepts service IDs as dependencies, leading to a lot of boilerplate code:

// Somewhere with service definitions

return [
    'privacy_policy/url' => new Value('https://example.com/privacy'),
    'privacy_policy/new_tab' => new Value(false),
    'privacy_policy' => new Constructor(Link::class, [
        'style/gray_button',
        'privacy_policy/url',
        'privacy_policy/new_tab',
    ]),

    'about/url' => new Value('https://example.com/about'),
    'about/new_tab' => new Value(false),
	'about' => new Constructor(Link::class, [
        'style/normal',
        'about/url',
        'about/new_tab',
    ]),

    'twitter/url' => new Value('https://twitter.com/example'),
    'twitter/new_tab' => new Value(true),
	'twitter' => new Constructor(Link::class, [
        'style/blue_button',
        'twitter/url',
        'twitter/new_tab',
    ]),

	// and so on ...
];

Each Link service needs 3 accompanying services to be declared, for the style, url and newTab dependencies. The above example omits styles to contrast them against the simpler case: when some dependencies (styles) have a valid reason for being declared as services, while others (URL and new-tab) are too simple to justify.

It may be argued that declaring everything as a service should still be desired, as it leads to more flexibility by allowing the url and newTab services to be extended without needing to modify the corresponding Link service. I would normally be inclined to agree with such an argument.

But consider the simplistic nature of the above use case. It doesn't take much effort to imagine an application that isn't interested in this level of flexibility and would rather enjoy the brevity of not having to declare the services for the newTab dependency.

Such an application would rather be able to pass the dependency values directly:

return [
    'privacy_policy' => new Constructor(Link::class, [
        'style/gray_button',
		'https://example.com/privacy',
        false
    ]),

	'about' => new Constructor(Link::class, [
        'style/normal',
		'https://example.com/about',
		false,
    ]),

	'twitter' => new Constructor(Link::class, [
        'style/blue_button',
		'https://twitter.com/example',
		true
    ]),

	// and so on ...
];

The above is currently not supported. The alternative is to use Factory instead of Constructor, which is still quite cumbersome for large lists:

return [
    'privacy_policy' => new Factory(['style/gray_button'], function ($style) {
		return new Link($style, 'https://example.com/privacy', false);
    }),

	'about' => new Factory(['style/normal'], function ($style) {
		return new Link($style, 'https://example.com/about', false);
    }),

	'twitter' => new Factory(['style/blue_button'], function ($style) {
		return new Link($style, 'https://twitter.com/example', true);
    }),

	// and so on ...
];

The Challenge

Allowing arbitrary values to be passed as dependencies is not trivial, as this raises the obvious challenge:

How does the underlying service helper class know if a string is a service ID or just a plain string?

return [
	'foo' => new Constructor(Foo::class, ['hello'])
];

You may consider doing a simple $container->has() check with the string. If it returns true, it's a service ID. If it returns false, it's a value. But it could also be a typo in the service ID by the developer, or a misconfigured application or container. The implicit nature of this approach is not very ergonomic.

The natural "OOP" response is to turn dependencies or values into an abstraction. There are two approaches to consider:

1. Abstracting Dependencies

This would involve instantiating some objects to represent dependencies. For instance:

return [
	'twitter' => new Constructor(Link::class, [
        new Dependency('style/blue_button'),
		true
    ]),
];

This could dramatically increase the amount of boilerplate code since it's likely that dependencies will be more common than direct values. Of course, this can be shortened by providing a proxy function:

namespace  Dhii\Service;

function dep(string $id) {
	return new Dependency($id);
}
use function Dhii\Service\dep;

return [
	'twitter' => new Constructor(Link::class, [
        dep('style/blue_button'),
		true
    ]),
];

... but it's still not an ideal solution.

That said, it could lead to an interesting abstraction for how dependencies get resolved, by pushing the responsibility of the resolution to the Dependency itself. Imagine:

class Dependency
{
	protected string $key;

	public function resolve(ContainerInterface $c)
	{
		return $c->get($this-key);
	}
}

There may be something interesting to consider here.

2. Abstracting Values

This would be similar to dependency abstraction, but instead of abstracting dependencies, we'd abstract values.

class Value
{
	protected $value;

	public function resolve()
	{
		return $this->value;
	}
}

function value($value)
{
	return new Value($value);
}

And used in a similar way:

use function Dhii\Service\value;

return [
	'twitter' => new Constructor(Link::class, [
        'style/blue_button',
		value(true)
    ]),
];

The main obstacle here is that we already have a Value class in this package. But that may actually be more of a blessing than a curse. Perhaps we can reuse the Value class for this purpose.

return [
	'twitter' => new Constructor(Link::class, [
        'style/blue_button',
		new Value(true)
    ]),
];

But if we're going to do that, then we might as well open it up to any Service implementation. Or better yet, any callable.

The Proposal

I propose a solution that is built around the latter option. Service helper classes would accept dependencies in one of two forms: a service, or an ID that resolves to one.

In my opinion, this substitutability is pretty cool and quite intuitive. But if you need further convincing, I've prepared a full list of advantages:

  • Existing string dependencies remain unchanged. Full backward-compatibility!
  • Dependencies can be inlined to reduce the number of services required to use some of the helpers.
  • The Value service helper would serve 90% of inlining cases, but the other service helpers can be used in the same way.
  • Does not affect the __invoke() signature of the service helpers, maintaining compatibility with PSR-11 containers that are agnostic of this package.

These are the changes that would need to be made:

Thoughts?

@mecha mecha added the question Further information is requested label Nov 24, 2022
@mecha mecha changed the title Explicit dependencies Flexible service dependencies Nov 24, 2022
@Biont
Copy link
Collaborator

Biont commented Dec 2, 2022

Thank you for the enjoyable writeup. You can imagine that I would much appreciate this change. And yes I also ran into the situation where I considered (or even ended up?) creating otherwise unneeded services just to be able to use the Constructor shorthand over a Factory. And since "naming things is hard", stuff like this this adds undesired noise when you're just prototyping things to get one particular service working.

So apart from laying the groundwork for stacking more flexible definitions, this already has tangible benefits. I don't see a reason NOT to implement this, but I am of course biased.

@XedinUnknown
Copy link
Member

I see. Initially, I ithought you'd want to pass the value directly, and just decide that anything that's not a string gets treated verbatim. But this is even beter without big changes, and makes more use of existing implementations.

I would love to see a PoC PR to tackle this! 🙏

@XedinUnknown XedinUnknown linked a pull request Dec 20, 2022 that will close this issue
@XedinUnknown
Copy link
Member

I see it now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
question Further information is requested
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants