Skip to content

Explicit Service Injection - #502

Open
NullVoxPopuli wants to merge 15 commits into
emberjs:mainfrom
NullVoxPopuli:explicit-dependency-injection
Open

Explicit Service Injection#502
NullVoxPopuli wants to merge 15 commits into
emberjs:mainfrom
NullVoxPopuli:explicit-dependency-injection

Conversation

@NullVoxPopuli

@NullVoxPopuli NullVoxPopuli commented Jun 15, 2019

Copy link
Copy Markdown
Contributor

@webark

webark commented Jun 15, 2019

Copy link
Copy Markdown

@NullVoxPopuli Where does the class name come from? Is it just the path camel cased with the type tacked on? Would it be something you import? Is it the class name you define in the individual file that would get pulled out?

@flashios09

flashios09 commented Jun 15, 2019

Copy link
Copy Markdown

hi @NullVoxPopuli ,
i like the idea 👏
this will add some imports but it will make the code more clear/readable
i wish if we can do the same with a model
so instead of this:

this.store.findAll('post', '...')

we have:

@service(StoreService) store;
// i prefer the pluralized form, `posts` not `post` for model name
@model(PostsModel) posts;
// ...

doSomething() {
    this.store.findAll(this.posts, '...');
    // or
    this.posts.somePostsModelMethod('...');
   // maybe
   this.posts.findAll('...');
   // a class cased form for model name `Posts` or `PostsModel`
   this.PostsModel.findAll('...');
}

@buschtoens

Copy link
Copy Markdown
Contributor

A few tangents regarding TypeScript.

Currently I (we?) do this for TypeScript:

import Service, { inject as service } from '@ember/service';
import BarService from './bar';

class FooService extends Service {
  @service bar!: BarService;
}

Alternatively you can get around the extra import of BarService, if you provide the service name explicitly and use the old Ember Object Model:

import Service, { inject as service } from '@ember/service';

class FooService extends Service.extends({
  bar: service('bar')
}) {
}

This works because of the clever registry pattern. The reason you have to use the Ember Object Model is that decorators still can't yet change the type signature: microsoft/TypeScript#4881

Once they could, the following should type-check and infer the service class automatically:

import Service, { inject as service } from '@ember/service';

class FooService extends Service {
  @service bar;
}

For the time being, if you don't want to or can't use the Ember Object Model, but dislike explicitly importing the injtectee class, you could use the trick that we discussed in machty/ember-concurrency-decorators#50: Use a Babel transform to convert class property assignments to decorated properties.

import Service, { inject as service } from '@ember/service';

class FooService extends Service {
  bar = service('bar');
}

// gets transformed into

class FooService extends Service {
  @service('bar') bar;
}

I have a transform for it ready, but I need to battle-test and optimize it further:
babel-plugin-transform-class-property-assignment-to-decorator

@gossi

gossi commented Jun 15, 2019

Copy link
Copy Markdown

@buschtoens

Alternatively you can get around the extra import of BarService

if I understand this RFC correctly, it is about explicitely importing the respective class and exchange the string lookup for the class definition.

Second is: As much as we love typescript, this RFC must work for JS and as such the registry pattern from e-c-ts isn't available in pure js land. So with this RFC TS code will look like this:

import Service form '@ember/serivce';
import NotificationsService from 'my-project/services/notifications';

class Foo extends Service {
  @service(NotificationsService) notifications: NotificationsService;
}

at least until decorators can mutate the type definition for properties (then we can get rid of the doubled written class name).

@buschtoens

buschtoens commented Jun 15, 2019

Copy link
Copy Markdown
Contributor

@gossi This is exactly why I raised these points. While Ember is committed to not pushing TypeScript onto anybody and offering first-class support for JS, we are equally committed to offering first-class support for TypeScript as well.

I find it important that, as long as decorators cannot change types in TS or using a Babel transform is not a community-accepted and agreed upon opinion, we do not deprecate string lookups, as this could worsen ergonomics for some TypeScript users.


Edit: I actually believe we should never deprecate string lookups, as this would break the automatic inferral, when decorators can change signatures, i.e.:

import Service, { inject as service } from '@ember/service';

class FooService extends Service {
  @service bar;
}

