Skip to content

Instantly share code, notes, and snippets.

@maxov
Last active August 29, 2015 14:04
Show Gist options
  • Save maxov/db6e0f471df16a6666f5 to your computer and use it in GitHub Desktop.
Save maxov/db6e0f471df16a6666f5 to your computer and use it in GitHub Desktop.
A document outlining plans.

Marionette.Plans

In this document I hope to expose a new way of managing nested views and even routing/controllers in Marionette. This is rather opinionated, but hopefully in a good way. It provides a replacement for what are typically called 'controllers', which Marionette and Backbone don't have. In any case, it certainly makes Marionette more opinionated, but it does not in any way restrict users from doing certain things.

I call this new idea Marionette.Plans. I am certainly not expecting this to be implemented in the actual library, but I will factor this out from my currrent application into another library for doing so.

I am still unsure whether Marionette.Plans will actually have a dependency on Marionette. It will most likely be split into a part without a Marionette dependency(just pure plan semantics), and a part with Marionette(Directors).

Reasons

I used to have a whole bunch of text here outlining reasons for Marionette.Plans, but it's probably already known, for the most part.

@jmeas:

Backbone doesn’t really provide any architectural pieces at all, aside from the router. Marionette sprinkles a little teeny bit of extra stuff onto that, with the Application and Module…but it still only hints at how you use them. v3 will be about making the architectural layer a bit more opinionated.

Marionette.Plans provides a more opinionated execution infrastructure for Marionette, but still retains a lot of flexibility.

Architecture

Marionette.Plans introduces quite a bit of new classes and object types. These include plans, effects, agents, and directors. However, they are all tied together in a way that provides meaningful abstractions.

Overview

Plans are the foundation for Marionette.Plans. They represent actions that also have dependencies and can be 'unapplied'.

Effects are the outcomes of plans when applied, and they allow for concise view/region nesting.

Agents manage a tree of plans and make sure that plans run with all their dependencies.

Directors are just Backbone/Marionette routers plus an agent, which is a coupling that works well. However, the developer can create and execute custom plans and agents on their own, if they wish.

Plans

As said before, plans are actions that have dependencies. Here's a definition for a plan:

var myPlan = new Marionette.Plan({
  parents: [/*dependencies here*/],
  apply: function () {
    // setup code
  },
  unapply: function () {
    // closing code
  }
});

You'll notice that each plan is an instance and not a class. It is possible to extend Marionette.Plan and implement some of the plan properties and create new instances, but this is not really useful in most cases.

Plan Trees

A plan has some dependencies, a function to apply, and a function to unapply. We call the dependencies of a plan the 'parents', and the apply and unnapply functions the 'body'. It turns out that this simple abstraction can cover many cases and is rather unopinionated.

Let's define a tree of plans that we'll use throughout this document. The body of each plan should be easy to infer.

var A = new Marionette.Plan({
  parents: [],
  apply: function () {
    console.log('A applied');
  },
  unapply: function () {
    console.log('A unapplied');
  }
});
var B = new Marionette.Plan({
  parents: [],
  apply: ...,
  unapply: ...
});
var C = new Marionette.Plan({
  parents: [A],
  apply: ...,
  unapply: ...
});
var D = new Marionette.Plan({
  parents: [B, C],
  apply: ...,
  unapply: ..
});
var E = new Marionette.Plan({
  parents: [C],
  apply: ...,
  unapply: ..
});

Perhaps an easier way of viewing these plans and their dependencies is as a literal tree:

   A
     \
  B   C
   \  |\
    \ | \
      D  E

We call a tree of plans with dependencies an 'plan execution tree', 'plan dependency tree', or simply a 'plan tree'.

Agents

Now, how do we execute these plans? The only way is to use an agent, to keep track of plans. Agents provide a way to execute plans and also encode plan dependency execution semantics. This means that the way plan dependency trees are executed is completely different from the actual plans themselves.

