19

I have created a full example for the purpose of describing this issue. My actual application is even bigger than the presented demo and there are more services and directives operated by every controller. This leads to even more code repetition. I tried to put some code comments for clarifications, PLUNKER: http://plnkr.co/edit/781Phn?p=preview

Repetitive part:

routerApp.controller('page1Ctrl', function(pageFactory) {
  var vm = this;

  // page dependent
  vm.name = 'theOne';
  vm.service = 'oneService';
  vm.seriesLabels = ['One1', 'Two1', 'Three1'];

  // these variables are declared in all pages
  // directive variables,
  vm.date = {
    date: new Date(),
    dateOptions: {
      formatYear: 'yy',
      startingDay: 1
    },
    format: 'dd-MMMM-yyyy',
    opened: false
  };

  vm.open = function($event) {
    vm.date.opened = true;
  };

  // dataservice
  vm.data = []; // the structure can be different but still similar enough
  vm.update = function() {
      vm.data = pageFactory.get(vm.service);
    }

  //default call
  vm.update();   
})

Basically I moved all the logic I could to factories and directives. But now in every controller that uses certain directive I need, for example, a field that keeps the value that directive is modifying. And it's settings. Later I need similar field to keep the data that comes from dataservice, and the call itself (method) is the same as well.

This leads to a lot of repetition.


Graphically I see the current example to look like this:

The current design

While I believe the proper design should look more like this:

The expected design


I tried to find some solution here, but none seem to be confirmed. What I have found:

  1. AngularJS DRY controller structure, suggesting I pass the $scope or vm and decorate it with extra methods and fields. But many sources say it is dirty solution.
  2. What's the recommended way to extend AngularJS controllers? using angular.extend, but this have problems when using controller as syntax.
  3. And then I have found also the answer (in the link above):

You don't extend controllers. If they perform the same basic functions then those functions need to be moved to a service. That service can be injected into your controllers.