@buschtoens

Copy link
Copy Markdown
Contributor

One further observation: The Ember Resolver / Container system is a bit of magic in the background. You don't always know where the backing injectee class is actually located or it might not be accessible to the code you are authoring.

There's also nothing preventing you from registering the same class with multiple names or even generating the names at runtime.

Admittedly these are uncommon edge cases.

@lougreenwood

lougreenwood commented Jun 15, 2019

Copy link
Copy Markdown

Also, by requiring explicit class name use, we're coupling the class to a specific service, but this shouldn't be a concern of the class:

Dependency injection is one form of the broader technique of inversion of control. The client delegates the responsibility of providing its dependencies to external code (the injector). The client is not allowed to call the injector code;[2] it is the injecting code that constructs the services and calls the client to inject them. This means the client code does not need to know about the injecting code, how to construct the services or even which actual services it is using; the client only needs to know about the intrinsic interfaces of the services because these define how the client may use the services. This separates the responsibilities of use and construction.
https://en.wikipedia.org/wiki/Dependency_injection

I mean, if we're going to import the ClassName, why not instantiate the service singleton and skip the service() container lookup all together.... But we all know that's a bad idea as it removes IoC.

So whilst I like the look of this as purely a nicer way to write TS classes in Ember right now - it's not really DI any more since the explicit class name is required (no more IoC).

So it seems that if the goal is to improve typing in TS, proper TS support for decorators will give us that.

For newbies that get confused by strings, maybe we need to better teach the newbies about DI and why a string name is about as explicit & accurate a DI lookup name should be to maintain proper separation.

@buschtoens

buschtoens commented Jun 15, 2019

Copy link
Copy Markdown
Contributor

@lougreenwood I totally agree with the point you're making regarding isolation of concerns.

However, I think @NullVoxPopuli was not aiming at better TypeScript support, but instead better "cmd+clickability" support for generic JS IDEs / users that don't use TypeScript.

Please correct me, if I am wrong.

@lougreenwood

lougreenwood commented Jun 15, 2019

Copy link
Copy Markdown

yeah - you're right, I was getting ahead of myself - sorry :D

But IMO, we also shouldn't start hacking apart established patterns for nicer IDE support - our solution should "work with the patterns" as well as "using the platform".

So if I understand JS & IDEs correctly... Since JS is not typed, and by it's nature DI de-couples - then there's no "platform" mechanism which is true to DI other than typing (and decorators changing function signature) which allows nice IDE integration?

@NullVoxPopuli

NullVoxPopuli commented Jun 15, 2019

Copy link
Copy Markdown
Contributor Author

@webark the class would be an import of the service itself :)

import Component from '@glimmer/components';
import { inject as service } from '@ember/services';
import MyService from 'appmame/services/my-service';

export default class extends Component {
  @service(MyService) myService;
}

:)

@NullVoxPopuli

Copy link
Copy Markdown
Contributor Author

@buschtoens the registry pattern could be used with classes as keys, couldn't it?

@NullVoxPopuli

Copy link
Copy Markdown
Contributor Author

then there's no "platform" mechanism which is true to DI

If only we had interfaces we could use instead :)

@NullVoxPopuli NullVoxPopuli changed the title Explicit Dependency Injection Explicit Service Injection Jun 15, 2019
@NullVoxPopuli

Copy link
Copy Markdown
Contributor Author

Renamed RFC due to technicality in DI definition

@buschtoens

Copy link
Copy Markdown
Contributor

the registry pattern could be used with classes as keys, couldn't it?

@NullVoxPopuli Yes, of course it could. But that's not the point I was trying to make. 🙂

Switching to a class-based registry / decorator and deprecating the string-based one, means that the future TypeScript ergonomics will be much worse, because you can't any more use the property name to infer the injection, as show in #502 (comment).

@NullVoxPopuli

Copy link
Copy Markdown
Contributor Author

Gotchya, so string lookup stays :)

@webark

webark commented Jun 15, 2019

Copy link
Copy Markdown

the class would be an import of the service itself

Thanks for clearing that up!

So there’s a pattern where you extend a dependencies service, to either overwrite or extend the dependent service.

When this is done, all of your existing references are updated.

