Skip to content

Repository files navigation

Space Brand Mood

SPACE Circle CIJoin the chat at https://gitter.im/meteor-space/general

Modular application architecture for Meteor, with following goals:

  1. Explicit dependencies in your code
  2. Full control over configuration and initialization
  3. Testability

Why?

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.

1. Explicit Dependencies

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

Sidebar: Namespaces and Classes

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:

Learn more about Space.Object

2. Control over configuration and Initialization

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

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 starts

because 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 starts

Modules

When 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()}});

Creating Applications based on Modules

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)}})

Configuring Modules and Applications

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'});

3. Testability

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.

Further Examples

Look through the tests of this package to see all features that space:base provides for you.

Migration Guide

3.2.1 → 4.0.0

The 4.0.0 release has brought many small breaking changes and improvements.

Lowercase API properties

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})

Module Lifecycle Changes

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.})

New Class Registry

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');

Install

meteor add space:base

Run the tests

./test.sh

Release History

You find all release changes in the changelog

License

Licensed under the MIT license.

About

Foundation for Modular Application Architecture in Meteor.

Resources

Stars

75 stars

Watchers

11 watching

Forks

Releases

Packages

Contributors

Languages