In the last post, we explored the Sandbox pattern for decoupled applications that we can easily modify and extend as business needs change and as new requirements emerge.
We led with the benefits of this pattern in the previous post, so now we’ll reveal its implementation:
/**
* @summary The core of the application; registers and emits events
*/
export class Sandbox extends EventTarget {
/**
* @param {String[]} modules
* @param {Function} callback
*/
constructor(modules, callback) {
super();
const factories = {};
/**
* An object with the `core` namespaces and event-related methods available to
* all modules; provided in the callback to the `Sandbox` constructor
*/
const sandbox = {
my: {},
core: {
generateUUID: this.generateUUID,
logger: {
getLoggerInstance: () => console,
},
},
// Bind event methods from the EventTarget (i.e., this) to ensure proper context
addEventListener: this.addEventListener.bind(this),
removeEventListener: this.removeEventListener.bind(this),
dispatchEvent: this.dispatchEvent.bind(this),
};
// Create factories for each module
modules.forEach((moduleName) => {
factories[moduleName] = () => new Sandbox.modules[moduleName](sandbox);
});
// Lazily initialize the modules using `Object.defineProperty`
Object.entries(factories).forEach(([moduleName, factory]) => {
Object.defineProperty(sandbox.my, moduleName, {
get: () => {
if (!sandbox.my[`__${moduleName}`]) {
try {
// Here we create module lazily (i.e. when the module is accessed)
sandbox.my[`__${moduleName}`] = factory();
} catch (ex) {
console.error(
`INTERNAL_ERROR (sandbox): Could not create module (${moduleName}); ensure this module is registered via Sandbox.modules.of. See details -> ${ex.message}`
);
return;
}
}
return sandbox.my[`__${moduleName}`];
},
});
});
// Pass the sandbox object with `my` and `core` namespaces to the callback
callback(sandbox);
/**
* Though Sandbox extends EventTarget we *only* return a `dispatchEvent` method to
* ensure that event registrations occur inside the Sandbox. This prevents
* "eavesdropping" on events by clients that are not sandboxed. All such clients
* can do is notify the sandbox of an external event of interest
*/
return {
dispatchEvent: this.dispatchEvent.bind(this),
};
}
/**
* Houses sandbox module definitions
*/
static modules = {
/**
* @param {String} moduleName - the identifier for the module to be referenced by
* @param {Object} moduleClass - module's constructor
*/
of: function (moduleName, moduleClass) {
Sandbox.modules[moduleName] = moduleClass;
},
};
}
The implementation of our Sandbox
ensures that it does not matter how dependencies are defined or ordered. Because they are lazily loaded, it means that dependent services are not created until they are used–even if a dependency is defined after a service that relies upon it; it doesn't matter.
Without lazy loading, services must be created in reverse topological order. This means first we create all the dependencies that have no dependencies themselves, then proceed to the ones that only depend on the already-created dependencies and so on until all services are created.
The ability to define dependencies in any order is another attribute that keeps our architecture flexible.
But how does it work? Let’s break it down.
Using Factory Functions for Modules: Instead of instantiating modules immediately in the
Sandbox
constructor, we create factory functions for each module and store them in afactories
object.Lazy Loading via getters: We use
Object.defineProperty
to delay the creation of each module until it is accessed for the first time. This allows us to resolve dependencies dynamically.Passing the Sandbox: Above, we pass the reference to the
sandbox
to each factory. Passing thesandbox
object to each module's constructor allows modules to reference each other through thesandbox.my
namespace.
// Lazily initialize the modules using `Object.defineProperty`
Object.entries(factories).forEach(([moduleName, factory]) => {
Object.defineProperty(sandbox.my, moduleName, {
get: () => {
if (!sandbox.my[`__${moduleName}`]) {
try {
// Here we create module lazily (i.e. when the module is accessed)
sandbox.my[`__${moduleName}`] = factory();
} catch (ex) {
console.error(
`INTERNAL_ERROR (sandbox): Could not create module (${moduleName}); ensure this module is registered via Sandbox.modules.of. See details -> ${ex.message}`
);
return;
}
}
return sandbox.my[`__${moduleName}`];
},
});
});
In the example above our getter is actually a function that creates the module.
Lazy loading means deferring initialization of classes until they are actually needed. Our getter function, shown above, checks to see if the module has been instantiated when the module is first accessed. If not, a factory function is called–this is the class that we register with the Sandbox.modules.of
method. The module is created then cached, ensuring each module is created only once.
This strategy allows us to avoid creating all our modules up front, especially if some modules may never be used. It also saves time on initialization and memory.
Dependency injection completes this architecture. All our services consume the sandbox as a constructor argument. This allows us to mock our sandbox API in unit tests and test our modules in complete isolation from the rest of our code.
At its simplest, Dependency Injection means providing a method or class all of the things it needs in order to do its job. This can be achieved by passing dependencies as function or constructor arguments or by using a framework.
Since our modules communicate with the rest of the application via the sandbox, we can place them anywhere in our code base without impacting peer modules. The Sandbox pattern, as shown above, gives us maximum flexibility when it comes to defining, executing, and organizing our code: ensuring that no matter what happens in the future, our application is ready to move at the speed of change.
Top comments (0)