Skip to content

Instantly share code, notes, and snippets.

@vman
Last active April 14, 2019 18:04
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save vman/46b9b41969506157d5c4b8437e2ac456 to your computer and use it in GitHub Desktop.
Save vman/46b9b41969506157d5c4b8437e2ac456 to your computer and use it in GitHub Desktop.
Tech Note: ServiceScope API

Originally posted by @octogonz (Microsoft) in Sep 2016 as a result of discussion on this issue.


We received some questions about the ServiceScope API, which is SPFx's implementation of the service locator pattern. This tech note provides some guidance for when/how to use this API.

What is a "service"?

In this document, a "service" is a TypeScript object that we want to be discovered indirectly (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 caches), however this is not required or definitive. The main point of ServiceScope is to allow a service to be constructed/configured by one module, and then consumed by a component in a separate module, without becoming coupled to every intermediary object 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 an 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 a TypeScript class that will be 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 this key is tied to the TimeProvider class, i.e. you cannot reference the key without loading up the code for the default implementation. For now, this requirement is mandatory: by guaranteeing a default implementation, consumers don't have to worry about runtime errors that would otherwise occur in an environment that does not provide an ITimeProvider. If consumers had the responsibility to check whether their service is really 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:

// THE SYSTEM DOES THIS ALREADY; DO NOT DO THIS IN YOUR WEB PART
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 THIS INSTEAD
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 observations:

  • Calls to ServiceScope.consume() represent implicit dependencies of your class. This is important information from a software design perspective, so these calls should be consolidated in a standard place (e.g. the class constructor), rather than scattered throughout your source file.

  • A complex application may have multiple ServiceScope objects, which can lead to subtle bugs if it's unclear which ServiceScope object a function should use. To avoid these problems, MyConsumer does NOT save its serviceScope as a class member. Instead, all calls to serviceScope.consume() occur immediately in the constructor, and then the constructor parameter is discarded. Where possible, this is the safest approach.

  • The constructor uses ServiceScope.whenFinished() to wrap the calls to consume(). This pattern is part of a two-stage initialization that provides certain important benefits (see below). You may be concerned that the this._timeProvider variable might be undefined when call doSomething() is called, however if you follow the best practice of avoiding any nontrivial operations in your class constructors, this issue doesn't occur in practice.

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: When someone attempts to consume a missing service, each parent scope will be consulted until the root scope is reached; if it is missing from the root scope, the default implementation will be automatically constructed and registered.

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 to AVOID ServiceScope

Consider these alternatives to modeling your classes as services:

  • Explicit dependencies: If your "DataFetcher" class requires an "HttpClient" object and a "TimeProvider" object, simply make them into parameters that are passed to the DataFetcher constructor.

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

The above approaches are very simple to code, and make your class's dependencies immediately obvious to other developers. You should really only be using ServiceScope when these techniques become burdensome, which occurs mainly with "plumbing" infrastructure for a library intended for usage by decoupled components. ServiceScope has its own costs; it will require consumers to know which service keys are available, and then to write special boilerplate code. To make our developer experience easier, the SPFx public API takes the approach of defining simple explicit service context objects (e.g. see BaseClientSideWebPart.context), while still including an IWebPartContext.serviceScope property for advanced scenarios.

Background Philosophy

Here's a little more detail about how we arrived at the ServiceScope design:

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 needs to access it. However this means that any code that wants to construct these components needs to obtain the PageManager itself, i.e. as its own constructor 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 "service context" class with name like "ApplicationContext", and then pass this around as a single 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 key/value dictionary that can look up items for consumers 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 several important ways:

  • Scoping mechanism: It provides a scoping mechanism so that e.g. if we had two active pages (e.g. when showing a pop-up dialog), they could each consume a unique PageManager instance while still inheriting other common dependencies from their parent scope.

  • Default implementations: Each ServiceKey is required to provide a default implementation of the dependency. This is important because the SPFx framework mixes together different components on a web page at runtime; a developer cannot realistically test their web part in every environment where it will be loaded. If there is a concern that services might be missing from the scope, every consumer would need to check each call to consume() and provide a fallback behavior. Guaranteeing a default implementation avoids this complexity.

  • Two-stage initialization: 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().

Compared to other inversion of control models (e.g. dependency injection), ServiceScope provides some additional distinguishing features:

  • Easy to debug: Control flow is linear and predictable, and does not rely on decorators, reflection, or "black boxes" such as graph solvers
  • Simple and lightweight: You don't have to rely on a special engine to construct your classes; services are ordinary TypeScript classes that can be constructed in the usual way
  • Type safe: Full TypeScript IntelliSense for methods such as ServiceScope.consume()
  • Stable API contracts: SPFx can introduce new service dependencies without having to break API contracts (e.g. by changing the class constructor for a public API)
  • Circular dependencies: The best practice is to avoid circular dependencies, but this is difficult to achieve in a plug-in model (e.g. where your web part will be mixed together at runtime with other components that you never tested). ServiceScope's two-stage initialization guarantees safe and predictable behavior when circular dependencies arise

There is some further reading in the issue that sparked the writing of this document here - SharePoint/sp-dev-docs#179

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