Let's create an agent with default behavior:

var agent = new Marionette.Agent();

Now the agent is able to manage every plan and its dependencies:

agent.apply(D); // => 'A applied', 'B applied', 'C applied', 'D applied'
agent.apply(B); // => 
agent.unapply(B); // => 'D unapplied', 'B unapplied'
agent.apply(D); // => 'B applied', 'D applied'
agent.apply(E); // 'D unapplied', 'B unapplied', 'E applied'

In the first apply call, the order of 'A applied' and 'B applied' does not matter and could switch, as per the default agent execution semantics.

Pay very close attention to the agent.apply(E) call. What the agent does is unapply everything that is not a dependency of E(in the right order), then apply E. This is important because of plans' application to view switching. However, the developer should not worry about these semantics, because they fit for the vast majority of applications.

Note that if we apply a plan, it is not executed again until it is unapplied.

It's completely possible to create different agents with completely different execution semantics. If a developer does not like how their plans are executed, they can change it completely by implementing Agent differently.

You may already be able to see how plans and agents can be very useful when working with nested views, but let's see how extensions to plans can make it dead easy to work with nesting.

Plan Parameters

A plan may be passed in parameters when it is executed. However, only plans without dependencies may use parameters. This may seem like a very harsh restriction, but for the application of plans to routing it makes sense. Let's add a parameter to our D plan:

var D = new Marionette.Plan({
  parents: [B, C],
  apply: function () {
    console.log('D applied with param ' + this.params.x)
  },
  unapply: function () {
    console.log('D unapplied with param' + this.params.x)
  }
});

Now when we apply our plan:

agent.apply(D, {x: 5}); // => 'A applied', 'B applied', 'C applied', 'D applied with param 5'

The parameter is saved when the plan is unapplied:

agent.unapply(D); // 'D unapplied with param 5'

And we can reapply the plan as much as we wish with more functions.

Also notice that if we call a plan again with different parameters, it is unapplied then reapplied.

The parameters are not simply passed in to the functions because, as we'll see, the function parameters are used for something else.

Effects

So far plans have basically been effectful functions with dependencies. However, with some plan actions(especially ones concerning with views), it is useful to see the outcome of a plan's parents.

It is really simple to add effects to a plan and to use a plan's parents' effects. All we have to do as add return values, and the values are passed into the child plan's body functions. Let's see how:

var A = new Marionette.Plan({
  parents: [],
  apply: function () {
    console.log('A applied');
    return 5;
  },
  unapply: function () {}
});
var B = new Marionette.Plan({
  parents: [],
  apply: function () {
    console.log('B applied');
    return 6;
  },
  unapply: function () {}
});
var C = new Marionette.Plan({
  parents: [A, B],
  apply: function (a, b) {
    console.log('C applied');
    return a + b;
  },
  unapply: function (a, b) {}
});

var agent = new Marionette.Agent();
var result = agent.execute(C); // => 'A applied', 'B applied', 'C applied', retuns 11
var result2 = agent.execute(C); // => returns 11

Or, by visualizing the plans as a tree, again:

  A    B
   \   |
    5  6
     \ |
      C

The effects of A and B are passed down to C. Note that the effects are also passed to the unapply function; they are saved just like plan parameters.

The above example may seem like a really verbose way of just executing functions, but it is a really contrived example.

Plans become very useful when the bodies deal with views, and the execution semantics are not so trivial. Then, agent-based plan execution is the only sane way to build your application.

It is also worth mentioning that 'this' is preserved in the apply and unapply functions. A plan can save a variable in the apply function by attaching it to this and then use it in the unapply function.

Directors

Directors are the application of plans to Marionette's routing. A director is a router plus an agent so it can execute plans. We specify what plan gets executed on every route, and the director handles the rest.

Let's recall the first plan tree, again:

   A
     \
  B   C
   \  |\
    \ | \
      D  E