With direct imports, would you always import a dependents service from the addons merged “app” space, even if it doesn’t exist? Or would you need to go and update all of your imports where you had imported them from the depended addon?

@NullVoxPopuli

Copy link
Copy Markdown
Contributor Author

would you always import a dependents service from the addons merged “app” space, even if it doesn’t exist? Or would you need to go and update all of your imports where you had imported them from the depended addon?

it should resolve to the same thing in both scenarios, afaik

@buschtoens

buschtoens commented Jun 16, 2019

Copy link
Copy Markdown
Contributor

With direct imports, would you always import a dependents service from the addons merged “app” space, even if it doesn’t exist? Or would you need to go and update all of your imports where you had imported them from the depended addon?

it should resolve to the same thing in both scenarios, afaik

An addon that has a service re-export in its app tree would make the service available in the host app, via the resolver, however the file would not be physically "there", meaning that "cmd+click" would fail, while the import would work at runtime.


I don't want to explode the scope here, but this segways into another interesting problem: What is the "official" way (pre and post this RFC) to override / clobber an addon's service (from another addon possibly)?

One way is using ember-addon.after in the package.json of the overriding addon to ensure that its app tree clobbers the app tree of the overridden addon. You could do the same from the host app as well.

One major hazard of this approach is emberjs/ember-cli-babel#240: If you use a file extension other than js, the clobbering might fail non-deterministically.

@NullVoxPopuli

NullVoxPopuli commented Jun 16, 2019

Copy link
Copy Markdown
Contributor Author

I don't want to explode the scope here, but this segways into another interesting problem: What is the "official" way (pre and post this RFC) to override / clobber an addon's service (from another addon possibly)?

maybe this RFC should be held off until embroider ships ;)
(though, I need to see if embroider would actually fix that problem)

but, I don't think we should continue to have clobbering of services. It's hard to debug.
if I import a service form an addon, I want that to be the actual location so I can ctrl/cmd click it and see what it is.

@webark

webark commented Jun 16, 2019

Copy link
Copy Markdown

An addon that has a service re-export in its app tree would make the service available in the host app, via the resolver, however the file would not be physically "there", meaning that "cmd+click" would fail, while the import would work at runtime.

this just goes against the main motivation of “enable "go to definition" support from service definitions so developers can more easily discover the where and how their service is defined.”

For using services in addons, it seems just as magical also, and more confusing around “is this a singleton” especially if you could import the same service from the invisible “app” space, and where it lives in the addon’s “addon” space in the same app.

@mehulkar

Copy link
Copy Markdown

This RFC should also probably provide some text about how to register/inject services in component rendering tests.

@runspired

Copy link
Copy Markdown
Contributor

@simonihmig and @pzuraq collectively have done a very good job summarizing my positions.

I fully agree with @pzuraq that there are things we should do to tighten up the existing expectations such that knowing where a service in use was declared is quickly discoverable, and that doing so is separate from changing the nature of the DI system (which this RFC would do largely by accident) or the syntax of using the DI system.

