Modular application architecture for Meteor, with following goals:
- Explicit dependencies in your code
- Full control over configuration and initialization
- Testability
As your Meteor app grows, you keep adding packages and dependencies
to it and sprinkle your configuration and initialization logic into
Meteor.startup blocks all over the code base. If you don't use some
structure, you will end up throwing your laptop against the wall.
Space comes with a lightweight dependency injection system. It tries to keep your code as clean as possible and doesn't force you to wrap your functions with library calls.
If an object needs other code during runtime, it simply declares its dependencies:
vardependentObject={dependencies: {lib: 'OtherCode'},sayHello: function(){this.lib.sayHello();}};Now dependentObject declares very explicitly that it needs OtherCode
which it will access via this.lib later on. But where does OtherCode
come from?
This is where the Space.Injector helps out:
varlibrary={sayHello: function(){console.log('hello!');}};varinjector=newSpace.Injector();// maps the string identifier 'OtherCode' to the library objectinjector.map('OtherCode').to(library);// injects all dependencies into the dependent objectinjector.injectInto(dependentObject);dependentObject.sayHello();// logs: 'hello!'Of course, this also works with Javascript constructors and prototypes:
varMyClass=function(){};MyClass.prototype.dependencies={lib: 'OtherCode'};MyClass.prototype.sayHello=function(){this.lib.sayHello()};varinstance=newMyClass();injector.injectInto(instance);instance.sayHello();// logs: 'hello!'This was just first glimpse into dependency injection, there many other ways to map your code and you can add your own too:
Learn more about Space.Injector
In the examples above we used plain Javascript, but Space comes bundled with a simple but powerful inheritance system with namespacing and classes:
varmyApp=Space.namespace('myApp');Space.Object.extend('myApp.MyBaseClass',{dependencies: {lib: 'OtherCode'},sayHello: function(){this.lib.sayHello();}});MyBaseClass.extend('myApp.MySubClass',{name: '',sayHello: function(){myApp.MyBaseClass.prototype.sayHello.call(this);console.log('I am '+this.name);}});varinstance=myApp.MySubClass.create({name: 'Dominik'});injector.injectInto(instance);instance.sayHello();// logs: 'hello!' and 'I am Dominik'This was just the very basic example, there are many other features that help you build awesome classes with Space:
Ok, now you declared your dependencies and learned how to inject them. The next questions is: "Where should the mapping of string identifiers to actual implementations happen?".
Applications are the command center of your code. Here you configure and initialize all the different pieces:
varApp=Space.Application.extend({// This is automatically called on creationconfigure: function(){// every app has its own injector by defaultthis.injector.map('ExternalLib').to(SomeLibrary);this.injector.map('MyDependentClass').toSingleton(MyClass);},startup: function(){// Create the singleton instance of my classthis.injector.create('MyDependentClass');}});app=newApp()app.start();// You decide when your app startsbecause singletons are needed so often, there is even a much shorter way to express the above:
varapp=Space.Application.create({// Let the framework map and create the singleton instances for yousingletons: ['MyDependentClass','MyOtherSingleton']});app.start();// You decide when your app startsWhen your application grows, it will become tedious to setup everything in your main application. It's time to split up your code into modules!
Modules work exactly like applications, in fact Space.Application
inherits from Space.Module. However they don't create an injector
for themselves, but use the one provided by the (single) application.
This way, all modules within your app share the same injector.
Modules declare which other modules they require and what runtime
dependencies they have, by putting the special properties
requiredModules and dependencies on their prototypes:
varMyModule=Space.Module.define('MyModule',{// Declare which other Space modules are requirerequiredModules: ['OtherModule','YetAnotherModule'],// Declare injected runtime dependenciesdependencies: {someService: 'OtherModule.SomeService',anotherService: 'YetAnotherModule.AnotherService'},// This method is called by the Space framework after all// required modules are initialized and the dependencies// are resolved and injected into the instance of this module.onInitialize: function(){// Add mappings to the shared dependency injection systemthis.injector.map('MyModule.TestValue').to('test');// Use required dependenciesthis.someService.doSomeMagic()this.anotherService.beAwesome()}});Space.Application.create({// Applications also declare which modules they need:requiredModules: ['MyModule'],// And their runtime dependencies from required modules:dependencies: {testValue: 'MyModule.TestValue'},// This is called when all required modules are configured.afterInitialize: function(){console.log(this.testValue);// logs 'test' (see module above)}})You can define default configurations for each module and application and override any part of it when creating an application instance like here:
Space.Module.define('FirstModule',{configuration: {firstToChange: 'first',firstToKeep: 'first'}});Space.Module.define('SecondModule',{requiredModules: ['FirstModule'],configuration: {secondToChange: 'second',secondToKeep: 'second'}});TestApp=Space.Application.extend({requiredModules: ['SecondModule'],configuration: {appConfigToChange: 'app',appConfigToKeep: 'app'}});varapp=newTestApp({configuration: {firstToChange: 'firstChanged',secondToChange: 'secondChanged',appConfigToChange: 'appChanged'}});expect(app.injector.get('configuration')).to.deep.equal({firstToChange: 'firstChanged',firstToKeep: 'first',secondToChange: 'secondChanged',secondToKeep: 'second',appConfigToChange: 'appChanged',appConfigToKeep: 'app'});You may ask why you should deal with dependency injection if you can access your dependencies directly like this:
varCustomer=function(id){this.id=id;};Customer.prototype.getPurchases=function(){returnPurchases.find({customerId: this.id}).fetch();}This works well, until you write your first unit tests. The problem is that this class
directly references Purchases and there is only one way you can test this (sanely):
By temporarily replacing Purchases globally with some mock/stub
describe('Customer.getPurchases',function(){beforeEach(function(){// Save a backup of the global Purchases collectionthis.savedPurchasesCollection=Purchases;// Replace the collection with an anonymous onePurchases=newMongo.Collection(null);this.customerId='xyz';})afterEach(function(){// Restore the global purchases collectionPurchases=this.savedPurchasesCollection;})it('queries the purchases collection and returns fetched results',function(){// PreparevartestPurchase={_id: '123',customerId: this.customerId};Purchases.insert(testPurchase);// Testvarcustomer=newCustomer(this.customerId);varpurchases=customer.getPurchases();expect(purchases).to.deep.equal([testPurchase]);})})In this example it does not look too bad but this pattern quickly becomes tedious if you have more than 1-2 dependencies you want to replace during your tests.
Here is how you can write a test like this when using space:
varCustomer=Space.Object.extend({// Annotate your dependenciesdependencies: {purchases: 'Purchases'},id: null,getPurchases: function(){this.purchases.find({customerId: this.id}).fetch();}});describe('Customer.getPurchases',function(){beforeEach(function(){this.customerId='xyz';// Inject dependencies directly on creationthis.customer=newCustomer({id: this.customerId,purchases: newMongo.Collection(null)// dependency});})it'queries the purchases collection and returns fetched results',function(){// PreparetestPurchase={_id: '123',customerId: this.customerId};this.customer.purchases.insert(testPurchase);// Testvarpurchases=customer.getPurchases();expect(purchases).to.deep.equal([testPurchase]);})})Since the dependencies property is just a simple prototype annotation that has
no meaning outside the Space framework, you can just inject the dependencies
yourself during the tests. This pattern works great, because your code remains
completely framework agnostic (you could replace Space by any other DI framework
or do it yourself). The positive side effect is that you explicitely declare your
dependencies now. This helps you keep an eye on the complexity and coupling. If
you realize that a class has more than 5 dependencies, it might be a good i
ndicator that it is doing too much.
Look through the tests of this package to see all
features that space:base provides for you.
The 4.0.0 release has brought many small breaking changes and improvements.
All api properties, significant to the framework are now like "normal" properties:
Space.Module.define('My.CustomModule',{requiredModules: ['My.OtherModule'],// instead of RequiredModulessingletons: ['My.OtherModule'],// instead of Singletons})Space.Object.extend('My.CustomClass',{dependencies: {/* … */}// instead of Dependencies})Previous hooks like onStart and onConfigure have been replaced with a complete
lifecycle split into three main phases: initialize, start, reset. Each
with on, before and after hooks like onInitialize / afterStart etc.
Space.Module.define('My.CustomModule',{onInitialize(){}// instead of "onConfigure"onStart(){}// same as previous "onStart"onReset(){}// did not exist before -> hook to reset collections etc.})There is a new recommended way to define classes with full class path for improved debugging and automatic type registration (EJSON / toData):
// Instead of:Space.Object.extend(My.namespace,'MyCustomClass');// Do this now:Space.Object.extend('My.namespace.MyCustomClass');meteor add space:base
./test.sh
You find all release changes in the changelog
Licensed under the MIT license.