And even when I did there is still a lot of repetition. Or is it the way it just has to be? Like John Papa sais (http://www.johnpapa.net/angular-app-structuring-guidelines/):

Try to stay DRY (Don't Repeat Yourself) or T-DRY

Did you face a similar issue? What are the options?

Community
  • 1
  • 1
Atais
  • 10,857
  • 6
  • 71
  • 111
  • It sounds like this is a question of "to what extent are they different". This is a very subjective question - and without concrete details, I don't think it'd be right to take a side. But as you know your app the best, and moving it into a service doesn't feel right, then it might just be the case that it might not be the right thing to do. But if you provide some code, maybe other members can shed some light on it. – Code Apprentice Nov 25 '15 at 15:10
  • I gave more real data example, however I do not think it will help. It is - as stated before, simply most of the variables that are used by some directive or service. But the issue is I still need to redefine them for every view that uses similar controller. – Atais Nov 25 '15 at 16:46
  • John Papa also wrote _"Being DRY is important, but not crucial if it sacrifices the others in LIFT". Consider splitting up the controller into smaller ones and / or even directives. The smaller a component the higher the chance of being reusable. – a better oliver Nov 25 '15 at 18:51
  • @zeroflagL but now that is the problem. I did split it as much as possible but now I have for example 6-10 views using the same directive, which require the same fields to be declared. For example a date picker which requires date field and date format. It can not be combined into bigger directive without breaking LIFT but now every view has to declare same fields over and over. In exact same way. And in my case it is actually a combination of 2-3 directives copied over and over. So is there any way to make this more DRY? – Atais Nov 26 '15 at 08:36
  • I solved this by using ui-router which allows for parent and children states. I will have a parent controller that gets called for every child controller. – jjbskir Nov 30 '15 at 16:13
  • @jjbskir The plunker example is already using ui-router. Can you show your solution? – Atais Nov 30 '15 at 16:39
  • @Atais I think passing $scope or vm into a factory, which can then handle similar setup's across multiple controllers would work - http://plnkr.co/edit/UogMGHSJp1XLZTL26JxH?p=preview - this has worked for me on several projects. I like using the Parent controller idea if I know functions will be used across the entire application, but am unsure if that is true for this set up so would prefer the factory idea. – jjbskir Nov 30 '15 at 18:39
  • @jjbskir this is solution presented in link #1. I wish you presented it as an answer as it would be easier to reply, but anyway this solution starts to work really strange when some field requires the factory field to be already set. I found it quite hard to manage as the variables are not being set top-down like they used to be. But maybe it was just our issue and we messed something up. I wonder what are others' experiences. The plunker works quite good but as I said, it is still simplified :-) – Atais Nov 30 '15 at 21:50
  • @Atais K, I can add it as a answer. If you add a bit more detail about your issues using a factory or a plnkr I can see what is going on with that. – jjbskir Nov 30 '15 at 22:13
  • @jjbskir the issue, by example: after your `pageFactory.setup()` method I set some page-specific variable based on another variable, that is operated by the factory. It happened to me, that the factory variables were `null` or `undefined` even though the factory init method was already called. – Atais Dec 01 '15 at 09:17

5 Answers5

8

From a over all design perspective I don't see much of a difference between decorating a controller and extending a controller. In the end these are both a form of mixins and not inheritance. So it really comes down to what you are most comfortable working with. One of the big design decisions comes down to not just how to pass in functionality to just all of the controllers, but how to also pass in functionality to say 2 out of the 3 controllers also.

Factory Decorator

One way to do this, as you mention, is to pass your $scope or vm into a factory, that decorates your controller with extra methods and fields. I don't see this as a dirty solution, but I can understand why some people would want to separate factories from their $scope in order to separate concerns of their code. If you need to add in additional functionality to the 2 out of 3 scenario, you can pass in additional factories. I made a plunker example of this.

dataservice.js

routerApp.factory('pageFactory', function() {

    return {
      setup: setup
    }

    function setup(vm, name, service, seriesLabels) {
      // page dependent
      vm.name = name;
      vm.service = service;
      vm.seriesLabels = seriesLabels;

      // these variables are declared in all pages
      // directive variables,
      vm.date = {
        date: moment().startOf('month').valueOf(),
        dateOptions: {
          formatYear: 'yy',
          startingDay: 1
        },
        format: 'dd-MMMM-yyyy',
        opened: false
      };

      vm.open = function($event) {
        vm.date.opened = true;
      };

      // dataservice
      vm.data = []; // the structure can be different but still similar enough
      vm.update = function() {
        vm.data = get(vm.service);
      }

      //default call
      vm.update();
    }

});

page1.js

routerApp.controller('page1Ctrl', function(pageFactory) {
    var vm = this;
    pageFactory.setup(vm, 'theOne', 'oneService', ['One1', 'Two1', 'Three1']);
})

Extending controller

Another solution you mention is extending a controller. This is doable by creating a super controller that you mix in to the controller in use. If you need to add additional functionality to a specific controller, you can just mix in other super controllers with specific functionality. Here is a plunker example.

ParentPage

routerApp.controller('parentPageCtrl', function(vm, pageFactory) {

    setup()

    function setup() {

      // these variables are declared in all pages
      // directive variables,
      vm.date = {
        date: moment().startOf('month').valueOf(),
        dateOptions: {
          formatYear: 'yy',
          startingDay: 1
        },
        format: 'dd-MMMM-yyyy',
        opened: false
      };

      vm.open = function($event) {
        vm.date.opened = true;
      };

      // dataservice
      vm.data = []; // the structure can be different but still similar enough
      vm.update = function() {
        vm.data = pageFactory.get(vm.service);
      }

      //default call
      vm.update();
    }

})

page1.js

routerApp.controller('page1Ctrl', function($controller) {
    var vm = this;
    // page dependent
    vm.name = 'theOne';
    vm.service = 'oneService';
    vm.seriesLabels = ['One1', 'Two1', 'Three1'];
    angular.extend(this, $controller('parentPageCtrl', {vm: vm}));
})

Nested States UI-Router

Since you are using ui-router, you can also achieve similar results by nesting states. One caveat to this is that the $scope is not passed from parent to child controller. So instead you have to add the duplicate code in the $rootScope. I use this when there are functions I want to pass through out the whole program, such as a function to test if we are on a mobile phone, that is not dependent on any controllers. Here is a plunker example.

jjbskir
  • 8,474
  • 9
  • 40
  • 53
  • Maybe it is just a difference in the point of view, but still the most appealing solution to me is by `extending controller` and to be honest I am really surprised that you achieved that with `controller as` syntax! I thought (and read) it does not work. Even though the directive solution would suit here I would opt for your solution because it is more flexible. Thanks so much! – Atais Dec 02 '15 at 09:32
  • For your situation that makes sense! I personally use a combination of these solutions through out my apps. – jjbskir Dec 02 '15 at 14:38
  • the extend controller solution is awesome...thank you so much for this! i'd upvote twice if i could – Pedro Garcia Mota May 13 '16 at 07:47
3

You can reduce a lot of your boilerplate by using a directive. I've created a simple one to replace all of your controllers. You just pass in the page-specific data through properties, and they will get bound to your scope.

routerApp.directive('pageDir', function() {
  return {
    restrict: 'E',
    scope: {},
    controller: function(pageFactory) {
      vm = this;
      vm.date = {
        date: moment().startOf('month').valueOf(),
        dateOptions: {
          formatYear: 'yy',
          startingDay: 1
        },
        format: 'dd-MMMM-yyyy',
        opened: false
      };

      vm.open = function($event) {
        vm.date.opened = true;
      };

      // dataservice
      vm.data = []; // the structure can be different but still similar enough
      vm.update = function() {
        vm.data = pageFactory.get(vm.service);
      };

      vm.update();
    },
    controllerAs: 'vm',
    bindToController: {
      name: '@',
      service: '@',
      seriesLabels: '='
    },
    templateUrl: 'page.html',
    replace: true
  }
});

As you can see it's not much different than your controllers. The difference is that to use them, you'll use the directive in your route's template property to initialize it. Like so:

    .state('state1', {
        url: '/state1',
        template: '<page-dir ' +
          'name="theOne" ' +
          'service="oneService" ' +
          'series-labels="[\'One1\', \'Two1\', \'Three1\']"' +
          '></page-dir>'
    })

And that's pretty much it. I forked your Plunk to demonstrate. http://plnkr.co/edit/NEqXeD?p=preview

EDIT: Forgot to add that you can also style the directive as you wish. Forgot to add that to the Plunk when I was removing redundant code.

kevrom
  • 156
  • 6
  • 1
    The styling was just to distinguish the views so do not worry. I really enjoy working with directives as well. The only hard-part I can image is when you would have to change a small detail >inside< the directive's HTML. For example when in the middle there should be an extra button. But in this case I guess it would work just fine. – Atais Dec 01 '15 at 09:33
  • This is true. I didn't design it with that in mind. There are solutions for that, though. You can create points in the template for extending. Perhaps set a flag such that `` and then you can check for the `vm.button` in the template, and `ng-include` that file in a particular spot. It may get harder to reason about your code in that case though. – kevrom Dec 01 '15 at 09:43
2

I can't respond in comment but here what i will do :

I will have A ConfigFactory holding a map of page dependent variables :

{
  theOne:{
      name: 'theOne',
      service: 'oneService',
      seriesLabels: ['One1', 'Two1', 'Three1']
  },
  ...
}

Then i will have a LogicFactory with a newInstance() method to get a proper object each time i need it. The logicFactory will get all the data / method shared betwwen controllers. To this LogicFactory, i will give the view-specific data. and the view will have to bind to this Factory.

And to retrieve the view-specific data i will pass the key of my configuration map in the router.

so let say the router give you #current=theOne, i will do in the controller :

var specificData = ServiceConfig.get($location.search().current);
this.logic = LogicFactory.newInstance(specificData);

Hope it help

I retouch your example, here is the result : http://plnkr.co/edit/ORzbSka8YXZUV6JNtexk?p=preview

Edit: Just to say this way, you can load the specific configuration from a remote server serving you the specific-view data

Igloob
  • 197
  • 5
  • Could you present this with the example plunker? – Atais Nov 30 '15 at 21:40
  • here it is : [http://plnkr.co/edit/ORzbSka8YXZUV6JNtexk?p=preview](http://plnkr.co/edit/ORzbSka8YXZUV6JNtexk?p=preview) – Igloob Nov 30 '15 at 22:29
  • I was thinking about using one controller with different setup but I have never found an example of such config online. However it could work out not that bad. Thanks! – Atais Nov 30 '15 at 22:30
  • One controller is not mandatory, i just dit it to clean the code. You can have multi controller witth their own logic depending of your need – Igloob Dec 01 '15 at 20:49
  • Frankly speaking, I have not implemented my previous components in the presented way. I find it really interesting as a solution but I am not sure if I would use it in my project. Still, thanks for sharing and I hope others can learn from it too. – Atais Dec 02 '15 at 09:29
2

I faced completely the same issues as you described. I'm a very big supporter of keeping things DRY. When I started using Angular there was no prescribed or recommended way to do this, so I just refactored my code as I went along. As with many things I dont think their is a right or wrong way to do these things, so use whichever method you feel comfortable with. So below is what I ended up using and it has served me well.

In my applications I generally have three types of pages:

  1. List Page - Table list of specific resource. You can search/filter/sort your data.
  2. Form Page - Create or Edit resource.
  3. Display Page - Detailed view-only display page of resource/data.

I've found there are typically a lot of repetitive code in (1) and (2), and I'm not referring to features that should be extracted to a service. So to address that I'm using the following inheritance hierarchy:

  1. List Pages

    • BaseListController
      • loadNotification()
      • search()
      • advancedSearch()
      • etc....
    • ResourceListController
      • any resource specific stuff
  2. Form Pages

    • BaseFormController
      • setServerErrors()
      • clearServerErrors()
      • stuff like warn user is navigating away from this page before saving the form, and any other general features.
    • AbstractFormController
      • save()
      • processUpdateSuccess()
      • processCreateSuccess()
      • processServerErrors()
      • set any other shared options
    • ResourceFormController
      • any resource specific stuff

To enable this you need some conventions in place. I typically only have a single view template per resource for Form Pages. Using the router resolve functionality I pass in a variable to indicate if the form is being used for either Create or Edit purposes, and I publish this onto my vm. This can then be used inside your AbstractFormController to either call save or update on your data service.

To implement the controller inheritance I use Angulars $injector.invoke function passing in this as the instance. Since $injector.invoke is part of Angulars DI infrastructure, it works great as it will handle any dependencies that the base controller classes need, and I can supply any specific instance variables as I like.

Here is a small snippet of how it all is implemented:

Common.BaseFormController = function (dependencies....) {
    var self = this;
    this.setServerErrors = function () {
    };
    /* .... */
};

Common.BaseFormController['$inject'] = [dependencies....];

Common.AbstractFormController = function ($injector, other dependencies....) {
    $scope.vm = {};
    var vm = $scope.vm;
    $injector.invoke(Common.BaseFormController, this, { $scope: $scope, $log: $log, $window: $window, alertService: alertService, any other variables.... });
   /* ...... */
}

Common.AbstractFormController['$inject'] = ['$injector', other dependencies....];

CustomerFormController = function ($injector, other dependencies....) {
    $injector.invoke(Common.AbstractFormController, this, {
            $scope: $scope,
            $log: $log,
            $window: $window,
            /* other services and local variable to be injected .... */
        });

    var vm = $scope.vm;
    /* resource specific controller stuff */
}

CustomerFormController['$inject'] = ['$injector', other dependencies....];

To take things a step further, I found massive reductions in repetitive code through my data access service implementation. For the data layer convention is king. I've found that if you keep a common convention on your server API you can go a very long way with a base factory/repository/class or whatever you want to call it. The way I achieve this in AngularJs is to use a AngularJs factory that returns a base repository class, i.e. the factory returns a javascript class function with prototype definitions and not an object instance, I call it abstractRepository. Then for each resource I create a concrete repository for that specific resource that prototypically inherits from abstractRepository, so I inherit all the shared/base features from abstractRepository and define any resource specific features to the concrete repository.

I think an example will be clearer. Lets assume your server API uses the following URL convention (I'm not a REST purest, so we'll leave the convention up to whatever you want to implement):

GET  -> /{resource}?listQueryString     // Return resource list
GET  -> /{resource}/{id}                // Return single resource
GET  -> /{resource}/{id}/{resource}view // Return display representation of resource
PUT  -> /{resource}/{id}                // Update existing resource
POST -> /{resource}/                    // Create new resource
etc.

I personally use Restangular so the following example is based on it, but you should be able to easily adapt this to $http or $resource or whatever library you are using.

AbstractRepository

app.factory('abstractRepository', [function () {

    function abstractRepository(restangular, route) {
        this.restangular = restangular;
        this.route = route;
    }

    abstractRepository.prototype = {
        getList: function (params) {
            return this.restangular.all(this.route).getList(params);
        },
        get: function (id) {
            return this.restangular.one(this.route, id).get();
        },
        getView: function (id) {
            return this.restangular.one(this.route, id).one(this.route + 'view').get();
        },
        update: function (updatedResource) {
            return updatedResource.put();
        },
        create: function (newResource) {
            return this.restangular.all(this.route).post(newResource);
        }
        // etc.
    };

    abstractRepository.extend = function (repository) {
        repository.prototype = Object.create(abstractRepository.prototype);
        repository.prototype.constructor = repository;
    };

    return abstractRepository;
}]);

Concrete repository, let's use customer as an example:

app.factory('customerRepository', ['Restangular', 'abstractRepository', function (restangular, abstractRepository) {

    function customerRepository() {
        abstractRepository.call(this, restangular, 'customers');
    }

    abstractRepository.extend(customerRepository);
    return new customerRepository();
}]);

So now we have common methods for data services, which can easily be consumed in the Form and List controller base classes.

Beyers
  • 8,968
  • 43
  • 56
  • Thanks for a great contribution. It is a great piece of advice for future applications' planning. However its not something I could use right now. Since @jjbskir was the first one to provide working extending example (in both ways actually) I have chosen his answer for bounty. Nevertheless I might use your solution in next project :)! Cheers. – Atais Dec 07 '15 at 09:25
1

To summarize the previous answers:

  1. Decorating controllers: as you said, this is a dirty solution; Imagine having different factories decorating the same controller, it will be very difficult (especially for other developers) to prevent collision of properties, and equally difficult to trace which factory added which properties. It's actually like having multiple inheritance in OOP, something that most modern languages prevent by design for the same reasons.

  2. Using a directive: this can be a great solution if all your controllers are going to have the same html views, but other than that you will have to include fairly complex logic in your views which can be difficult to debug.


The approach I propose is using composition (instead of inheritance with decorators). Separate all the repetitive logic in factories, and leave only the creation of the factories in the controller.

routerApp.controller('page1Ctrl', function (Page, DateConfig, DataService) {
    var vm = this;

    // page dependent
    vm.page = new Page('theOne', 'oneService', ['One1', 'Two1', 'Three1']);

    // these variables are declared in all pages
    // directive variables,
    vm.date = new DateConfig()

    // dataservice
    vm.dataService = new DataService(vm.page.service);

    //default call
    vm.dataService.update();

})

.factory('Page', function () {

    //constructor function
    var Page = function (name, service, seriesLabels) {
        this.name = name;
        this.service = service;
        this.seriesLabels = seriesLabels;
    };

    return Page;

})


.factory('DateConfig', function () {

    //constructor function
    var DateConfig = function () {
        this.date = new Date();
        this.dateOptions = {
            formatYear: 'yy',
            startingDay: 1
        };
        this.format = 'dd-MMMM-yyyy';
        this.opened = false;
        this.open = function ($event) {
            this.opened = true;
        };
    };

    return DateConfig;

})

This code is not tested, but I just want to give an idea. The key here is to separate the code in the factories, and add them as properties in the controller. This way the implementation is not repeated (DRY), and everything is obvious in the controller code.

You can make your controller even smaller by wrapping all the factories in a larger factory (facade), but this may make them more tightly coupled.

gafi
  • 12,113
  • 2
  • 30
  • 32
  • Thanks for the summary, as for the bounty - I actually asked for a working example since sometimes the idea is right, but in the end it does not work ;-). Hope we all learn from the examples. – Atais Dec 07 '15 at 12:38
  • I meant that the concept works, but you may find some bugs in the sample code – gafi Dec 08 '15 at 04:43