One thing I'd note on the value of strings-v-classes as keys that I think got glossed over is that were we to cleanup the expectations around where services can originate from then when you don't have a language server it is still quick to find the source (look in the services directory for the service of that name, if it's a re-export you can go-to-source from there). E.g. if we need a language server for either pattern (which we would) I feel strings come with the additional benefit of conveying the location to go look for the concrete implementation when that language server is not present.

@NullVoxPopuli

NullVoxPopuli commented Jul 9, 2019

Copy link
Copy Markdown
Contributor Author

I mean, really all I want is control+click. With strings, we need to make up our own tooling, which sounds like more work. :-\

@runspired

Copy link
Copy Markdown
Contributor

I had this discussion with @NullVoxPopuli out of band, but wish to summarize it here.

I mean, really all I want is control+click. With strings, we need to make up our own tooling, which sounds like more work. :-\

Moving to using classes as keys would not prevent us from using our own tooling nor save us any work. In either case we end up needing a language-server to ensure that control+click works.

For strings, we have to map strings to a class.
For classes, we have to map classes to other classes.

This remapping from class A to class B is likely to cause users a large amount of confusion. (Just imagine a new user being told that to "use service B" you must "import service A").

Why would this be?

Let's run through an example of some pitfalls with classes as keys.

Imagine the addon GoodCommunicator provides the service called central-hub, and in several of it's own other services or components it consumes that service.

export default class CentralHubService {
  dispatch() {}
  subscribe() {}
}

Let's say that GoodCommunicator is such a great addon that many other addons also try to coordinate some behaviors via the central-hub.

import CentralHubService from 'good-communicator/services/central-hub';

export default MyComponent extends Component {
  @service(CentralHubService) centralHub;
}

Still another addonGoodCommunicatorPlus realizes that the central-hub could use an upgraded protocol for dispatch, so it imports and extends the original class.

import CentralHubService from 'good-communicator/services/central-hub';

export default AdvancedCentralHubService extends CentralHubService {
   dispatch() {
      if (...) {
        // ....
      } else {
        return super.dispatch(...arguments);
      }
   }
}

And it (or another addon) uses this version of the service class:

import AdvancedCentralHubService from 'good-communicator-plus/services/central-hub';

export default MyComponent extends Component {
  @service(AdvancedCentralHubService) centralHub;
}

Now finally, a consuming application consumes GoodCommunicatorPlus as an addon and it decides to use the service, so it adds it to its services, adding in some config.

import CentralHubService from 'good-communicator-plus/services/central-hub';
import config from './hub-config';

export default ConfiguredCentralHubService extends CentralHubService {
   config = config;
}

The situation we are now in is that we have 3 distinct classes that all are serving as keys to what is intended to be the same service.

If the consuming application does nothing, then we would instantiate three separate services, two of which would be missing the desired configuration.

If however the consuming application wanted to ensure that we continued to only have a single service, it would need to remap the keys. Something like:

import CentralHubService from 'good-communicator/services/central-hub';
import AdvancedCentralHubService from 'good-communicator-plus/services/central-hub';
import ConfiguredHubService from '../services/central-hub';

export function initialize(application) {
  application.unregister(CentralHubService);
  application.unregister(AdvancedCentralHubService);
  application.register(CentralHubService, ConfiguredHubService);
  application.register(AdvancedCentralHubService, ConfiguredHubService);
};

export default {
  initialize
};

With this remapping complete, we would now be back to a single "central-hub" service.

But there remains a problem: the class we've used as a key in so many places is not the class that we've instantiated. Any "go-to-definition" features we take advantage of out of the box are going to send us to the wrong class for 2 of the 3 potential keys in this example. And remember, there's nothing preventing an application from using all 3 key variants (the two from addons and it's own).

In parting, a final concern:

This sort of keying by class is also problematic if someone desires multiple instances of a given service. By using the class as the key, we lose the ability to re-use the same class multiple times if desired.

@pzuraq

pzuraq commented Jul 11, 2019

Copy link
Copy Markdown
Contributor

@runspired I think your examples are exaggerating the issue a bit, as they don't use the API the way it is described by the RFC. If we were to move forward with this RFC, in order for any addon to override a service, it would have to manually reregister that service, so the addon that provides the AdvancedCentralHubService would require users to do something like this:

import CentralHubService from 'good-communicator/services/central-hub';
import AdvancedCentralHubService from 'good-communicator-plus/services/central-hub';

export function initialize(application) {
  application.register(CentralHubService, AdvancedCentralHubService);
};

export default {
  initialize
};

And the addon itself would inject the CentralHubService:

// good-communicator-plus/components/my-component.js
import CentralHubService from 'good-communicator/services/central-hub';

export default MyComponent extends Component {
  @service(CentralHubService) centralHub;
}

I'm not particularly worried about this pattern being difficult or smelly, because IMO it should be. Attempting to override a service from another addon like this should require some manual steps, because it is generally an antipattern - the functionality should be contributed up stream, ideally, or contained in a separate service that the downstream addon has full control over.

Now, your second example is a bit more where my concerns come from as well, because it isn't uncommon to have a service that should be configured, or an interface for a service that should be provided by the consuming application, and Ember Data is moving this direction so it'll become even more common. In these cases, it's unavoidable to have a different implementation, and this alternate implementation would need to be registered, and then tooling would have to know about that registration so it could Control+Click to the proper definition. While we could absolutely do this, we no longer get it for "free", like this RFC was hoping for.

