Structure for large-scale frontend development projects

So you read John Papa’s Angular App Structuring Guidelines and apply feature-based structuring of your app? You use ES6 syntax to get more readable and maintainable code? You are proficient with type definitions and including existing libaries is your second nature? That’s great! What’s next?
When your scope exceeds that of a single app, there are some core concepts from strong-typed development worlds like Java and .Net that make your code manageable at the next level; I’m talking about abstract classes, Inversion of Control (IoC), modules / namespaces and setting up reusable libraries. In this article I will present you with the tools and structure required to do this.

DI and IoC Angular

TypeScript – modules, references and imports

I chose TypeScript as an ES6 syntax simply because of Angular team’s preference, most of the apps I’ve been working on have been Angular.
Since most browsers don’t understand this code, we need to translate it to something readable, i.e. ES5. This is called transpiling, the transpiler in this case is TSC. It does two things: first it validates the code and then it translates the code to ES5. In order to check validity the code, it needs to know for each file what other objects are referenced, much like using statements in Java and .Net.

There are different ways to link code files together in TypeScript. For all cases I choose to reference code files and use module names. It gives me a way to keep physical file structure completely separate from logical structure, a good programming practice: my compiler is the only one that needs to be aware of code file structure.

// File 1
declare module "url" {
    export interface Url {
        protocol?: string;
        hostname?: string;
        pathname?: string;
    }
 
    export function parse(urlStr: string, parseQueryString?, slashesDenoteHost?): Url;
}
 
// File 2
///<reference path="file1.ts"/>
import url = require("url");
var myUrl = url.parse("http://www.typescriptlang.org");

As you can see, file references are kept separate from module definitions. Now if only we had a way to auto-reference all TypeScript files, we can completely get rid of the file references. As I mentioned only the compiler needs to know about file references. To get TSC to auto-reference all code files, we configure TSC as described on the Wiki. Furthermore, external references are included by using Typings: a repository of library definitions.

Introduce your own libraries

Now that we have all the components in place for developing a single app, it’s time to scale up. Most importantly, how do you set up your own library with reusable objects that deliver a specific functionality or deliver a base object that can be extended in an app. Integrating your libraries means you look at the following topics:

  • What do I provide in a library.
  • How to set up the angular binding and dependency injection.
  • How to deliver your library implementation.
  • How to properly reference the code in your library.
module app.module {
    'use strict';
 
    export interface IController extends angular.IScope {
        loading: boolean;
        contract: app.models.Contract;
        getContract(id: string): void;
    }
 
    class ControllerBase implements IController {
        loading: boolean;
        contract: app.models.Contract;
 
        deliveryPlaceName: string;
 
        constructor($scope: angular.IScope,
            private $state: angular.ui.IState,
            private $stateParams: angular.ui.IStateParamsService,
            private service: app.services.IService,
            private otherService: app.services.IOtherService
        ) {
            // ...
        }
 
        getContract(id: string): void {
            // ...
        }
 
        internalMethod(): boolean {
            // ...
        }
    }
 
    class Controller extends ControllerBase {
        loading: boolean;
        contract: app.models.Contract;
 
        deliveryPlaceName: string;
 
        static $inject = [
            '$scope',
            '$state',
            '$stateParams',
            'app.services.IService',
            'app.services.IOtherService'
        ];
 
        constructor($scope: angular.IScope,
            private $state: angular.ui.IState,
            private $stateParams: angular.ui.IStateParamsService,
            private service: app.services.IService,
            private otherService: app.services.IOtherService
        ) {
			super($scope, $state, $stateParams, service, otherService);
 
            // Initialize data
        }
    }
}

Angular Binding

The crucial component that makes all the difference between readable code and a brain overload is the Angular Binding. Each binding looks similar to this:

angular
        .module('app.module')
        .controller('app.module.IController', Controller);

In its core this reads like: “In Angular module app.module, when an app.module.IController is requested, insert Controller”. In the past we have always set the key to the same name as the implementation. That worked fine, since there was always one app and therefore one implementation. No confusion.

But Beware: we are now working with more than one app, better yet we want to deliver base classes with different implementations! My best practice is therefore to always use the Interface as key, this gives much more readable code. Also it will make the IoC part at the end of this article much more readable.

Library content

Most examples you see of TypeScript code in Angular are aimed at bundling one Angular component in a file. Each file consists of defining a Module, providing an Interface definition, providing a class that implements that interface and ending with an Angular Binding.

module app.module {
    'use strict';
 
    export interface IController extends angular.IScope {
        loading: boolean;
        contract: app.models.Contract;
        getContract(id: string): void;
    }
 
    class Controller implements IController {
        loading: boolean;
        contract: app.models.Contract;
 
        deliveryPlaceName: string;
 
        static $inject = [
            '$scope',
            '$state',
            '$stateParams',
            'app.services.IService',
            'app.services.IOtherService'
        ];
 
        constructor($scope: angular.IScope,
            private $state: angular.ui.IState,
            private $stateParams: angular.ui.IStateParamsService,
            private service: app.services.IService,
            private otherService: app.services.IOtherService
        ) {
            // ...
        }
 
