If I had only one piece of advice to give about building large meteor apps, it would be this: use a model layer. By that I mean DRY up your document-oriented code by putting it in a single location.
When you begin working on a project, it's easy to let logic about your documents spill out all over your codebase - a lot goes into your templates, some may reside in your tests, and the remainder lives in your methods. Before you know it, there's significant overlap and it's hard to determine exactly how your collections are supposed to work. To illustrate this point, let's have a look at a simple collection: the comment.
Comments
In this example, comments are going to have an authorId
, some text
, and an optional parentId
. Any comment without a parent will be known as a "root" comment. The goal for this project will be to create an interface where comments can be displayed in an arbitrarily deep tree, similar to those on hacker news. Furthermore, each comment should be editable under certain circumstances. It will look something like this when we're done:
Model
Before we start coding, let's think about all of the functionality that will be useful when building the template. We'll need some ability to find replies and the root comments. Additionally we'll want to know something about the author, and if the comment can be edited.
How can we synthesize these ideas into a single interface? It turns our meteor comes with a handy feature called a transform
which you can add when defining a collection. A transform is a function which is applied to every document after it's been fetched. You can use that function to add properties to the underlying record.
Code
Here's an implementation of our comment model using transforms:
// Comment model
Comment = function(doc) {
_.extend(this, doc);
};
_.extend(Comment.prototype, {
// returns a user document for this comment's author
fetchAuthor: function() {
return Meteor.users.findOne(this.authorId);
},
// returns a comments cursor for all replies to this comment
findReplies: function(options) {
return Comments.find({parentId: this._id}, options);
},
// returns true if this comment has replies
hasReplies: function() {
return this.findReplies().count() > 0;
},
// returns true if this comment can be edited by the current user
canEdit: function() {
var user = Meteor.user();
var isSuperuser = user && user.isSuperuser;
var isAuthor = Meteor.userId() === this.authorId;
return isSuperuser || (isAuthor && !this.hasReplies());
}
});
// Comments collection
Comments = new Mongo.Collection("comments", {
transform: function(doc) {
return new Comment(doc);
}
});
_.extend(Comments, {
// returns a comments cursor for all comments without a parentId
findRoots: function(options) {
return Comments.find({parentId: {$exists: false}}, options);
}
});
Now every comment instance will have a function to fetch its author, find all of its replies, and determine if it can be edited. Additionally, we extended the collection itself with a function to return all of the root comments.
Note that in CoffeeScript, we could have used a class
to define Comment
. It's also worth mentioning that defining Comment
globally isn't necessary unless you need to refer to it later (e.g. when using instanceof
).
Tip #1: When naming your transforms, use prefixes like is/has/can for functions which return a boolean, and find/fetch for functions which return a cursor or a document respectively. Doing this makes it much easier to tell what these functions do without having to refer to the code.
Note that the rules for editing a comment are somewhat complex: superusers can edit at any time, whereas regular users can only edit their own comments when there are no replies. While we could have written that logic directly into our template, by using a model layer, we have separated our concerns and made the function reusable.
Tip #2: Pass an
options
argument to your find functions to allow for sort/skip/limit/etc.
Here's the template:
<body>
{{> comments rootComments}}
</body>
<template name="comments">
{{#each this}}
<ul>
<li>
{{fetchAuthor.username}}: {{text}}
{{#if canEdit}}
<button>edit</button>
{{/if}}
{{> comments findReplies}}
</li>
</ul>
{{/each}}
</template>
Tip #3: Spacebars can use dot notation to chain function calls and properties together. We used
{{fetchAuthor.username}}
above to do the equivalent offetchAuthor().username
.
The only helper we need to write is rootComments
which establishes the initial context:
Template.body.helpers({
rootComments: function() {
return Comments.findRoots();
}
});
Even here we've used findRoots
so that the template doesn't need to concern itself with the definition of a root comment. The less your template needs to know about the underlying document mechanics, the easier your code will be to maintain.
Summary
By moving our model-specific code to the collection itself, we dramatically reduced the complexity of the template. In fact, in this example the comments
template didn't need any additional helpers. Furthermore, the same logic can be reused in your methods. Here's an example implementation of comments.update
:
Meteor.methods({
'comments.update': function(commentId, text) {
check(commentId, String);
check(text, String);
var comment = Comments.findOne(commentId);
if (!comment)
throw new Meteor.Error(404, 'comment not found');
if (!comment.canEdit())
throw new Meteor.Error(403, 'you may not edit this comment');
Comments.update(commentId, {$set: {text: text}});
}
});
Because canEdit
has exactly one definition in our app, we can exhaustively test it and rely on it everywhere.
Resources
- If you'd like to learn more about transforms I'd recommend watching this video on Evented Mind.
- If you'd prefer to use a slightly less verbose syntax for adding transforms, check out the collection helpers package.