I think if we were providing interfaces, this would be a more logical leap. Users would go-to the definition, see it was an interface, and then know they had to find the proper implementation. I agree that taking them to a class, even an "abstract class", probably isn't enough of a hint that there could be a subclass being used, and could result in a lot of confusion.

@sukima

sukima commented Mar 15, 2022

Copy link
Copy Markdown

Disclaimer: I haven't read this yet, But I did have a related yet independent idea.

What ii all service classes inherited a static inject method which could look up the singleton via a Map/WeakMap with the class itself being the key?

import MyService from 'my-app/services/my-service';


export default class MyComponent extends Component {
  @MyService.inject myService;
}

@wagenet

wagenet commented Jul 24, 2022

Copy link
Copy Markdown
Member

Where does this stand?

@NullVoxPopuli

Copy link
Copy Markdown
Contributor Author

@chancancode has a user-land addon that explores this space. 🤔

and @wycats has some thoughts, too.

There is also: emberjs/ember.js#20095

  • where I said I'd try to prototype this out soon (though, in that comment, I said not ™️, but it's def ™️ at this point) -- I got distracted

@wagenet wagenet added the S-Proposed In the Proposed Stage label Dec 2, 2022
@wagenet wagenet added S-Exploring In the Exploring RFC Stage and removed S-Proposed In the Proposed Stage labels Feb 10, 2023
@wagenet

wagenet commented Feb 10, 2023

Copy link
Copy Markdown
Member

This is being moved to the Exploring state.

@lifeart

lifeart commented Oct 16, 2023

Copy link
Copy Markdown

While reading https://netbasal.com/lazy-load-services-in-angular-bcf8eae406c8

I think we could have something like this https://gist.github.com/lifeart/fbcc7bd8747562aa85d79b42ca991493

In short, all properties on lazy service may be promisified by default, with ability to call toSync to get "reference" service object.

thanks to @NullVoxPopuli navigating me here.

Some version of provided gist:

class Bar {
    doSomething() {
        console.log('do something');
    }
    name: string;
}

type PromisifyProps<T> = {
    [P in keyof T]: T[P] extends (...args: infer A) => infer R ? (...args: A) => Promise<R> : Promise<T[P]>;
};

// a function to accept service load using import
// and it should return same type as service but all it's methods and properties should be promises
// we can use this function to make lazy loading of services
// under the hood we use proxy to make all methods and properties to be promises
function lazyService<T extends object>(service: () => Promise<T>): PromisifyProps<T> & {
    toSync(): Promise<T>;
} {
  let loadedService: T;
  const proxy = new Proxy({}, {
    get(_, prop) {
      return new Promise(async (resolve, reject) => {
        if (!loadedService) {
          try {
            loadedService = await service();
          } catch(e) {
            reject(e);
          }
        }
        if (prop === 'toSync') {
            return resolve(loadedService);
        }
        const value = Reflect.get(loadedService, prop);
        if (typeof value === 'function') {
          return resolve((...args: any[]) => Promise.resolve(value.apply(loadedService, args)));
        } else resolve(value);
      });
    },
  });

  return proxy as PromisifyProps<T> & {
    toSync(): Promise<T>;
  }
}

class Foo {
    bar = lazyService<Bar>(() => import('./bar'));
    async onClick() {
        // auto-load and invoke service method
        await this.bar.doSomething();
        // get service property
        const name = await this.bar.name;

        // convert async service to sync (auto-load)
        const sync = await this.bar.toSync();
        const secondName  = sync.name;
        
    }
}

@ef4

ef4 commented Oct 16, 2023

Copy link
Copy Markdown
Contributor

While there's nothing that would preclude that kind of auto-lazy service for someone who wanted it, I don't think it makes much sense as a default or encouraged pattern. There are almost always better places to do the lazy loading.

For example: if the service will only gets used based on certain URLs, it would get taken care of automatically by route-based splitting, with no need to juggle promises in the component.

