Skip to content

Work in progress: this website is still under construction. Content may be incomplete or change at any time.

ServiceLayer

This document contains details about the implementation of the Open Pioneer Trails runtime’s service layer. It should aid developers to gain an understanding of the implementation in order to maintain or extend it.

The service layer provides a generic dependency injection mechanism in which services can be defined. Services may reference each other through references, forming a graph of services.

See Design for some background information.

The service layer’s implementation lives in the package @open-pioneer/runtime, mostly in the services directory. It is heavily inspired by the Spring framework.

The most important classes and modules are:

  • class ServiceLayer

    Responsible for maintaining the service lifecycle. This involves starting all required services, connecting them via references and destroying them again when the system shuts down.

  • function verifyDependencies

    The service layer will verify the services’ dependency graph before starting them by calling this function. It will visit all services to ensure that their dependencies can be fulfilled and that no cycles occur. On error, the method will output sensible error messages that can be easily understood by the developer.

    In its current form, the function essentially ensures that the dependency graph is an acyclic graph.

    The return value of verifyDependencies is used by the service layer. It contains the service index (mapping interface names, qualifiers etc. to services) and the verified dependency information (mapping services to other referenced services). The service layer will use the index to support dynamic service lookups (e.g. for the UI) and the dependency information during service construction to setup service references.

  • class PackageRepr / ServiceRepr

    Represents a package / service at runtime. It must be noted that these classes are stateful: their state will reflect the changes made by the service layer. For example, a ServiceRepr’s instance attribute will hold the actual service instance once it has been created. As a consequence, they cannot be reused or shared between multiple application instances.

  • function createPackages

    Parses metadata into instances of PackageRepr and ServiceRepr. This transforms the well defined metadata format (input to the service layer) into the internal runtime representation. Package properties are also handled here: properties defined by the application are merged with the default values declared in the individual packages.

The metadata format is currently maintained in the runtime package at @open-pioneer/runtime/metadata. It describes packages, services and their associated metadata (such as references, provided interfaces, etc.). In most instances, the metadata will be autogenerated by build tools. However, it can also be provided manually (see tests).

The metadata format should be stable: it is part of the public interface. When the metadata format is altered, the build tooling will (most likely) have to be updated as well.

  1. Construction

    The service layer is constructed from a set of PackageRepr. It will immediately verify and index the services in the dependency graph. The construction is currently triggered from the custom element class when it is connected to the DOM.

    During its construction, the service layer will first verify the entire service graph (needed and unneeded services). It will then compute the set of actually required services. This set is currently made up of the services required by the UI (taking from package metadata) and the services required by the framework (constructor parameter). Only those services (and their transitive dependencies) will actually be started.

  2. start

    The start() method is called to launch all required services.

    createService is recursive: a service’s dependencies must be created before the service itself can be created.

    The process to construct a service s is as follows:

    1. Has s already been constructed? Return s.instance and increment its reference count.
    2. Is the service in any other state than not-constructed? Throw an error (this is a sanity check, since verifyDependency should have caught the issue).
    3. Call createService(d) for every service d required by s and store the instance reference.
    4. Finally create the service s by passing the required references and other parameters into its constructor. Note that this initializes the service’s reference count to 1.

    start() tracks the number of times a service is being used as a dependency. This is needed during shutdown.

  3. The application runs. Services may call each other’s methods.

  4. destroy

    The destroy() is being called on the service layer. This currently happens when the custom component is removed from the DOM.

    Destruction reverses the process in start().

    destroyService(s) is called for every service:

    1. Is the service already destroyed? Do nothing and return.
    2. Remove a reference from s. It is no longer used and can be safely destroyed if the count reaches zero.
    3. Call destroyService(d) for every dependency of s. It is important to destroy the dependencies after the service has been destroyed, because the service’s destructor may still reference its dependencies.

The service layer implements 1-to-1 or 1-to-n dependencies. This also means that an interface can be implemented by multiple services at the same time.

1-to-1 dependencies are the common case: a service references another single service that implements an interface. The service layer throws an error when the reference cannot be resolved to an exact service. The error conditions are:

  • The interface is not implemented at all
  • The interface is implemented multiple times but the reference is not qualified

A “qualifier” (a simple string tag in its current form) can be used to disambiguate the reference.

Services can reference all services implementing a certain interface. This feature will most likely be used to implement extension points where a service may customize the implementation of another service.

When 1-to-n references are resolved, all services implementing the requested interface are gathered into an array which is then injected into the service’s constructor instead of the usual, single instance.

Verification (e.g. cycle detection) has been extended to support this feature. Note that it is not an error if there are zero implementations of an interface: the array will simply be empty.

The service layer provides an API to retrieve interface implementations at runtime (getService and getServices). This is a required building block for the integration with react components: when a react component calls useService(...), a dynamic lookup will be triggered (the result can be cached). An error will be thrown if the dependency on that service has not been declared in the package’s metadata (ui.references).

A service cannot depend on itself (directly or indirectly). Such a thing would be impossible to implement (reasonably) because a dependency should be fully initialized before it is being used.

We currently verify that there are no cycles through static analysis. This should remain the default case: most references to other services should be static. Tooling such as the verifyDependencies function can then provide useful analysis to prevent (or diagnose) common errors.

The restriction can still be relaxed when a service depends on itself statically but is only dynamically used once fully constructed; in other words, if the cycle is “resolved” once the first service method is actually being called.

The way this could be implemented is by declaring a dependency as lazy. A reference would still be injected into the service’s constructor, but it will only be a proxy object that must not be used in a cycle. Such a reference cannot be verified statically and should therefore only be used at carefully chosen locations to break cycles.

Example:

class MyService {
constructor({ references }) {
const actuallyMyself = references.myself; // not a MyService, but a Lazy<MyService>
this.actuallyMyself = actuallyMyself;
// Would throw because myself has not been fully constructed yet!
actuallyMyself.get().foo();
}
bar() {
// Works because by now the service has been constructed.
// This requires runtime checks inside get().
this.actuallyMyself.get().foo();
}
foo() {
console.log("Hello World");
}
}