Skip to content

Tech Note: ServiceScope API

pgonzal edited this page Sep 14, 2016 · 8 revisions

We received some questions about the ServiceScope API, which is SPFx's implementation of the service locator pattern.

What is a "service"?

In this document, a "service" is a TypeScript object that will be discovered indirectly by the objects that want to consume it (a.k.a. "inversion of control"). Typically the service implements an interface contract that could have alternative implementations (e.g. a mock implementation for unit testing) or alternative instances (e.g. two different cache instances), however this is not required or definitive. The main point of ServiceScope is to allow the service to be constructed/configured in one place, and then passed down to a consumer in other place, without complicating the intermediary objects that it must pass through along the way.

Example 1: Implementing a service

If your service can have multiple implementations, you would typically start by defining a interface contract like this:

/**
 * This interface allows unit tests to simulate the system clock.
 */
export interface ITimeProvider {
  /**
   * Returns the current date/time.
   */
  getDate(): Date;

  /**
   * Returns a DOMHighResTimeStamp timing measurement, as defined by the
   * standard performance.now() API.
   */
  getTimestamp(): number;
}

Then you create the default implementation:

/**
 * This is the default implementation of ITimeProvider that simply
 * calls the real browser APIs.
 */
export default class TimeProvider implements ITimeProvider {
  constructor(serviceScope: ServiceScope) {
    // (this constructor is currently unused, but it is required by the
    // ServiceKey.create() contract)
  }

  public getDate(): Date {
    return new Date();
  }

  public getTimestamp(): number {
    return performance.now();
  }
}

Lastly, you define a service "key", which consumers will use to lookup the service:

export const timeProviderServiceKey: ServiceKey<ITimeProvider>
  = ServiceKey.create<ITimeProvider>('sp-client-base:TimeProvider', TimeProvider);

Note that the key is associated with a default implementation. Currently, this is mandatory. Guaranteeing a default implementation avoids runtime errors that would otherwise occur when the component is loaded in an environment that does not know how to provide a service. If consumers had to check each service to see if it is available, their implementation would be much more complicated.

Example 2: Consuming a service

For a given application, there is one "root" instance of ServiceScope, defined like this:

// DO NOT DO THIS IN PRODUCTION CODE
const serviceScope: ServiceScope = ServiceScope.startNewRoot();
serviceScope.finish();

In a unit test, you can use code like this to create an isolated scope. A web part should NOT create its own root scope. Instead, it should rely on the BaseClientSideWebPart.context.serviceScope property that is passed down from the application, like this:

// DO NOT DO THIS IN PRODUCTION CODE
const serviceScope: ServiceScope = this.context.serviceScope;

To consume your service, you use the key, like this:

class MyConsumer {
  private _timeProvider: ITimeProvider;

  constructor(serviceScope: ServiceScope) {
    serviceScope.whenFinished(() => {
      this._timeProvider = serviceScope.consume(timeProviderServiceKey);
    });
  }

  public doSomething(): void {
    console.log('The date is :' + this._timeProvider.getDate());
  }
}

Some notes:

  • Calls to ServiceScope.consume() represent implicit dependencies of your class, which are important information from a design perspective. As such, these calls should be isolated in a single place, rather than scattered throughout your implementation.
  • A complex application can have multiple nesting ServiceScope objects, and subtle bugs can occur if it is unclear which scope should be used by a given function. To avoid confusion about this, MyConsumer does NOT save its serviceScope parameter. Instead, all calls to serviceScope.consume() occur immediately in the constructor, and then the parameter is discarded. Where possible, this is the safest approach.
  • In certain initialization scenarios (see below), the ServiceScope may not be in a "finished" state when a class constructor is being called. The ServiceScope.whenFinished() API defers consuming until the ServiceScope is ready. NOTE: The this._timeProvider variable will still be guaranteed to be initialized by the time you call doSomething(), if you follow the best practice of avoiding nontrivial operations inside class constructors.

Example 3: Creating a nested scope

In the above example, because we did not explicitly construct the TimeProvider, serviceScope.consume(timeProviderServiceKey) will return the default implementation. If a service is missing, it will be automatically created and registered at the "root" scope.

Suppose we want to replace the TimeProvider with a different object, e.g. an instance of a class "MockTimeProvider". Once ServiceScope.finish() is called, we cannot add any more items. (This restriction prevents other hard-to-detect bugs.) Instead, we need to create a new child scope, like this:

export default class MockTimeProvider implements ITimeProvider {
  constructor(serviceScope: ServiceScope) {
    // (this constructor is currently unused, but it is required by the
    // ServiceKey.create() contract)
  }

  public getDate(): Date {
    return new Date('1/1/2016');
  }

  public getTimestamp(): number {
    return 0;
  }
}

const childScope: ServiceScope = serviceScope.startNewChild();
const mockTimeProvider: MockTimeProvider = childScope.createAndProvide(timeProviderServiceKey, MockTimeProvider);
childScope.finish();

/*
NOTE: The createAndProvide() call above is a shorthand for this longer expression:

const mockTimeProvider: MockTimeProvider = new MockTimeProvider(childScope);
childScope.createAndProvide(timeProviderServiceKey, mockTimeProvider);
*/

Now we can pass the childScope down the chain to other consumers. When they call childScope.consume(timeProviderServiceKey); they will receive the mockTimeProvider object.

When NOT to design services

Consider these alternatives to using ServiceScope:

  • Explicit dependencies: If your "DataFetcher" class requires an "HttpClient" object and a "TimeProvider" object, simply pass it as a constructor parameter.

  • Service Contexts: If your "DataFetcher" class depends on 10 different classes, define an interface called IDataFetcherContext with 10 parameters, and pass this object around.

The above approaches are very simple to code, and make your class's dependencies very obvious to consumers. You should really only be using ServiceScope when these techniques become burdensome, which happens mainly with "plumbing" infrastructure for a decoupled environment. Because ServiceScope requires consumers to know which keys are available and write extra boilerplate code, the SPFx public API's take the approach of defining explicit service context objects (e.g. see BaseClientSideWebPart.context), while still including an IWebPartContext.serviceScope property for advanced scenarios.

Background Philosophy

ServiceScope provides a formalized way for components to register and consume dependencies ("services"), and to enable different implementations to be registered in different scopes. This improves modularity by decoupling components from their dependencies in an extensible way.

For example, suppose that various components need access to an IPageManager instance. We could simply make the PageManager a singleton (i.e. global variable), but this will not work e.g. if we need to create a pop-up dialog that requires a second PageManager instance. A better solution would be to add the PageManager as a constructor parameter for each component that requires it, however then we immediately face the problem that any code that calls these constructors also needs a PageManager parameter. In an application with many such dependencies, business logic that ties together many subsystems would eventually pick up a constructor parameter for every possible dependency, which is unwieldy. A natural solution would be to move all the dependencies into a class with name like "ApplicationContext", and then pass this around as our constructor parameter. This enables the PageManager to be passed to classes that need it without cluttering the intermediary classes that don't. However, it still has a design problem that "ApplicationContext" has hard-coded dependencies on many unrelated things. A more flexible approach is to make it a dictionary that can look up items for consumers/providers who know the right lookup key (i.e. ServiceKey). This is the popular "service locator" design pattern, familiar from the SPContext API in classic SharePoint.

ServiceScope takes this idea a step further in two important ways: First, it provides a scoping mechanism so that e.g. if we had two different pages, they could each consume a unique PageManager instance while still sharing other common dependencies. Secondly, it allows for a ServiceKey to provide a default implementation of the dependency. This is important for API stability in our modular client-side environment: For example, suppose that version 2.0 of our application introduced a new IDiagnosticTracing interface that a version 2.0 component will expect to consume. If the version 2.0 component gets loaded by an older 1.0 application, it would fail. We could fix this by requiring each consumer to check for any missing dependencies and handle that case, but it would require a lot of checks. A better solution is to ensure that a default implementation always exists, perhaps just a trivial behavior, so that components don't have to worry about it.

Usage: ServiceScope instances are created by calling either ServiceScope.startNewRoot() or ServiceScope.startNewChild(). They are initially in an "unfinished" state, during which provide() can be called to register service keys, but consume() is forbidden. After ServiceScope.finish() is called, consume() is allowed and provide() is now forbidden. These semantics ensure that ServiceScope.consume() always returns the same result for the same key, and does not depend on order of initialization. It also allows us to support circular dependencies without worrying about infinite loops, even when working with external components that were implemented by third parties. To avoid mistakes, it's best to always call consume() inside a callback from serviceScope.whenFinished().

Clone this wiki locally