For another example: if a service is only needed when the user clicks on a particular button, it's actually not great to do the lazy loading after they click. That puts the loading into their perceptible critical path. It's better if the service is already loading the background before they click. It costs almost nothing as long as you do it after rendering the critical path. For that you'd probably want the service itself to have a small shim that's part of the initial payload that is responsible for lazy loading the rest, preferable using idle timing (which would be nice to do given something like #957).

@ef4

ef4 commented Oct 27, 2023

Copy link
Copy Markdown
Contributor

This was discussed at the spec meeting this week, the main open point of discussion was wether we want to keep the decorator syntax despite typescript still not inferring it, or use a field assignment syntax instead that would infer correctly (with an explicit this):

  cookie = service(this, CookieService);

@chancancode

chancancode commented Oct 31, 2023

Copy link
Copy Markdown
Member

Expanding on @ef4's comment above:

Today, service lookups are keyed on the tuple (owner, "string-key"). This means that:

  1. we can't easily tell from the import graph which services are needed by what modules, thus we have to eagerly load/register all services upfront
  2. in order to provide services that are scoped to engines (and that is the only kind of scoping we provide today), we have to create a "child owner" for each engine (the first part of that key tuple)

In Polaris, we plan to solve both of these problems by changing the lookup key to (owner, SomeJavaScriptValue).

  1. in order to inject the service, you would have to acquire it somehow, typically by importing the module where the service is defined
  2. standard JavaScript features allows you to control the scoping of the service, for example:
    1. un-exported values can only be used locally with the module
    2. exports map in package.json allows you to keep the value private within the package
    3. packages can ensure they share (or not) services with the appropriate peerDependencies relationship
    4. you can always register that as a global, pass it as argument, {{yield}} it to child components, etc

In terms of practical day-to-day usages, it will probably look something like this:

// app/services/session.js
import { setOwner } from "@ember/owner";
import { tracked } from "@glimmer/tracking";

export default class SessionService {
  // Alternatively, we can still provide a superclass in the framework
  // to deal with this boilerplate, just have to be a different import
  // and not subclassing from `Ember.Object`
  constructor(owner) {
    setOwner(this, owner);
  }

  @tracked currentUser = null;
}
// app/components/menu.js
import { service } from "@ember/service";
import Component from "@glimmer/component";
import Session from "my-app/services/session";

export default class Menu extends Component {
  @service(Session) accessor session;
}

...or...

// app/components/menu.js
import { service } from "@ember/service";
import Component from "@glimmer/component";
import Session from "my-app/services/session";

export default class Menu extends Component {
  // About the same number of characters but possibly work better with TypeScript today
  session = service(Session, this);
}

For the primitives, we will need:

// The definition of "Owner" in this context is different/more relaxed than the traditional one
// We only need it to be a WeakMap-key-able, that should probably be rectified across the framework
export type Owner = object;

export type ServiceDefinition<T> = /* ...defined later... */;
export type ServiceInstanceType<S extends ServiceDefinition<any>> = /* ...defined later... */;

type InstantiatedServices = WeakMap<ServiceDefinition<any>, ServiceInstanceType<ServiceDefinition<any>>>;
const Services = new WeakMap<Owner, InstantiatedServices>;

export function lookupService<S extends ServiceDefinition<any>>(owner: Owner, definition: S): ServiceInstanceType<S> {
  let services = servicesFor(owner);
  let service: ServiceInstanceType<S> | undefined = services.get(definition);

  if (service === undefined) {
    service = instantiate(owner, definition);
    services.set(definition, service);
  }

  return service!;
}

type OverriddenServices = WeakMap<ServiceDefinition<any>, ServiceDefinition<any>>;
const Overrides = new WeakMap<Owner, OverriddenServices >;

export function overrideService<S1 extends ServiceDefinition<any>, S2 extends ServiceDefinition<any>>(owner: Owner, definition: S1, override: S2) {
  if (DEBUG && servicesFor(owner).has(definition)) {
      throw new Error(`Cannot override service ${inspect(definition)} after it has already been instantiated`);
    }
  }

  let services = servicesFor(owner);
  let service: ServiceInstanceType<S> | undefined = services.get(definition);

  if (service === undefined) {
    service = instantiate(owner, definition);
    services.set(definition, service);
  }

  return service!;
}

