Encapsulating data with DataObject

In the previous section we introduced distributed data structures and demonstrated how to use them. We’ll now discuss how to combine those distributed data structures with custom code (business logic) to create modular, reusable pieces.

The @fluidframework/aqueduct package

The Aqueduct library provides a thin layer over the core Fluid Framework interfaces that is designed to help developers get started quickly with Fluid development.

You don’t have to use the Aqueduct library. It is an example of an abstraction layer built on top of the base Fluid Framework with a focus on making Fluid development easier, and as such you can choose to use Fluid without it.

Having said that, if you’re new to Fluid, we think you’ll be more effective with it than without it.

DataObject

DataObject is a base class that contains a SharedDirectory and task manager. It ensures that both are created and ready for you to access within your DataObject subclass.

import { DataObject, DataObjectFactory } from "@fluidframework/aqueduct";

class MyDataObject extends DataObject implements IFluidHTMLView { }

The root SharedDirectory

DataObject has a root property that is a SharedDirectory. Typically you will create any additional distributed data structures during the initialization of the DataObject, as described next, and store handles to them within the root SharedDirectory.

DataObject lifecycle

DataObject defines three lifecycle methods that you can override to create and initialize distributed data structures:

/**
 * Called the first time, and *only* the first time, that the DataObject
 * is opened on a client. It is _not_ called on any subsequent clients that
 * open it.
 */
protected async initializingFirstTime(): Promise<void> { }

/**
  * Called every time the DataObject is initialized _from an existing
  * instance_. * Not called the first time the DataObject is initialized.
  */
protected async initializingFromExisting(): Promise<void> { }

/**
  * Called after the DataObject is initialized, regardless of whether
  * it was a first time initialization or an initialization from loading
  * an existing object.
  */
protected async hasInitialized(): Promise<void> { }

initializingFirstTime

initializingFirstTime is called only once. It is executed only by the first client to open the DataObject and all work will complete before the DataObject is loaded. You should implement this method to perform setup, which can include creating distributed data structures and populating them with initial data. The root SharedDirectory can be used in this method.

The following is an example from the Badge DataObject:

protected async initializingFirstTime() {
    // Create a cell to represent the Badge's current state
    const current = SharedCell.create(this.runtime);
    current.set(this.defaultOptions[0]);
    this.root.set(this.currentId, current.handle);

    // Create a map to represent the options for the Badge
    const options = SharedMap.create(this.runtime);
    this.defaultOptions.forEach((v) => options.set(v.key, v));
    this.root.set(this.optionsId, options.handle);

    // Create a sequence to store the badge's history
    const badgeHistory =
        SharedObjectSequence.create<IHistory<IBadgeType>>(this.runtime);
    badgeHistory.insert(0, [{
        value: current.get(),
        timestamp: new Date(),
    }]);
    this.root.set(this.historyId, badgeHistory.handle);
}

Notice that three distributed data structures are created and populated with initial data, then stored within the root SharedDirectory.

initializingFromExisting

The initializingFromExisting method is called each time the DataObject is loaded except the first time it is created. Note that you do not need to implement this method in order to load data in your distributed data structures. Data already stored within DDSes is automatically loaded into the local client’s DDS during initialization; there is no separate load event handler that needs to be implemented by your code.

In simple scenarios, you probably won’t need to implement this method, since data is automatically loaded, and you’ll use initializingFirstTime to create your data model initially. However, as your data model changes, this method provides an entry point for you to run upgrade or schema migration code as needed.

hasInitialized

The hasInitialized method is called each time the DataObject is loaded. One common use of this method is to stash local references to distributed data structures so that they’re available for use in synchronous code. Recall that retrieving a value from a DDS is always an asynchronous operation, so they can only be retrieved in an asynchronous function. hasInitialized serves that purpose in the following example.

protected async hasInitialized() {
  this.currentCell = await this.root.get<IFluidHandle<SharedCell>>("myCell").get();
}

Now any synchronous code can access the SharedCell using this.currentCell.

DataObjectFactory

DataObjects, like distributed data structures, are created asynchronously using a factory pattern. (Constructors in TypeScript cannot be asynchronous, so a factory pattern is required.) Therefore you must export a factory class for a DataObject, as the next code example illustrates.

The DataObjectFactory constructor takes the following arguments.

  1. The first argument is the string name of the DataObject. This is used in logging.
  2. The DataObject subclass itself.
  3. An array of factories, one for each DDS used by the DataObject.
  4. This argument is used in a more advanced scenario called Providers that is outside the scope of this documentation. An empty object must be passed when Providers are not being used.
export const BadgeInstantiationFactory = new DataObjectFactory(
    BadgeName,
    Badge,
    [
      SharedMap.getFactory(),
      SharedCell.getFactory(),
      SharedObjectSequence.getFactory(),
    ],
    {},
);

Learn more

The Aqueduct library contains more than just DataObject and DataObjectFactory. To dive deeper into the details, see the Aqueduct package README.