        getContract(id: string): void {
            // ...
        }
 
        internalMethod(): boolean {
            // ...
        }
    }
 
    angular
        .module('app.module')
        .controller('app.module.IController', Controller);
}

Given the lessons we have learned from delivering libraries – but keeping the practicality of JavaScript structures – I propose the following guidelines for structuring:

  • Interfaces and their implementations will be contained in the same library.
  • Each interface and each class get their own file.
  • Modules are defined as done in single-app projects; one for services, one per feature, one for directives. At developer’s discretion.
  • A library doesn’t mention anything about Angular bindings.

In the example above, we get two files: The interface definition and the class definition. Since we don’t apply Angular binding in the library, we have the flexibility to define which specific implementation must be injected at the specific app (this is detailed below). My application structure now looks as follows:

lib/
    directives/
    components/
		layout/
			shell.html
			shell.i-controller.ts
			shell.controller.ts
			topnav.html
			topnav.i-controller.ts
			topnav.controller.ts
		people/
			attendees.html
			attendees.i-controller.ts
			attendees.controller.ts
    services/
        data.i-service.ts
        data.service-base.ts
        modal.i-service.ts
        modal.service-base.ts
        spinner.i-service.ts
        spinner.service.ts
app/
    app.module.ts
    app.config.ts
    app.routes.ts
    directives.ts
    directives/
    components/
		people/
			speakers.html
			speakers.i-controller.ts
			speakers.controller.ts
			speaker-detail.html
			speaker-detail.i-controller.ts
			speaker-detail.controller.ts
		sessions/
			sessions.html      
			sessions.i-controller.ts
			sessions.controller.ts
			session-detail.html
			session-detail.i-controller.ts  
			session-detail.controller.ts  
    services/
        data.service.ts  
        modal.service.ts  
        localstorage.service.ts
        logger.service.ts

Library Delivery

The library is delivered like any other external NPM / Bower package, at the organisation’s discretion. My suggestion is to provide each package from its own Git repository, and reference it directly from the application’s package.json.

It is best practice to deliver your package including the compiled code, so at a minimum the package should include:

  • One JavaScript file containing the non-minified code, usable for development purposes and to be integrated in a transpilation pipeline.
  • The typings definition file that is generated when transpiling, This can be done using the –specification parameter of the TSC transpiler.

Now that we have a working library and the typings definition, it should be consumed. This can easily be done by adding the package in the dependencies, linking the built code like with any other package and add a reference to the typings definition.

Angular Binding, Dependency Injection and Inversion of Control

And now everything will come together. As a last step, make sure you structure your app’s content in the same way as your libraries. The result is a set of code files, consisting of:

  • Interfaces, defined in libraries and in the app.
  • Base classes, defined in the libraries. Examples can be application messages and modal windows. The base class provides the general ones, extended in an app with specifics.
  • Concrete classes, defined in libraries and in the app.

Now that all implementation is provided, it can be injected into Angular in a single IoC definition file, as follows:

angular.module('app', ['ui.bootstrap','ui.utils','ui.router','ngAnimate', 'ngResource']);
 
angular.module('app.service', []);
angular.module('app.service').service('lib.service.IFirstService', lib.service.FirstService);
angular.module('app.service').service('lib.service.ISecondService', app.service.SecondService);
angular.module('app.service').service('app.service.IFirstService', app.service.FirstService);
 
angular.module('app.module', []);
angular.module('app.module').controller('lib.module.IFirstController', lib.module.FirstController);
angular.module('app.module').controller('lib.module.ISecondController', app.module.SomeController);
angular.module('app.module').controller('app.module.IFirstController', app.module.FirstController);
angular.module('app.module').controller('app.module.ISecondController', app.module.SecondController);
angular.module('app.module').controller('app.module.IThirdController', app.module.ThirdController);
angular.module('app.module').controller('app.module.IFourthController', app.module.FourthController);
 
angular.module('app.directives', []);
angular.module('app.directives').directive('lib.directives.IFirstDirective', lib.directives.FirstDirective.instance);
angular.module('app.directives').directive('lib.directives.ISecondDirective', lib.directives.SecondDirective.instance);
angular.module('app.directives').directive('lib.directives.IThirdDirective', lib.directives.ThirdDirective.instance);
angular.module('app.directives').directive('lib.directives.IFourthDirective', app.directives.SomeDirective.instance);

Conclusion

With the continuing rise in front end development the needs to develop larger structures keeps growing. To keep up with these needs we can learn a lot by looking at other areas of expertise. Above story showcases how concepts from strong-typed languages helps us in keeping a maintainable code base using minimal effort, applying this set of concepts:

  • Separate Interfaces from their implementations
  • Use Base Classes that can be extended in each application
  • Deliver your generic libraries, including its typings definition
  • Centralize Dependency Injection in each application through use of IoC
Facebooktwitterredditpinterestlinkedinmail

1 Response

Leave a Reply