diff --git a/app/assets/javascripts/discourse/controllers/header_controller.js b/app/assets/javascripts/discourse/controllers/header_controller.js
index 69413093b..f7a5429c4 100644
--- a/app/assets/javascripts/discourse/controllers/header_controller.js
+++ b/app/assets/javascripts/discourse/controllers/header_controller.js
@@ -9,6 +9,7 @@
 Discourse.HeaderController = Discourse.Controller.extend({
   topic: null,
   showExtraInfo: null,
+  notifications: null,
 
   categories: function() {
     return Discourse.Category.list();
@@ -39,6 +40,16 @@ Discourse.HeaderController = Discourse.Controller.extend({
 
     toggleMobileView: function() {
       Discourse.Mobile.toggleMobileView();
+    },
+
+    showNotifications: function(headerView) {
+      var self = this;
+
+      Discourse.ajax("/notifications").then(function(result) {
+        self.set("notifications", result);
+        self.set("currentUser.unread_notifications", 0);
+        headerView.showDropdownBySelector("#user-notifications");
+      });
     }
   }
 
diff --git a/app/assets/javascripts/discourse/controllers/notification_controller.js b/app/assets/javascripts/discourse/controllers/notification_controller.js
new file mode 100644
index 000000000..19db113ca
--- /dev/null
+++ b/app/assets/javascripts/discourse/controllers/notification_controller.js
@@ -0,0 +1,17 @@
+Discourse.NotificationController = Discourse.ObjectController.extend({
+  scope: function() {
+    return "notifications." + Discourse.Site.currentProp("notificationLookup")[this.get("notification_type")];
+  }.property(),
+
+  username: function() {
+    return this.get("data.display_username");
+  }.property(),
+
+  link: function() {
+    if (this.blank("data.topic_title")) {
+      return "";
+    }
+    var url = Discourse.Utilities.postUrl(this.get("slug"), this.get("topic_id"), this.get("post_number"));
+    return '<a href="' + url + '">' + this.get("data.topic_title") + '</a>';
+  }.property()
+});
diff --git a/app/assets/javascripts/discourse/controllers/notifications_controller.js b/app/assets/javascripts/discourse/controllers/notifications_controller.js
new file mode 100644
index 000000000..1268515f4
--- /dev/null
+++ b/app/assets/javascripts/discourse/controllers/notifications_controller.js
@@ -0,0 +1,3 @@
+Discourse.NotificationsController = Ember.ArrayController.extend(Discourse.HasCurrentUser, {
+  itemController: "notification"
+});
diff --git a/app/assets/javascripts/discourse/helpers/i18n_helpers.js b/app/assets/javascripts/discourse/helpers/i18n_helpers.js
index 92e2335c8..56d57ea2a 100644
--- a/app/assets/javascripts/discourse/helpers/i18n_helpers.js
+++ b/app/assets/javascripts/discourse/helpers/i18n_helpers.js
@@ -34,6 +34,13 @@ Ember.Handlebars.registerHelper('i18n', function(property, options) {
   return I18n.t(property, params);
 });
 
+/**
+ Bound version of i18n helper.
+ **/
+Ember.Handlebars.registerBoundHelper("boundI18n", function(property, options) {
+  return new Handlebars.SafeString(I18n.t(property, options.hash));
+});
+
 /**
   Set up an i18n binding that will update as a count changes, complete with pluralization.
 
diff --git a/app/assets/javascripts/discourse/models/notification.js b/app/assets/javascripts/discourse/models/notification.js
deleted file mode 100644
index 8d55a6f19..000000000
--- a/app/assets/javascripts/discourse/models/notification.js
+++ /dev/null
@@ -1,42 +0,0 @@
-/**
-  A data model representing a notification a user receives
-
-  @class Notification
-  @extends Discourse.Model
-  @namespace Discourse
-  @module Discourse
-**/
-Discourse.Notification = Discourse.Model.extend({
-
-  readClass: (function() {
-    if (this.read) return 'read';
-    return '';
-  }).property('read'),
-
-  url: function() {
-    if (this.blank('data.topic_title')) return "";
-    return Discourse.Utilities.postUrl(this.get('slug'), this.get('topic_id'), this.get('post_number'));
-  }.property(),
-
-  rendered: function() {
-    var notificationName = Discourse.Site.currentProp('notificationLookup')[this.notification_type];
-    return I18n.t("notifications." + notificationName, {
-      username: this.data.display_username,
-      link: "<a href='" + (this.get('url')) + "'>" + this.data.topic_title + "</a>"
-    });
-  }.property()
-
-});
-
-Discourse.Notification.reopenClass({
-  create: function(obj) {
-    obj = obj || {};
-
-    if (obj.data) {
-      obj.data = Em.Object.create(obj.data);
-    }
-    return this._super(obj);
-  }
-});
-
-
diff --git a/app/assets/javascripts/discourse/templates/header.js.handlebars b/app/assets/javascripts/discourse/templates/header.js.handlebars
index 5e59f0c83..c483fc287 100644
--- a/app/assets/javascripts/discourse/templates/header.js.handlebars
+++ b/app/assets/javascripts/discourse/templates/header.js.handlebars
@@ -94,20 +94,7 @@
 
       {{render search}}
 
-      <section class='d-dropdown' id='notifications-dropdown'>
-        {{#if view.notifications}}
-          <ul>
-            {{#each view.notifications}}
-            <li class="{{unbound readClass}}">{{{unbound rendered}}}</li>
-            {{/each}}
-            <li class='read last'>
-              <a {{bindAttr href="currentUser.path"}}>{{i18n notifications.more}} &hellip;</a>
-            </li>
-          </ul>
-        {{else}}
-          <div class='none'>{{i18n notifications.none}}</div>
-        {{/if}}
-      </section>
+      {{render notifications notifications}}
 
       <section class='d-dropdown' id='site-map-dropdown'>
       <ul class="location-links">
diff --git a/app/assets/javascripts/discourse/templates/notifications.js.handlebars b/app/assets/javascripts/discourse/templates/notifications.js.handlebars
new file mode 100644
index 000000000..7b06cd115
--- /dev/null
+++ b/app/assets/javascripts/discourse/templates/notifications.js.handlebars
@@ -0,0 +1,14 @@
+<section class="d-dropdown" id="notifications-dropdown">
+  {{#if content}}
+    <ul>
+      {{#each}}
+        <li {{bind-attr class="read"}}>{{unbound boundI18n scope linkBinding="link" usernameBinding="username"}}</li>
+      {{/each}}
+      <li class="read last">
+        <a {{bind-attr href="currentUser.path"}}>{{i18n notifications.more}} &hellip;</a>
+      </li>
+    </ul>
+  {{else}}
+    <div class="none">{{i18n notifications.none}}</div>
+  {{/if}}
+</section>
diff --git a/app/assets/javascripts/discourse/views/header_view.js b/app/assets/javascripts/discourse/views/header_view.js
index 6ecc9fea3..b2e3e2271 100644
--- a/app/assets/javascripts/discourse/views/header_view.js
+++ b/app/assets/javascripts/discourse/views/header_view.js
@@ -48,19 +48,12 @@ Discourse.HeaderView = Discourse.View.extend({
     return false;
   },
 
+  showDropdownBySelector: function(selector) {
+    this.showDropdown($(selector));
+  },
+
   showNotifications: function() {
-
-    var headerView = this;
-    Discourse.ajax('/notifications').then(function(result) {
-      headerView.set('notifications', result.map(function(n) {
-        return Discourse.Notification.create(n);
-      }));
-
-      // We've seen all the notifications now
-      Discourse.User.current().set('unread_notifications', 0);
-      headerView.showDropdown($('#user-notifications'));
-    });
-    return false;
+    this.get("controller").send("showNotifications", this);
   },
 
   examineDockHeader: function() {
@@ -106,7 +99,8 @@ Discourse.HeaderView = Discourse.View.extend({
       return headerView.showDropdown($(e.currentTarget));
     });
     this.$('a.unread-private-messages, a.unread-notifications, a[data-notifications]').on('click.notifications', function(e) {
-      return headerView.showNotifications(e);
+      headerView.showNotifications(e);
+      return false;
     });
     $(window).bind('scroll.discourse-dock', function() {
       headerView.examineDockHeader();
diff --git a/test/javascripts/controllers/header_controller_test.js b/test/javascripts/controllers/header_controller_test.js
new file mode 100644
index 000000000..9753012ea
--- /dev/null
+++ b/test/javascripts/controllers/header_controller_test.js
@@ -0,0 +1,36 @@
+var server;
+
+module("Discourse.HeaderController", {
+  setup: function() {
+    server = sinon.fakeServer.create();
+  },
+
+  teardown: function() {
+    server.restore();
+  }
+});
+
+test("showNotifications action", function() {
+  var controller = Discourse.HeaderController.create();
+  var viewSpy = {
+    showDropdownBySelector: sinon.spy()
+  };
+  Discourse.User.current().set("unread_notifications", 1);
+  server.respondWith("/notifications", [200, { "Content-Type": "application/json" }, '["notification"]']);
+
+
+  Ember.run(function() {
+    controller.send("showNotifications", viewSpy);
+  });
+
+  equal(controller.get("notifications"), null, "notifications are null before data has finished loading");
+  equal(Discourse.User.current().get("unread_notifications"), 1, "current user's unread notifications count is not zeroed before data has finished loading");
+  ok(viewSpy.showDropdownBySelector.notCalled, "dropdown with notifications is not shown before data has finished loading");
+
+
+  server.respond();
+
+  deepEqual(controller.get("notifications"), ["notification"], "notifications are set correctly after data has finished loading");
+  equal(Discourse.User.current().get("unread_notifications"), 0, "current user's unread notifications count is zeroed after data has finished loading");
+  ok(viewSpy.showDropdownBySelector.calledWith("#user-notifications"), "dropdown with notifications is shown after data has finished loading");
+});
diff --git a/test/javascripts/controllers/notification_controller_test.js b/test/javascripts/controllers/notification_controller_test.js
new file mode 100644
index 000000000..2628a9803
--- /dev/null
+++ b/test/javascripts/controllers/notification_controller_test.js
@@ -0,0 +1,51 @@
+var controller;
+var notificationFixture = {
+  notification_type: 1, //mentioned
+  post_number: 1,
+  topic_id: 1234,
+  slug: "a-slug",
+  data: {
+  topic_title: "some title",
+    display_username: "velesin"
+  }
+};
+var postUrlStub = "post-url-stub";
+
+module("Discourse.NotificationController", {
+  setup: function() {
+    sinon.stub(Discourse.Utilities, "postUrl").returns(postUrlStub);
+
+    controller = Discourse.NotificationController.create({
+      content: notificationFixture
+    });
+  },
+
+  teardown: function() {
+    Discourse.Utilities.postUrl.restore();
+  }
+});
+
+test("scope property is correct", function() {
+  equal(controller.get("scope"), "notifications.mentioned");
+});
+
+test("username property is correct", function() {
+  equal(controller.get("username"), "velesin");
+});
+
+test("link property returns empty string when there is no topic title", function() {
+  var fixtureWithEmptyTopicTitle = _.extend({}, notificationFixture, {data: {topic_title: ""}});
+  Ember.run(function() {
+    controller.set("content", fixtureWithEmptyTopicTitle);
+  });
+
+  equal(controller.get("link"), "");
+});
+
+test("link property returns correctly built link when there is a topic title", function() {
+  var $link = $(controller.get("link"));
+
+  ok(Discourse.Utilities.postUrl.calledWithExactly("a-slug", 1234, 1), "URL is generated with the correct slug, topic ID and post number");
+  equal($link.attr("href"), postUrlStub, "generated link points to a correct URL");
+  equal($link.text(), "some title", "generated link has correct text");
+});
diff --git a/test/javascripts/controllers/notifications_controller_test.js b/test/javascripts/controllers/notifications_controller_test.js
new file mode 100644
index 000000000..26ceeb1cd
--- /dev/null
+++ b/test/javascripts/controllers/notifications_controller_test.js
@@ -0,0 +1,82 @@
+var controller, view;
+
+var appendView = function() {
+  Ember.run(function() {
+    view.appendTo(fixture());
+  });
+};
+
+var noItemsMessageSelector = "div.none";
+var itemListSelector = "ul";
+var itemSelector = "li";
+
+module("Discourse.NotificationsController", {
+  setup: function() {
+    sinon.stub(I18n, "t", function (scope, options) {
+      options = options || {};
+      return [scope, options.username, options.link].join(" ").trim();
+    });
+
+    controller = Discourse.NotificationsController.create();
+
+    view = Ember.View.create({
+      controller: controller,
+      templateName: "notifications"
+    });
+  },
+
+  teardown: function() {
+    I18n.t.restore();
+  }
+});
+
+test("mixes in HasCurrentUser", function() {
+  ok(Discourse.HasCurrentUser.detect(controller));
+});
+
+test("by default uses NotificationController as its item controller", function() {
+  equal(controller.get("itemController"), "notification");
+});
+
+test("shows proper info when there are no notifications", function() {
+  controller.set("content", null);
+
+  appendView();
+
+  ok(exists(fixture(noItemsMessageSelector)), "special 'no notifications' message is displayed");
+  equal(fixture(noItemsMessageSelector).text(), "notifications.none", "'no notifications' message contains proper internationalized text");
+  equal(count(fixture(itemListSelector)), 0, "a list of notifications is not displayed");
+});
+
+test("displays a list of notifications and a 'more' link when there are notifications", function() {
+  controller.set("itemController", null);
+  controller.set("content", [
+    {
+      read: false,
+      scope: "scope_1",
+      username: "username_1",
+      link: "link_1"
+    },
+    {
+      read: true,
+      scope: "scope_2",
+      username: "username_2",
+      link: "link_2"
+    }
+  ]);
+
+  appendView();
+
+  var items = fixture(itemSelector);
+  equal(count(items), 3, "number of list items is correct");
+
+  equal(items.eq(0).attr("class"), "", "first (unread) item has proper class");
+  equal(items.eq(0).text(), "scope_1 username_1 link_1", "first item has correct content");
+
+  equal(items.eq(1).attr("class"), "read", "second (read) item has proper class");
+  equal(items.eq(1).text(), "scope_2 username_2 link_2", "second item has correct content");
+
+  var moreLink = items.eq(2).find("> a");
+  equal(moreLink.attr("href"), Discourse.User.current().get("path"), "'more' link points to a correct URL");
+  equal(moreLink.text(), "notifications.more …", "'more' link has correct text");
+});
diff --git a/test/javascripts/integration/header_test.js b/test/javascripts/integration/header_test.js
index 215dcab6c..f7ce4f336 100644
--- a/test/javascripts/integration/header_test.js
+++ b/test/javascripts/integration/header_test.js
@@ -144,7 +144,7 @@ test("notifications: content", function() {
       equal(notificationsDropdown().find("li").eq(1).attr("class"), "read", "list item for read notification has correct class");
       equal(notificationsDropdown().find("li").eq(1).html(), 'notifications.replied velesin <a href="/t/topic/1234/2">Some topic title</a>', "notification without a slug and for a non-first post in a topic is rendered correctly");
 
-      equal(notificationsDropdown().find("li").eq(2).html(), 'notifications.liked velesin <a href=""></a>', "notification without topic title is rendered correctly");
+      equal(notificationsDropdown().find("li").eq(2).html(), 'notifications.liked velesin', "notification without topic title is rendered correctly");
 
       equal(notificationsDropdown().find("li").eq(3).attr("class"), "read last", "list item for 'more' link has correct class");
       equal(notificationsDropdown().find("li").eq(3).find("a").attr("href"), Discourse.User.current().get("path"), "'more' link points to a correct URL");
diff --git a/test/javascripts/models/notification_test.js b/test/javascripts/models/notification_test.js
deleted file mode 100644
index 830c97e58..000000000
--- a/test/javascripts/models/notification_test.js
+++ /dev/null
@@ -1,5 +0,0 @@
-module("Discourse.Notification");
-
-test("create", function() {
-  ok(Discourse.Notification.create(), "it can be created without arguments");
-});
\ No newline at end of file
diff --git a/test/javascripts/views/header_view_test.js b/test/javascripts/views/header_view_test.js
new file mode 100644
index 000000000..f4968da92
--- /dev/null
+++ b/test/javascripts/views/header_view_test.js
@@ -0,0 +1,14 @@
+module("Discourse.HeaderView");
+
+test("showNotifications", function() {
+  var controllerSpy = {
+    send: sinon.spy()
+  };
+  var view = Discourse.HeaderView.create({
+    controller: controllerSpy
+  });
+
+  view.showNotifications();
+
+  ok(controllerSpy.send.calledWith("showNotifications", view), "sends showNotifications message to the controller, passing header view as a param");
+});