ADR-56: Plugin pattern for Renderer features

More details about this document
Latest published version:
https://adr.decentraland.org/adr/ADR-56
Authors:
BrianAmadori
Feedback:
GitHub decentraland/adr (pull requests, new issue, open issues)
Edit this documentation:
GitHub View commits View commits on githistory.xyz

Problem Statement

Right now, the Explorer features don't follow any standarized structure. HUDController is used as a sort of service locator/factory mix-up. Bridges that correspond to specific features are scattered over the project. DataStore modules that correspond to specific features are very centralized.

Some order was put with the Environment service locator implementation, but this class is aimed to contain the services linked to the world runtime and is not useful for the UI features.

Needs

Approach

A single plugin can contain:

The idea is to define what a plugin is and define a common plugin container. As all plugins contain Bridges, DataStore and HUDs, we are going to leverage this knowledge and try to group these in the same container for each feature.

The proposed approach consists of leveraging our current PluginSystem implementation to identify and properly encapsulate all our features.

Untitled

A single plugin roughly should look like this (pseudo-code):

public interface ISomePlugin, IPlugin
{
    SomeHUD someHud { get; }
    SomeBridge someBridge { get; }
    DataStore_Some someDataStore { get; }
    SomeSubsystem subsystem { get; }
}

public class SomePlugin : ISomePlugin
{
    // Members need to be virtual to be able to mock them with Substitute.ForPartsOf()
    public virtual ISomeHUD someHud { get; set; }
    public virtual ISomeBridge someBridge { get; set; }
    public virtual ISomeSubsystem subsystem { get; set; }

    // DataStore doesn't have interfaces because it has stubs, 
    // stubs are objects with mocked data. 
    // Mocked data can be created straight away without the need of mocking.
    public DataStore_Some someDataStore { get; set; }
    private DataStore store;

    // DI and initialization is directly handled in the constructor
    public SomeFeature(DataStore store)
    {
        this.store = store;
        store.RegisterStore<DataStore_Some>();
        subsystem = new SomeSubsystem(this);
        someHud = new SomeHUD(this);
        someBridge = new SomeBridge(this);
    }

    // Enabling and disabling of feature without deallocating resources
    public void Enable() {}
    public void Disable() {}
    
    public void Update() {}
    public void LateUpdate() {}

    public void Dispose()
    {
        subsystem.Dispose();
        someHud.Dispose();
        someBridge.Dispose();
        store.UnregisterStore<DataStore_Some>();
    }
}

Let's look at some of the implications:

DataStore is not a collection of static classes anymore

To give the ownership of the feature to the IPlugin implementation, the feature's store is injected to a very simple service locator. The data service locator is named DataStore .

Bridges are now contained in the feature

In the current state, all bridges are scattered at the root level of the project hierarchy. This makes it hard to identify which bridge correspond to which feature, and this issue will get harder the more features are added to the client.

To fix this design issue, feature specific bridges are contained inside of each IPlugin implementation. This will help to identify which bridges correspond to which feature.

As Bridges are now MonoBehaviour, it would be most likely that the code would have to beMain.i.gameObject.AddComponent<SomeBridge>() for the time being. However, the idea is to eventually get rid of MonoBehaviour bridges.

Bridges will be the single source of truth for messages that go and come from Kernel, and will follow the pattern already designed on renderer-protocol (example).

HUDs are now contained in the feature

This basically means that HUDController will be dumped. HUDs will be created and implemented through IPlugin classes. The entry points (preview, explorer, etc.) will enable different configurations of plugins instead of HUD elements.

Any other feature specific subsystems are contained in the feature

Right now, a feature can be a collection of subsystems that are either scattered through the project, or unified in the global Environment service locator. With this new design, the IPlugin will contain the relevant systems for the feature, and adding the systems to Environment will not be needed anymore.

With this change, adding systems to Environment is not strictly needed anymore for initializing a system, as the system will just be contained within a feature.

IPlugin Instance is injected in the feature subsystems

As Environment is not being used anymore for communication between same-feature systems, the service locator of the feature scope should be the IPlugin implementation itself. All the feature subsystems that look to reference each other should use the plugin instance as a hub.

đź’ˇ As a guideline, a IPlugin implementation should act as a feature container. It shouldn't have any kind of business logic. This would allow a new-comer (or future you) to easily understand which are the pieces used by the feature, and plug/unplug new pieces as needed.

The future of Environment

The Environment class proved to be very useful for keeping a stable state between tests and access to global services, however, its weakest point arises when we have to create new features, as we can't put all the features subsystems in the Environment contexts.

With this in mind, the Environment class may have to be refactored to only contain cross-feature systems, like asset management, pool management, external services interfaces and so on. All feature specific systems should be moved to their own IPlugin implementations.

Cross-feature communication

The only means of cross-feature communication is by the usage of the DataStore and global services.

Untitled

General guidelines:

Folder Structure

Each Plugin will create its own folder in the root folder DCLPlugins.

There is a template structure to follow but each plugin can have its own structure based on the needs

PluginNameFolder
...Scripts
...Editor
...ScriptableObjects
...Resources
...Tests
...Visuals
......Prefabs
......Textures
......UI
......Animations
...

Unit Testing Cases

Two scenarios have been considered, a partially mocked feature and just testing a subsystem by itself. Here are some working examples:

Partially mocked feature

[Test]
public void IntegrationTestOfFeatureWithSomeMockedParts()
{
    var dataStore = new DataStore();
    var feature = Substitute.ForPartsOf<SomeFeature>(dataStore);
    var mockedHud = Substitute.For<ISomeHUD>();

    feature.Configure().someHud.Returns(mockedHud);
    feature.Enable();

        // Act
    // ...
    // Assert
}

Test single subsystem

[Test]
public void SubsystemTest()
{
    var dataStore = new DataStore();
    var dataStub = new DataStore_Some();
    dataStore.AddStore(dataStub);

    var feature = Substitute.For<ISomeFeature>(dataStore);
    var subsystem = new SomeSubsystem(feature);

        // Act
    // ...
    // Assert
}

Benefit

The benefits are already addressed in the needs section, the approach would satisfy the needs.

Competition (alternatives)

No other alternatives were considered.

If nothing is done, there's the risk of inconsistent codebase scaling. More unrelated systems could end up scattered in the project, and the complexity of contribution may increase and be more costly for newcomers. This would translate to poor quality and high cost of the improvements we make to the platform in the long term.

Rollout Plan

Open Questions

Q: When I should decide to define a new system as a IPlugin? What is a plugin?

A: A plugin should be any system that's big enough to require a Bridge, HUD, or a mix of systems that interact with each other. Cases of contributions that aren't worthy of being a plugin can include:

Participants

License

Copyright and related rights waived via CC0-1.0. Living