function servicesFor(owner: Owner): InstantiatedServices {
  let map = Services.get(owner);
  
  if (map === undefined) {
    map = new WeakMap();
    Services.set(owner, map);
  }

  return map;
}

function instantiate<S extends ServiceDefinition<any>>(owner: Owner, definition: S): ServiceInstanceType<S> {
  /* ...defined later... */
}

On top of which we can build the convenience API.

From the primitive's perspective, we can accept any WeakMap-key-able (object) as a "service definition" (or "service key"). However, we need to know how to instantiate that key into the service instance. Usually, we would have some kind of manager for this purpose. On the other hand, we don't really care what the instance type is – for example, boolean as a service (service(isMobile, this) service) should work just fine.

@ef4

ef4 commented Nov 3, 2023

Copy link
Copy Markdown
Contributor

Linking to @chancancode's addon which explores this design and has some discussion issues around open questions: https://github.com/chancancode/ember-polaris-service

@ef4

ef4 commented Feb 9, 2024

Copy link
Copy Markdown
Contributor


#### the hierarchy check

Logic will be added to the register method to ensure that the lookup type either is the same as the service instance's type or is an ancestor type. This will prevent the ability to register unrelated classes that would break the implied class hierarchy that is assumed with dependency injection.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there may be cases where people do not want or care about static typing and would like to rely on "duck typing" -- any object that responds to the "quack()" message shall be a "duck".

should the validation be configurable globally or per registration? or is it per lookup?

e.g., in testing, i might inject a sinon.mock() instance that is programmed to respond to a subset of methods and i might not use the abstract base class at all.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

duck typing specifically I think has to be a separate RFC, because achieving that in a language that doesn't support such a thing will take an RFC as long as this one.

I've called this "looking up by shape" elsewhere in this existing RFC

programmed to respond to a subset of methods and i might not use the abstract base class at all.

that is fine as long as the key is the same, and/or the key-hierarchy is the same. the keys are independent of the implementation (outside of initial instantiation)

@NullVoxPopuli

Copy link
Copy Markdown
Contributor Author

Some implementation / example demos here: https://github.com/NullVoxPopuli/ember-rfc-502-playground/tree/main/app/demos

@BoussonKarel

Copy link
Copy Markdown

When I click on the Rendered link of this issue, it's throwing a 404:
image

@runspired

Copy link
Copy Markdown
Contributor

Important design issues uncovered by experimentation work:

something I'd note - no referential token intended to be used by multiple packages is safe. Even same version, if a dep is ever duplicated you have a different token. There are only 2 exceptions to this: Symbol.for (not Symbol) and strings. Also note that Symbol.for is not safe from a typescript perspective - only strings satisfy the unique reference + unique type constraint if you care about the potential for code duplication.

We hit this a lot in WarpDrive, and ended up writing special utils to handle defining and retrieving references for objects, strings and symbols. (For objects, we keep a global registry with a string key, first reference registered wins).

@NullVoxPopuli

NullVoxPopuli commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

for all situations where you would have duplicates, you fall in to two categories

  • the project's deps are messed up (either peer, externals, or package manager issue)
  • you do want separate copies of services, because perhaps you have multiple majors / versions of a dependency (and thus the service it provides), and the code for those multiple dependencies is only written for the specific version of the service that was published with its major. if we were to use strings, whichever copy of the dep is out of date would fail with runtime errors

so, I dare argue the opposite: referential keys gets you more stability, even if it means you don't always get the same copy of a service that every copy of a dependency has (the problem is the duplicate deps)

@runspired

runspired commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

the project's deps are messed up (either peer, externals, or package manager issue)

this is aspirational but no package manager today is capable of creating accurate dependency trees (isolated or hoisted) without using duplicate copies of the same version of a package.

I'm not necessarily arguing for strings here - I'm just pointing out where things are likely to get a little rough. The ember ecosystem tends to do something I have not seen in any other ecosystem so far - have community packages that depend on other community packages as peers as part of one global ecosystem. That makes us far more susceptible to package reference issues imo than say tanstack or millionjs deps that tend to pull from their own ecosystem only.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

S-Exploring In the Exploring RFC Stage

Projects

None yet

Development

Successfully merging this pull request may close these issues.