Let's prototype an application with some routes to use these plans:

/d/:x => D
/e/ => E

Not every plan is given a route, because it is possible that some plans execute intermediate steps, like setting up a layout view. There may be some view that is not filled by these plans. This is A, B, and C: plans that execute some intermediate view setup/takedown.

A corresponding director would just look like this:

var AppDirector = Marionette.Director.extend({
  
  routes: {
    'd/:x': D,
    'e': E
  }

});

var myDirector = new AppDirector();
var myAgent = myDirector.agent;

The director uses a router and an agent in the background. It behaves like a normal router when a page navigation occurs, firing off the right plans using the agent. We can do explicit page navigations by either using the agent or director:

myDirector.navigate('d/5');
myAgent.apply(D, {x: 5});

I haven't figured out exactly what events a Director fires off, but it will most likely fire off plain router events.

Application usage

I wouldn't have thought up plans without a corresponding application. Here's the code for such an application:

// The base application plan. It shows the base layout with a nav, content, and footer region.
var AppPlan = new Marionette.Plan({
  
  parents: [],

  apply: function () {
    Application.layout = this.layout = new Marionette.LayoutView({
  
      template: ...,

      regions: {
        nav: "#nav",
        content: "#content",
        footer: "#footer"
      }

    });

    this.layout.render();
    return this.layout;
  },

  unapply: function () {
    this.layout.destroy();
  }

});

// this plan shows a footer
var FooterPlan = new Marionette.Plan({
  
  parents: [AppPlan],

  apply: function (layout) {
    var fv = new FooterView();
    layout.footer.show(fv);
  },

  unapply: function (layout) {
    layout.footer.empty();
  }

});

// this plan shows an infinite scrolling list of users
var ListPlan = new Marionette.Plan({
  
  // In this plan we don't want the footer, because it's infinite scrolling
  parents: [AppPlan],

  apply: function (layout) {
    var usersList = new UsersListView();
    layout.content.show(usersList);
  },

  unapply: function (layout) {
    layout.content.empty();
  }

});

// this plan shows a layout with a sidebar and detail section 
// within the 'content' region of the AppPlan
// it also uses a footer
var UserLayoutPlan = new Marionette.Plan({
  
  // In this plan we do want the footer
  parents: [AppPlan, FooterPlan],

  apply: function (appLayout) {
    var layout = new Marionette.LayoutView.({
  
      template: ...,

      regions: {
        sidebar: "#sidebar",
        detail: "#detail"
      }

    });

    appLayout.content.show(layout);
  },

  unapply: function (appLayout) {
    appLayout.content.empty();
  }

});

// put a user list in the UserLayoutPlan
var UserListPlan = new Marionette.Plan({
  
  parents: [UserLayoutPlan],

  // implementation

});

// put a user detail in the UserLayoutPlan
var ShowUserPlan = new Marionette.Plan({

  parents: [UserLayoutPlan],

  // implementation

});

var AppDirector = Marionette.Director.extend({
  
  routes: {
    '': HomePlan, // some home app plan
    'list': ListPlan
    'users': UserListPlan,
    'users/:id': ShowUserPlan // takes the user ID to show
  }

});
new AppDirector();

Given the previous plan semantics, it should be easy to see how this application works in drastically reducing complication with nested views and routes. All the nesting is handled for the developer when they specify plan parents.

Plan generators

As you've noticed, there's quite a bit of boilerplate still with the view plans. I have yet to specify them, but there will be a bunch of utility plan 'generators' (functions that create plans) that will make specifying nested views and such very concise.

Or there could be ViewPlans that take in views and use them. Still not sure.

This is a part of the document that needs work for sure.

