A usable prototype for previewing Ember Polaris-style service injection.
// my-app/services/config.js – by convention only, can be wherever
import Service from 'ember-polaris-service';
export default class ConfigService extends Service {
get(key) {
/* ... */
}
set(key, value) {
/* ... */
}
}
import Component from '@glimmer/component';
import { service, singleton } from 'ember-polaris-service';
import Config from 'my-app/services/config';
import Store from '@future-ember-data-maybe/store';
class MyComponent extends Component {
@service(Config) config;
@service(Store) store;
// New power unlocked!
@service(singleton(Date.now)) now;
@service(singleton(window.localStorage)) localStorage;
// Use them normally:
get color() {
return this.localStorage.get('color') ?? this.config.get('color');
}
}
// In tests
import { override, singleton } from 'ember-polaris-service';
import Config from 'my-app/services/config';
test('some test', async function (assert) {
class MockConfigService {
#map = new Map();
get(key) {
return this.#map.get(key);
}
set(key, value) {
this.#map.set(key, value);
}
}
override(this.owner, Config, MockConfigService);
const frozenNow = Date.now();
override(this.owner, singleton(Date.now), singleton(() => frozenNow));
// ...
}
Important
Refers to the Interoperability section for more details.
- Ember.js v3.28 or above
- Embroider or ember-auto-import v2
ember install ember-polaris-service
Important
This addon is experimental in nature. The purpose is to collect real world feedback for the design direction of Polaris service injections. At this time, it represents our best guess to what the API could look like, but by its very nature, everything is subject to change. That being said, the addon is entirely in "userspace" code and does not use private APIs, so as long as it does what you need, it should be fairly safe to try it out in your app.
In Ember, services provide a robust mechanism for managing global shared states tied to the application's lifetime. Additionally, services are an ideal place to put logic and functionalities for managing and manipulating these states.
Unlike global variables or module states, services are intrinsically linked to the application's lifecycle and are cleaned up when the application is torn down, with the added provision to implement custom cleanup logic.
This architecture is particularly advantageous in scenarios like testing and environments like FastBoot, where application instances are created and torn down repeatedly, or when multiple applications or logical applications (Engines) coexist and operate within the same shared context.
Dependency injection (DI) is a technique that allows for writing code that are loosely coupled to its dependencies. Rather than hardcoding the dependencies and constructing them internally, your code will declare what dependencies it needs, and some external system will be in charge of locating, constructing and supplying these dependencies for your code at runtime.
For example, import
-ing a class from another module and instantiating it with
the new
keyword in your code creates a hardcoded dependency on the external
class. This is normally not a bad thing, as we will discuss further below.
On the other hand, in Octane, the @service
decorator is used to declare a
late-bound, injected dependency on a service by its name. This allows the
services to be externally configured at runtime, or swapped out entirely with a
compatible mock during testing.
Traditionally, Ember's dependency injection system underpins much of the framework's functionality. It consists of a few key parts:
- Container: This is a central repository for the application instance's objects. All services, routes, controllers and other classes in an Ember application instance are instantiated and managed by the container.
- Resolver: When you request an object by name, e.g.
service:storage
orcomponent:user/profile
, if it's not already there, the container delegates to the resolver to lookup the code/factory/class/constructor responsible for instantiating this type of object. - Registry: Alternatively, constructors (or instances, for singletons) can be registered directly with the registry by name. At runtime, the container will first consult the registry before performing a resolver lookup, and if a registration is present, it will bypass the normal resolver behavior.
- Owner: An interface that combines the registry and container API, which
is to say, it's an object with a
register()
andlookup()
method on it. Under-the-hood, this is typically either an application instance or an engine instance. Every object that wishes to participate in the DI system would need to be assigned an owner during construction (with thesetOwner
API). - loader.js: While strictly speaking not part of the DI system, it is an
important participant in this system for a modern Ember app. The classic
ember-cli build pipeline compiles all your modules into AMD format with their
full module paths retained verbatim in your application bundler. At runtime,
the default
ember-resolver
translates the logical names into conventional module paths andrequire()
the relevant modules.
Piecing these together with an example:
- Your class is instantiated and an provided with an owner.
- The
@service foo;
decorator runs. By default, the name of the field is used, so this translates intoowner.lookup('service:foo');
. - The container delegates the request to the resolver. On
ember-resolver
, that translates intorequire("my-app/services/foo");
- Alternatively, at some point before the first lookup for this name, you
could have done
owner.register('service:foo', SomeService, ...);
, in which case the resolver is bypassed here and the service will be instantiated with theSomeService
constructor instead. This may be helpful during testing, for example.
Historically, the DI system isn't just used for services, it underpins pretty
much every aspect of the framework. {{some-helper}}
in the template? Lookup
helper:some-helper
on the container. store.createRecord('user', { ... })
?
Lookup model:user
to find the constructor and pass the arguments along.
Furthermore, a lot of these behavior are customizable. For example, you could
supply a custom resolver to tweak the module path conventions. In fact, even
the default ember-resolver
supports multiple module naming conventions (ahem,
pods) – in the examples above, in each case it would actually attempt
various other paths first before landing on the standard path. In fact, it
doesn't even have to be from loader.js
, or be based on modules at all. The
globals-resolver looked things up on global variables, for
example.
All in all, the flexible architecture of the traditional DI system has served us well over the years. It facilitated conventions-over-configuration paradigm and can be credited as one of the key reasons that we managed to survive and make the transition from the good of days of scripts, globals, naïve file concatenations to the world of modules and modern JavaScript development.
The traditional DI system, while comprehensive and versatile, does come with a number of drawbacks:
- Learning Overhead: Developers new to an Ember app have to first learn and become fluent with the naming conventions before they can be productive with Ember, especially in apps that uses non-standard conventions.
- Tooling Integration: Likewise, tools also need to be taught/configured to understand these conventions, if at all possible. Without these, things like TypeScript or refactoring tools in your editor will not work.
- Performance Overhead: Having to perform these container/resolver lookups for everything at adds a significant cost at runtime – not to mention the cost and garbage created by parsing, splitting, joining and translating all these strings for the various names.
- Dynamism: While the vast majority of Ember apps simply follow the default naming conventions, the fact that the system allows for the possibility for arbitrary customizations means that there is no way to tell where things are really located without running the code in the browser, which makes it almost impossible to things like dead code elimination correctly. Many errors are only reportable at runtime and often go unnoticed.
- Complexity: The myriad of moving parts and the numerous paths objects can take to be looked up or registered can be daunting for newcomers and even experienced developers, especially when debugging issues in this area. It can also make refactoring, especially renaming, moving or deleting code, more delicate.
In light of these challenges, it's worth considering how the traditional DI system need to evolve to better align with modern JavaScript best practices, improve performance, and reduce complexity while still maintaining the core strengths of the framework.
The future direction of managing Ember application's code dependency lies in shedding the complex DI system in favor of straightforward explicit imports. Here's why this makes sense:
- Necessity of DI: For most objects, such as with components and helpers, DI isn't even required, nor desirable. It's a rarity to find an instance where someone would mock components in tests (in fact, we recommend against that), rendering this flexibility unwarranted.
- Flexibility in Conventions: By pivoting to explicit imports, the Ember convention becomes less prescriptive and more suggestive. While it's still beneficial to adhere to the Ember convention, diverging from it won't result in things breaking. This flexibility erases the need for the customizability.
- Verbosity vs Clarity: Granted, explicit imports might be slightly more verbose than relying on conventions. However, the shift towards explicitness in this area aligns with the broader JavaScript ecosystem — you already need to do that when importing functions from third-party libraries, for example. Modern tooling, with features like auto-import suggestions, has evolved to mitigate verbosity.
- Tooling Integration: Virtually all modern tooling natively understands and are built-around imports and Node's resolution rules. By aligning with these ecosystem trends, we unlock the possibility to benefit many such tools without any additional configuration.
The is an ongoing effort to reform Ember's API to eschew string-based lookups,
with the <template>
tag (.gjs
/.gts
) in the rendering layering being the
most notable example.
In the meantime, Embroider serves as a bridge to codify (literally in computer code) any remaining necessary conventions to help tools understand our code. However, the endgame for Ember in the upcoming Polaris Edition is to completely eliminate string-based lookups.
Despite the broad adoption of explicit imports, there's one domain where the principles of DI remain as relevant as ever: services.
As discussed earlier, services are quintessentially about managing shared states that spans across the codebase and the runtime lifecycle of the application. They are specifically designed to be late-bound and swappable at runtime (in particular, during testing), so they demand a mechanism to be injected where needed, reinforcing the significance of a DI system.
That being said, even within the realm of services, explicit imports still play a crucial role. These imports act as static annotations for inter-module code dependencies. Such annotations are paramount for static analysis optimizations such as code-splitting and tree-shaking. Without them, we would have no way to tell where these services are being used, and are forced to preemptively load every service in the initial payload regardless of its immediate necessity.
In essence, while the broader Ember programming model shifts towards explicit imports, there remains a need for a DI system in Ember to facilitate service injections. The challenge, and the path forward, is to devise a leaner, more streamlined DI system solely tailored for services, while also leveraging the strengths of explicit imports to optimize performance and code manageability.
Important
While this is the most common and convenient way to define a service, it is
not required or the only way to define a service. The primitives do not make
any assumptions about services subclassing from Service
.
import Service from 'ember-polaris-service';
export default class MyService extends Service {
// ...
}
The Service
super class takes care of some necessary boilerplate to ensure
the the object is proper setup for injections, but otherwise does not do much
else. It is pretty much a drop-in replacement for the existing Service
class
in Ember, except it does not inherit from Ember.Object
. Notably, that means
it does not have a special willDestroy
lifecycle hook. If cleanup logic is
needed, the registerDestructor
API from @ember/destroyable
can be used
instead.
import { registerDestructor } from '@ember/destroyable';
import { tracked } from '@glimmer/tracking';
import Service, { type Scope } from 'ember-polaris-service';
// Converts resize events into reactive @tracked properties
export default class ViewportService extends Service {
@tracked width = window.innerWidth;
@tracked height = window.innerHeight;
constructor(scope: Scope) {
super(scope);
const listener = () => {
this.width = window.innerWidth;
this.height = window.innerHeight;
};
window.addEventListener("resize", listener);
registerDestructor(() => {
window.removeEventListener("resize", listener);
});
}
}
Note
An complimentary RFC to add a @destructor
decorator may be beneficial here,
and would also benefit other kind of use cases outside of services.
import Component from '@glimmer/component';
import { service } from 'ember-polaris-service';
import MyService from 'my-app/services/my-service';
export default class MyComponent extends Component {
@service(MyService) myService;
}
Important
When Stage 3 decorators are available, using the @service
decorator with
the accessor
keyword is recommended. This allows the service to be lazily
looked up on first access, rather than eagerly during class instantiation.
import Component from '@glimmer/component';
import { service } from 'ember-polaris-service';
import MyService from 'my-app/services/my-service';
export default class MyComponent extends Component {
@service(MyService) accessor myService;
}
Important
Using the decorator form in TypeScript requires additional type annotation.
Legacy "experimental" decorators:
import Component from '@glimmer/component';
import { service } from 'ember-polaris-service';
import MyService from 'my-app/services/my-service';
export default class MyComponent extends Component {
@service(MyService) declare myService: MyService;
}
Stage 3 decorators:
import Component from '@glimmer/component';
import { service } from 'ember-polaris-service';
import MyService from 'my-app/services/my-service';
export default class MyComponent extends Component {
@service(MyService) accessor myService!: MyService;
}
An alternative functional form is also supported:
import Component from '@glimmer/component';
import { service } from 'ember-polaris-service';
import MyService from 'my-app/services/my-service';
export default class MyComponent extends Component {
myService = service(this, MyService);
}
This eliminates the need for the type annotation, but the downside is it forces an eager lookup during class instantiation.
We are looking to hear your feedback on the real world DX implications in this area. The rest of this document will use the functional form.
Note that, intrinsically, this requires importing the service into scope, which
provides the module dependency linkage needed for tree-shaking, etc. However,
rather than hardcoding the instantiation of the dependency (new MyService()
),
the service()
helper provides the needed indirection for the dependency to be
late-bound and injected at runtime, and can be swapped out as needed.
Traditionally, services are looked up based on a string key on the owner, so
in effect they are instantiated and cached once per the logical key tuple of
(owner, name)
.
In the new design, services are looked up based on the logical key tuple of
(owning-scope, service-token)
.
interface Scope {
// intentionally left blank
}
The "owning scope" of service is directly analogous to the "owner" concept in the traditional DI system. The same service will only ever be instantiated once within the same owning scope. Once instantiated, they will live for the rest of the scope's lifetime and cleaned up (running any registered destructors) when the owning scope is destroyed.
Typically, this will be the application instance, in which case things work
exactly the same as they do today, with the exception that the owner will no
longer have methods like lookup()
and register()
.
In fact, as illustrated by the TypeScript interface, the only requirement is
that it is some kind of object (i.e. a valid WeakMap
key).
This means that, while services are typically scoped to the lifetime of the
application, they are no longer required to be. For example, you can now
instantiate services that are scoped to a route or component, for example (see
the scoped()
helper). Any child route or component can and will share the
same instance of the service as long as they pass in the same scope object in
the lookup, and the service will be torn down with the owning scope.
function setScope(object: object, scope: Scope): void;
function getScope(object: object): Scope | undefined;
Note
Alternatively, we could repurpose the concept of "owner" to fit this new,
narrower meaning and retained the name setOwner
and getOwner
. However,
we can't just use the existing setOwner
and getOwner
API, because things
currently expect the owner to meet the { register, lookup }
interface, so
that transition would have to be managed somehow.
Analogous to setOwner
and getOwner
in the traditional DI system, these
primitives are used to manage the scope of the an object. This has to be done
fairly early during construction, before any injected services are accessed, as
the scope is a necessary part of the lookup key.
Typically, if you are subclassing from a framework-provided class, you would not have to do this step manually, as it is typically handled for you in the super class's constructor.
Important
The current implementation of getScope
fallbacks to getOwner
, so that we
could seamlessly interoperate with the existing construction protocol in the
framework. In other words, the new service lookups will continue to work on
any classes/context where the @service
decorator would have worked today.
The "service token" is the second part of the logical key tuple for a service lookup. It is combines the string-based "name" of a service and the service factory in the traditional DI system. It serves a few purpose:
- It uniquely identifies the service (implied by being part of the lookup key)
- It tells the DI system how to instantiate the service
- It links up the inter-module code dependencies
Typically, this token would be the service class, which makes sense as it fits all three of those purposes – it's clearly identifies the service, it's obvious how to instantiate the service from the class, and since you'll need to import the service class into the consuming module, it links up the code dependency as well.
// private
declare const INSTANTIATE: unique symbol;
interface ServiceFactory<T> {
[INSTANTIATE]: (scope: Scope) => T;
}
From the runtime DI system's perspective, the only actual requirement is that
we can figure out how to instantiate the service from this token. The internal
protocol for this is that the token should have an [INSTANTIATE]
symbol on
it, with a function that takes the scope as the only argument and returns the
instantiated service.
However, the [INSTANTIATE]
symbol is un-exported and private to Ember. You
would never set this directly. Instead, you would use the manager pattern:
import { setServiceManager } from 'ember-polaris-service';
interface Configurations {
site: {
name: string;
brandColor: string;
isPrivate: boolean;
};
user: {
locale: string;
timezone: string;
};
}
function getConfig(): Configurations {
return {
/* ... */
};
}
// The following code allows the `getConfig` function to be used as a services.
export default setServiceManager(
() => ({
createService() {
return getConfig();
},
}),
getConfig,
);
To inject this service:
import Component from '@glimmer/component';
import { service } from 'ember-polaris-service';
import getConfig, { type Configurations } from 'my-app/services/config';
export default class MyComponent extends Component {
// The type annotation here is unnecessary and included for clarity in this
// example only, type inference would have worked correctly here
config: Configurations = service(this, getConfig);
}
Important
This example is provided for illustrative purposes only. In practice, it can
be simplified using the factory()
helper, which handles the boilerplate for
you, rather than juggling the manager pattern yourself.
As with other manager APIs, there is a fair bit of verbosity required here. It
is quite uncommon for developers to need to use these primitive API directly.
The intention is to provide these verbose but maximally flexible primitives as
building blocks for higher-level abstractions such as the Service
super class
and the singleton()
helper.
The complete manager design borrows from and is consistent with other managers in Ember:
interface ServiceManager<D extends object, T> {
createService(definition: D): T;
}
interface ServiceManagerFactory<D extends object, T> {
(scope: Scope): ServiceManager<D, T>;
}
function setServiceManager<D extends object, T>(
factory: ServiceManagerFactory<D, T>,
definition: D,
): D & ServiceFactory<T>;
A service manager is an object with a createService
method, that accepts the
service token as an argument, and instantiates the service from that. This can
be extended to have more capabilities in the future, but that is currently the
only requirement.
The setServiceManager
function attaches the manager to the token, by way of
defining the internal [INSTANTIATE]
on the token object, which makes it a
ServiceFactory
. For convenience, it returns the same object back to its
caller.
Note that, rather than setServiceManager
accepting the manager directly as
argument, it itself uses a factory pattern and takes a callback that returns
the manager instead. This is for consistency with the other managers design.
In a lot of cases, this isn't needed, but it can come in handy if the manager
has states it wants to associate with the owning scope, or that it itself has
clean up logic that needs to be executed when the scope is destroyed.
Note that the token is passed in to createService
may not be the same as
the one that is passed to setServiceManager
. This is because [INSTANTIATE]
,
like any other properties in JavaScript, is inherited through the prototype
chain. This means, when calling setServiceManager
on a class, its subclasses
will inherit the same manager by default. Alternatively, setServiceManager
on
SomeClass.prototype
will make all instances of that class inherit this by
default.
For concrete examples, the Service
class and the singleton()
helper uses
these techniques under-the-hood.
function lookup<T>(scope: Scope, factory: ServiceFactory<T>): T;
The core primitive for looking up services is the lookup
function, which
accepts the owning scope and the factory and returns the instantiated service.
This is analogous to and a generalized version of owner.lookup('service:...')
in the traditional DI system.
Important
The key difference between the primitive lookup()
function and the surface
API service()
is in the first argument. In lookup()
, this argument is the
owning scope itself, whereas the first argument in service()
is an object
that is already associated with an owning scope. Essentially, service()
exists so that you don't have to write lookup(getScope(this), MyService)
.
function override<T>(
scope: Scope,
factory: ServiceFactory<T>,
override: ServiceFactory<T>,
);
The core primitive for overriding services is the override
function, which
accepts the owning scope, the original factory, and the override factory as
arguments. This is a leaner version of owner.register('service:...', ... )
in the traditional DI system.
Important
This must be done before the service is looked up for the first time. In development mode, an error will be thrown when attempting to override an already instantiated service.
With the new design, services no longer have to be placed in a specific
location on the filesystem, as they are resolved with actual imports. That
said, for most services, you probably do want to place them in the standard
app/services
folder.
Important
Beware that, by default, modules in the app/services
folder are always
included in the @embroider/compat
build. You can opt out, partially or
entirely, with the staticAppPaths
config option.
An modules in this folder are exposed to the traditional DI system, in that
they will be available for lookup via owner.lookup('service:$NAME')
or
equivalently via the Octane @service
decorator. However, by default, this
will fail when you subclass from the Service
class provided here, because
it does not have the same instantiation protocol the traditional DI system
expects.
If you want to make your new services available this way, you can import
from the compat
module, which provides a shim to bridge both systems:
// my-app/services/config.ts
import Service from 'ember-polaris-service/compat';
// This class works with `lookup(...)`, `service(...)`, but also the
// traditional `owner.lookup('service:config')` and `@service config`
export default class ConfigService extends Service {
// ...
}
In addition to the Service
shim, the compat
module also provides a version
of the @service
decorator.
import Component from '@glimmer/component';
import { service } from 'ember-polaris-service/compat';
import Config from 'my-app/services/config';
import Store from '@future-ember-data-maybe/store';
class MyComponent extends Component {
@service(Config) config;
@service(Store) store;
}
In addition accepting ServiceFactory
as argument, this hybrid @service
decorator also allows classic services – subclasses of @ember/service
. This
allows you to start linking up your module dependency graph and consistently
use the same style of service injection syntax across the codebase before
fully migrating to this new system.
When the hybrid @service
decorator encounters a non ServiceFactory
argument
it falls back to a string-based lookup on the traditional DI system, using the
property name as they key. Like the traditional @service
decorator, it also
optionally takes a second string argument for the lookup key, for cases where
the property key cannot be used to identify the service (e.g. there are slashes
in the service name).
Important
The service
function from the compat
module can only be used in decorator
form. It cannot be used in the functional service(this, MyService)
form. It
needs to see the property name for the traditional string-based lookup.
A common use case for services is to indirect access to global or third-party APIs, so that they can be easily stubbed out in tests.
The singleton()
helper exists to facilitate this pattern:
import Component from '@glimmer/component';
import { service, singleton } from 'ember-polaris-service';
import Config from 'my-app/services/config';
class MyComponent extends Component {
now = service(this, singleton(Date.now));
localStorage = service(this, singleton(window.localStorage));
}
The singleton()
helper adapts any object into a ServiceFactory<T>
that, by
default, simply return the same object during instantiation. It is "stable", in
that given the same object, it will always return the same wrapper. In other
words, singleton(Date.now) === singleton(Date.now)
. This makes it possible to
use the returned wrapper as the unique service token.
In this example, the component can consume the injected APIs like normal, such
as this.now()
or this.localStorage.set(...)
, etc. This behaves the same as
now = Date.now;
or localStorage = window.localStorage;
.
However, the extra indirection allows these APIs to be swapped out in tests without mocking/stubbing the globals directly:
// In tests
import { override, singleton } from 'ember-polaris-service';
import Config from 'my-app/services/config';
test('some test', async function (assert) {
class MockStorage implements Storage {
#map = new Map<string, string>();
getItem(key: string): string | null {
return this.#map.get(key) ?? null;
}
setItem(key: string, value: string): void {
this.#map.set(key, value);
}
// ...
}
override(this.owner, Config, MockStorage);
const frozenNow = Date.now();
override(this.owner, singleton(Date.now), singleton(() => frozenNow));
// ...
}
Note
While the singleton()
helper offers a quick way to convert arbitrary
objects into services, in many cases, it would be beneficial to carefully
consider your actual requirements and hand-craft a smaller, tailored service
class that delegates to these external APIs.
For example, the built-in Storage
interface includes additional things such
as key()
, length
, etc, which may not be relevant to your needs. By using
actual localStorage
as the service definition, your mock would also have to
implement these unnecessary details to be correct.
It may ultimately be more appropriate to have your own StorageService
class
that exposes only the set()
and get()
methods that uses localStorage
internally by default, and the mock can implement just those two methods.
Think about it this way: while you can just have a single "globals service"
singleton(window)
to rule them all, that is probably not a great idea!
While not mocking/stubbing globals is just good practice, this pattern becomes essential as we transition to strict ES modules, since module exports cannot be mocked/stubbed. This approach, on the other hand, would work just fine:
import Component from '@glimmer/component';
import { service, singleton } from 'ember-polaris-service';
import { shuffle } from 'lodash';
class MyComponent extends Component {
shuffle = service(this, singleton(shuffle));
}
// In tests
import { override, singleton } from 'ember-polaris-service';
import { shuffle } from 'lodash';
test('some test', async function (assert) {
function mockShuffle<T>(array: T[]): T[] {
return array.toReversed();
}
override(this.owner, singleton(shuffle), singleton(mockShuffle)));
// ...
}
The factory
adapter allows anything else to be convert into a service by
providing a factory function. For example, the getConfig
example above can
be simplified into:
import { factory } from 'ember-polaris-service';
interface Configurations {
site: {
name: string;
brandColor: string;
isPrivate: boolean;
};
user: {
locale: string;
timezone: string;
};
}
function getConfig(): Configurations {
return {
/* ... */
};
}
export default factory(getConfig);
import Component from '@glimmer/component';
import { service } from 'ember-polaris-service';
import getConfig, { type Configurations } from 'my-app/services/config';
export default class MyComponent extends Component {
// The type annotation here is unnecessary and included for clarity in this
// example only, type inference would have worked correctly here
config: Configurations = service(this, getConfig);
}
This can also be used to adapt arbitrary external APIs without converting them
into subclasses of Service
. Additionally, since the factory have access to
the scope, it can use that to lookup other services or register destructors as
well.
import { factory, lookup } from 'ember-polaris-service';
import getConfig from 'my-app/services/config';
function DateTimeFormat(scope: Scope): Intl.DateTimeFormat {
const config = lookup(scope, getConfig);
return new Intl.DateTimeFormat(config.user.locale);
}
export default factory(DateTimeFormat);
Whereas singleton()
allows using an existing value as a service, factory()
allows for deferring producing that value until it is needed. It essentially
allows you to attach a lazily-called function to the owning scope, and ensuring
once called, its return value will be cached for the lifetime of the owning
scope. Further, since the factory have access to the scope, it can lookup other
services or register destructors as well.
The scoped()
helper can be used to instantiate a service scoped to something
other than the inherited parent scope:
import Component from '@glimmer/component';
import { scoped, service, singleton } from 'ember-polaris-service';
import RandomNumberGenerator from 'my-app/services/rng';
class Game extends Component {
rng = service(scoped(this), RandomNumberGenerator);
}
In this example, the instantiated RandomNumberGenerator
service is be unique
to the particular component instance, and will be torn down when the component
instance is torn down. In effect, this allows multiple instances of a service
to be instantiated, across different parts of the application, with different
lifetimes attached.
In this example, service(scoped(this), RandomNumberGenerator)
has the same
effect as lookup(this, RandomNumberGenerator)
, in contrast to the typical
behavior of lookup(getScope(this), RandomNumberGenerator)
, which would have
looked up the shared RandomNumberGenerator
on the the application instance.
While, in this case, the same result could be accomplished with lookup()
instead of service()
, the scoped()
helper returns a wrapper object that has
the component instance set as its owning scope, and can be shared to outside
consumers without exposing the component instance itself.
For example, {{yield (scoped this)}}
can give other components a way to
lookup or instantiate additional services within the same scope, without giving
direct access to the component's internal states (fields, methods, etc).
Warning
One potential problem with this code is that it is virtually impossible to
override in tests – without additional coordination, there is no way to
obtain a reference to the component instance directly, which is required to
call override()
, and it also needs to happen early enough before anything
is looked up.
Perhaps a better approach for this component to take the "game scope" as an
argument, at least optionally one, and use this.args.scope ?? scoped(this)
for the lookup. That way, the test can setup an scope with the appropriate
overrides before calling the component.
In a way, the ability to create nested lookup solves some of the use cases
that a "context" API may be used for ("avoiding deep prop-drilling"). It
allows far away (in the call stack) to share a bunch of states by just
threading through a single scoped()
wrapper.
By definition, services cannot easily take arguments. For example, this does not work:
import Component from '@glimmer/component';
import { scoped, service } from 'ember-polaris-service';
import { SeededRandom } from 'my-app/services/rng';
class MyComponent extends Component {
// Don't do this!
rng = service(this, SeededRandom(12345));
}
While this is valid JavaScript, and can be made to do something, it most likely does not do what you want.
The purpose of services is to share state between multiple parties, and to do that they must all have a way to uniquely identify "the same piece of state" through the service token. When the token itself is dynamic, then no two places in the app would be able to share the "same" instance of the service.
Depending on what you are looking to accomplish, there are a few possible alternatives. If, in this example, you are looking to hardcode a well-known seed for the RNG, perhaps that can be made into a named subclass, which can be easily shared:
// app/services/rng
import { type Scope, factory } from 'ember-polaris-service';
class SeededRandom {
constructor(private seed: number) {
// ...
}
next(): number {
// ...
}
}
export const WellKnownSeededRandom = factory(() => {
return new SeededRandom(12345);
});
Alternatively, the configuration for a service can itself be injected:
// app/services/rng
import { type Scope, factory, lookup } from 'ember-polaris-service';
class SeededRandom {
constructor(private seed: number) {
// ...
}
next(): number {
// ...
}
}
export const RandomSeed = () => Math.random();
export const RandomNumberGenerator = factory((scope: Scope) => {
let seed = lookup(scope, RandomSeed);
return new SeededRandom(seed);
});
With the service token being a JavaScript value – as opposed to a string key in the traditional DI system – you are now able to control who has access to your service with standard JavaScript patterns.
Traditionally, in engines, we needed to create a nested, isolated registry and container for each engine. The purpose is such that engines can define their own services for the own shared state, without those states leaking out into the host app, and vice versa.
With the new design, this wouldn't be necessary. As long as the engine – or any
addon packages for that matter – keep their services private an unexported with
the "exports"
field in their package.json
, naturally, those services are
"isolated" to the engine. Likewise, it would be impossible for engines to
accidentally consume the host app's services, as it would have no way to gain
access to those service tokens in the first place.
In this world, privacy and access control comes down to privacy and access to
the JavaScript values, and they can also be deliberately shared using any means
that is appropriate to the situation – module exports, {{yield}}
s, argument
passing, global variables, etc.
Sometimes, service overrides are useful outside of tests, too. Let's say you have a service for consuming an external mapping API, something like Google Maps, but your team is testing out and contemplating switching to a different provider that offers a similar service for your needs.
You could define an abstract service class that describe the common interface:
// app/services/mapping/index
import Service from 'ember-polaris-service';
export class Coordinates {
constructor(lat: number, lng: number) {}
}
// If using JavaScript, you could do the same without the abstract keyword,
// just define an empty class with comments, or define the method with empty
// implementations that throws, expecting them the concrete implementations
// to override them.
export default abstract class MappingService extends Service {
abstract addressToCoordinates(address: string): Promise<Coordinates>;
abstract coordinatesToAddress(coordinates: Coordinates): Promise<string>;
// ...
}
This defines the abstract interface and a common service token that can be used to inject the mapping service in the rest of the app, regardless of which concrete implementation in used:
import { action } from '@ember/action';
import Component from '@glimmer/component';
import { service } from 'ember-polaris-service';
import MappingService from 'my-app/services/mapping';
export default class MyComponent extends Component {
mapping = service(this, MappingService);
async jumpTo(address: string): void {
let coordinates = await this.mapping.addressToCoordinates(address);
// ...do something with it...
}
}
To provide concrete implementations:
// app/services/mapping/google
import MappingService from '.';
export default class GoogleMappingService extends MappingService {
async addressToCoordinates(address: string): Promise<Coordinates> {
// ...use the Google API
}
async coordinatesToAddress(coordinates: Coordinates): Promise<string> {
// ...use the Google API
}
// ...
}
// app/services/mapping/osm
import MappingService from '.';
export default class OpenStreetMapsMappingService extends MappingService {
async addressToCoordinates(address: string): Promise<Coordinates> {
// ...use the OSM API
}
async coordinatesToAddress(coordinates: Coordinates): Promise<string> {
// ...use the OSM API
}
// ...
}
Note that, if nothing is importing these implementations, then they won't be included in the build. This can be a good thing – for example, if the new OSM implementations are only meant for internal testing on a special staging, you can include that only in that environment.
You do have to configure an actual implementation somewhere though, say in an instance initializer:
// app/instance-initializers/mapping
import { macroCondition, getOwnConfig, importSync } from '@embroider/macros';
import { override } from 'ember-polaris-service';
import MappingService from 'app/services/mapping';
export function initialize(owner) {
let implementation: typeof MappingService;
if (macroCondition(getOwnConfig().useOSM)) {
implementation = importSync('app/services/mapping/osm').default;
} else {
implementation = importSync('app/services/mapping/google').default;
}
override(owner, MappingService, implementation);
}
export default {
initialize,
};
This approach ensures only the necessary implementation is included.
Alternatively, you can include both and decide which one to use at runtime:
// app/instance-initializers/mapping
import { lookup, override } from 'ember-polaris-service';
import ConfigService from 'app/services/config';
import MappingService from 'app/services/mapping';
import GoogleMappingService from 'app/services/google';
import OpenStreetMapMappingService from 'app/services/osm';
export function initialize(owner) {
// ...or based on URL, query params, global variables, etc
if (lookup(owner, ConfigService).useOSM) {
override(owner, MappingService, OpenStreetMapMappingService);
} else {
override(owner, MappingService, GoogleMappingService);
}
}
export default {
initialize,
};
Note that, by doing this in an instance initializer, the service gets included into the initial bundle, as opposed to only in the bundles where the service is needed. This may be important if the service brings in a lot of code and is only needed in infrequently accessed part of the app.
An alternatively would be to put this selection logic inside the file where the service token is defined:
// app/services/mapping/index
import { type Scope, factory } from 'ember-polaris-service';
import ConfigService from '../config';
import MappingService from './abstract';
import GoogleMappingService from './google';
import OpenStreetMapMappingService from './osm';
export default factory((scope: Scope): MappingService => {
// ...or based on URL, query params, global variables, etc
if (lookup(scope, ConfigService).useOSM) {
return new OpenStreetMapMappingService(scope);
} else {
return new GoogleMappingService(scope);
}
});
That way, the module dependencies are all "wired up" – parts of the apps that depends on the mapping service will import this module, which in turns, import the relevant concrete implementation(s).
Finally, since all the operations on the service are inherently async in this case (as it talks to an external web service), another option is to load the relevant code on demand:
// app/services/mapping/index
import { type Scope, service } from 'ember-polaris-service';
import ConfigService from '../config';
import AbstractMappingService, { Coordinates } from './abstract';
export default class MappingService extends AbstractMappingService {
config = service(this, ConfigService);
async addressToCoordinates(address: string): Promise<Coordinates> {
return (await this.implementation()).addressToCoordinates(address);
}
async coordinatesToAddress(coordinates: Coordinates): Promise<string> {
return (await this.implementation()).coordinatesToAddress(coordinates);
}
// ...
private async implementation(): Promise<AbstractMappingService> {
if (this.config.useOSM) {
return await import('./osm');
} else {
return await import('./google');
}
}
}
See the Contributing guide for details.
This project is licensed under the MIT License.
Initial development of this addon and proposal partially funded by Discourse.