Some points of discussion

  • Do plans do enough? Maybe plans should also do some sort of resource management or something of that kind.
  • Do plans do too much? Maybe plans are doing too much, and the more of the execution semantics should be left to the developer.
  • Should there be some kind of nested director? I think this should happen, as it should be a feature to have director dependencies just like plan dependencies so that not everything will be specified in the same place. Or maybe just an alternate syntax. I am unsure of this yet.
  • Some extensions to plans? Maybe there's some other structure I'm missing that goes well with plans.
  • Other kinds of plans? May be some interesting other kinds of view plans, or layout plans.
  • Changes to plan API? I'm sure of this one. There may be some additional methods for plans that I'm missing, or maybe special plan inheritance.
  • Do plans belong in core Marionette? This is up to the core contributors of Marionette to decide. If not, I'm sure this will be a useful library.

Next Steps

This document has been outlining Marionette.Plans. Hopefully you can see the usage in plans, agents, effects and directors and why they greatly simplify application development.

I outlined some points of discussion in the previous section. These are points which I am not sure about yet, but may be interesting to talk about.

Here are the next few steps I will take in working with this idea:

  1. I'm going to prototype a semi-complicated Marionette application using the plans architecture. This is so I am finalized on the semantics of how plans are executed, and their intended usage within the Marionette architecture. This will also show how Marionette.Plans will be useful within an application, especially dealing with nested things.

  2. I will finish my reference implementation of the Marionette.Plans library on my own repo, and write tests for the various semantics.

  3. Probably some discussion on how this will be used. I am still unsure of Marionette.Plans' applications; it may be useful in other areas.

  4. Discuss on a core implementation. I'm not sure if this fits within Marionette well or deserves to be in its own library. That is for the Marionette core contributors to decide.

By no means is this idea final, or even a good idea(I just think it is). Please leave comments and criticism, especially around the points of discussion. Thanks for reading.

/*
* Backbone.Plans
* ----------------
* Pre-Alpha 0.0.1
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
define(['backbone', 'underscore'], function(Backbone, _) {
return factory(Backbone, _);
});
}
else if (typeof exports !== 'undefined') {
var Backbone = require('backbone');
var _ = require('underscore');
module.exports = factory(Backbone, _);
}
else {
factory(root.Backbone, root._);
}
}(this, function(Backbone, _) {
// Backbone.Plan
// -------------
// An object that represents an action that can be executed, destroyed,
// and have dependencies.
// An Agent uses plans with dependencies to be executed.
var Plan = Backbone.Plan = function (options) {
options = options || {};
_.extend(this, _.pick(options, ['parents', 'execute', 'destroy', 'name']));
this.initialize.apply(this, arguments);
};
_.extend(Plan.prototype, Backbone.Events, {
initialize: function () {},
parents: [],
execute: function () {},
destroy: function () {}
});
// TODO use a tree structure, multiple array traversals with just a flat array
// Backbone.Agent
// --------------
// An Agent manages the execution tree of a set of plans with dependencies,
// with several various entry points.
var Agent = Backbone.Agent = function (options) {
options = options || {};
//_.extend(this, options);
this.executedPlans = [];
this.initialize.apply(this, arguments);
};
_.extend(Agent.prototype, Backbone.Events, {
initialize: function () {},
execute: function (plan, options, destroyTree) {
if(arguments.length < 3) destroyTree = true;
// if there is an applied plan, find it and remove it
var executedPlan = _.findWhere(this.executedPlans, {plan: plan});
var effects;
if (executedPlan) {
if (options && !_.isEqual(options, executedPlan.options)) {
// if there are options and the options are not equal to
// executed plan options, undo previously executed plan
this.destroyPlain(executedPlan);
// execute the current plan
return this.destroyPlain(plan, executedPlan.effects, options);
} else {
return executedPlan.planEffect;
}
} else {
// execute the current plan
return this.executePlain(plan, options, destroyTree);
}
},
// plainly execute a plan
executePlain: function (plan, params, destroyTree) {
plan.params = params || {};
if(destroyTree) {
// destroy concurrent plans
var dependencies = this.dependencies(plan);
var others = _.reject(this.executedPlans, function (elem) {
return _.contains(dependencies, elem.plan);
});
var toDestroy = _.reject(others, function (elem) {
var parents = elem.plan.parents;
return _.some(others, function (executedPlan) {
return _.contains(parents, executedPlan.plan);
});
});
var destroy = this.destroy.bind(this);
_.each(toDestroy, function (executedPlan) {
destroy(executedPlan.plan);
});
}
var execute = this.execute.bind(this);
// execute the parent plans
var effects = _.map(plan.parents, function (plan) {
return execute(plan, null, false);
});
var effect = plan.execute.apply(plan, effects);
// add to list of applied plans
this.executedPlans.push({
plan: plan,
effects: effects,
planEffect: effect,
params: plan.params
});
return effect;
},
// get whole dependency tree around a plan
wholeTree: function (plan) {
return _.union(this.dependencies(plan),
_.flatten(_.map(this.dependsOn(plan), this.wholeTree.bind(this))));
},
// get dependencies of a plan
dependencies: function (plan) {
return _.union(plan.parents,
_.flatten(_.map(plan.parents, this.dependencies.bind(this)))
);
},
// get plans that directly depend on a plan
dependsOn: function (plan) {
return _.pluck(_.filter(this.executedPlans, function (executedPlan) {
return _.contains(executedPlan.plan.parents, plan);
}), 'plan');
},
// destroy a plan
destroy: function (plan) {
_.each(this.dependsOn(plan), this.destroy.bind(this));
var appliedPlan = _.findWhere(this.executedPlans, {plan: plan});
this.destroyPlain(appliedPlan);
},
// plainly destroy a plan
destroyPlain: function (executedPlan) {
this.executedPlans = _.without(this.executedPlans, executedPlan);
var plan = executedPlan.plan;
plan.params = executedPlan.params;
plan.destroy.apply(plan, executedPlan.effects);
}
});
// Backbone.Director
// -----------------
// A Director extends a router with to provide plan-based routes.
// It is given a map of routes to plans, and creates an agent and
// executes the plans accordingly.
var Director = Backbone.Director = function (options) {
options = options || {};
if(options.routes) this.routes = options.routes;
var agent = this.agent = new Agent();
var router = this.router = new Backbone.Router();
_.each(this.routes, function (plan, route) {
router.route(route, function () {
agent.execute(plan, {routeParams: arguments});
});
});
this.initialize.apply(this, arguments);
};
_.extend(Director.prototype, Backbone.Events, {
navigate: function (fragment, options) {
options = options || {};
options.trigger = true;
Backbone.history.navigate(fragment, options);
}
});
Plan.extend = Agent.extend = Director.extend = Backbone.Model.extend;
}));

Plans implementation

I've finally implemented Plans! And they seem to work perfectly, but I haven't written a full comprehensive test suite yet.

There are a few differences from the spec:

  • 'apply' becomes 'execute' to avoid name clash with javascript
  • 'unapply' becomes 'destroy' now that 'apply' has dissapeared
  • actual agent semantics are slightly different(order kinda matters in a few more cases).

Otherwise, the library is pretty much ready to use.

// Create our Application
var app = new Marionette.Application();
// Create the plans
var ProfilePlan = new Marionette.Plan({
parents: [],
apply: function () {
var profileView = new ProfileView();
app.layout.someRegion.show(profileView);
return profileView;
},
unapply: function () {
app.layout.someRegion.empty();
}
});
var ProfileSettingsPlan = new Marionette.Plan({
parents: [Profile],
apply: function (profileLayout) {
profileLayout.whateverRegion.show(new SettingsView());
},
unapply: function (profileLayout) {
profileLayout.whateverRegion.empty();
}
});
// Attach a director
app.router = new Marionette.Director({
routes: {
'profile': ProfilePlan,
'profile/settings': ProfileSettingsPlan
}
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment