Architecture for web clients

Many software projects involve cross-cutting concerns like logging, caching or security. Designing for these concerns is time well spent as these aspects tend to lead to duplicated or strongly coupled code. Accidental complexity and code that is hard to extend or maintain can occur when the proper design for these aspects is being neglected.

In this article we will use logging of all HTTP requests as an example requirement for cross cutting concerns. To simplify the implementation we will just use the console object to log the output. When simplified, this is similar to requirements often found in real-world Angular 2 projects. We will discuss several different design and implementation options along with the impact on the application. A proper implementation should not violate DRY (Don’t Repeat Yourself) and SRP (Single Responsibility Principle). There is no canonical or golden hammer way to achieve these goals, though. As we will see, each option has different properties regarding testability, ease of change and maintainability.

In our example scenario we have a backend API that can be accessed using HTTP. It provides resources for the entities Customer and Invoice. For each remote call the URL should be logged.

Example scenario

Within the application, Customers and Invoices are core business objects. Customers are accessed by the CustomerService while invoices are accessed by theInvoiceService. Both services fetch their entities from an HTTP/JSON-based backend.

To focus on cross-cutting concerns, presentation logic and integration with higher level Angular 2 components are not covered in this article.

The following list contains a breakdown of the tasks required to properly fetch the entities from the backend resources

Individual responsibilities:

URL building

Executing HTTP request for the URL

Mapping of JSON-Response to target type

Cross-cutting concerns:

Logging

To illustrate these points, let us consider fetching data for a list of Invoices from a remote endpoint located at http://api.example.com/

In our example, a valid request path for this request can be formed by appending the name of the resource, “invoices” to the base URL of the endpoint. The resulting URL is: http://api.example.com/invoices/

In order to retrieve the invoices, a HTTP GET request is performed via a separate HTTP service.

The HTTP response contains a JSON object as payload, which represents the invoices. Since we use TypeScript, the response has to be converted to a TypeScript object with actual types by parsing the JSON response and instantiating new objects based on the JSON data. The actual instantiation is left out here to keep this as simple as possible. Instead, a type assertion is used. A type assertion tells the TypeScript compiler to treat the expression following the assertion as if it were of the asserted type. After the compilation, the type information is lost completely as the runtime is regular, untyped, JavaScript. In the following example, a type of invoice-array is asserted:

.map((response) => <Invoice[]>response.json());



Finally we want to log the request to the console. While not recommended for production, it will help us in this particular example. Logging can be implemented as shown in the next example, where the URL used in the HTTP-GET request is printed to the console:

console.log(`url: ${url}`);



When comparing the different solutions we will inspect at which point each responsibility is being implemented and how a change of requirements impacts the solution. The change scenario is to modify the log format at a later stage. In addition, we evaluate how an additional cross-cutting concern, caching, is being implemented.

The simplest solution consists of directly implementing each task right away —that is if we begin with a naive approach. While the perceived developer productivity will be good and no obvious problems exist, we will see how this can lead to hard-to-maintain code and a loss of development velocity at a later stage.

Naive approach

In this simplistic approach, every service which needs to access HTTP resources implements all functionality on its own. This covers the basic responsibilities as well as the cross-cutting concerns, as it is shown in the following code example:

customer.service.ts import {Injectable} from '@angular/core' import {Http} from '@angular/http'; import {Observable} from 'rxjs/Observable'; import 'rxjs/Rx'; import {Customer} from './customer.model'; @Injectable() export class CustomerService { private name: string = 'customers'; constructor(private http: Http) {} protected buildURL(): string { return `/data/${this.name}/data.json`; } public getCustomers(): Observable<Customer[]> { let url = this.buildURL(); console.log(`url: ${url}`); return this.http.get(url) .map((response) => <Customer[]>response.json()); } } invoice.service.ts import {Injectable} from '@angular/core' import {Http} from '@angular/http'; import {Observable} from 'rxjs/Observable'; import 'rxjs/Rx'; import {Invoice} from './invoice.model'; @Injectable() export class InvoiceService { private name: string = 'invoices'; constructor(private http: Http) {} protected buildURL(): string { return `/data/${this.name}/data.json`; } public getInvoices(): Observable<Invoice[]> { let url = this.buildURL(); console.log(`url: ${url}`); return this.http.get(url) .map((response) => <Invoice[]>response.json()); } }

In this approach code can be written fast and with minimal planning effort. But this comes at a price: you will have to write – and maintain – duplicate code throughout the application.

Building the URL, executing the HTTP call, mapping of the JSON response and logging is handled in every service. (Note that the URLs in all examples point to local json files used as substitute for a real backend.)

This is a strong violation of the DRY (Don’t Repeat Yourself) principle. Also the SRP (single responsibility principle) is affected.

From a testing perspective, the impact becomes even more visible: Each individual service must be tested to correctly implement all responsibilities, resulting in duplicate test code. Furthermore, the tests will be complex, as for every class, every functionality has to be tested.

Implementing the change of the log format will come with the risk of not implementing the changes in all services consistently since the change has to be performed multiple times at different locations. This is especially true if more than one developer or multiple branches are involved.

Adding a new cross-cutting concern draws a similar picture: Caching can be added to the application by adding it to each service and adjusting all tests. If multiple developers or branches are involved, the risk of inconsistencies within the application increases even more.

Result: This approach is applicable for small demos or learning purposes; a use in production systems is highly discouraged. During later stages of a project, this approach manifests itself in a large code base. Changes need to be implemented at multiple locations making copy/paste an attractive option, bugs are (re-)appearing at different locations and documentation is not up to date or inconsistent.

In order to avoid such an approach during your project, cross cutting concerns should be addressed at an early design stage. To prevent running accidentally into this kind of scenario, regular code reviews can be performed.

Revised approach

Refining the naive approach from the previous section a new component, the BackendService, is introduced. It handles HTTP communication and implements logging. Other services will use delegation to access it and support their respective clients.

In the following example, the HTTP handling logic is delegated from the separate services to a single BackendService. This service handles all the backend communications and the logging concerns.

backend.service.ts import {Injectable} from '@angular/core' import {Http} from '@angular/http'; import {Observable} from 'rxjs/Observable'; import 'rxjs/Rx'; @Injectable() export class BackendService{ constructor(private http: Http) { } public get(url:string): Observable<any[]> { console.log(`url: ${url}`); return this.http.get(url) .map((response) => response.json()||[]); } }

Both the customer and the invoice service behave similarly by building the desired URL and using the BackendSerivce to handle the request execution and logging.

invoice.service.ts import {Injectable} from '@angular/core'; import {Observable} from 'rxjs/Observable'; import 'rxjs/Rx'; import {BackendService} from '../shared/backend.service'; import {Invoice} from './invoice.model'; @Injectable() export class InvoiceService { constructor(private backendService: BackendService) { } protected buildURL(): string { return `/data/invoices/data.json`; } public getInvoices(): Observable<Invoice[]> { return this.backendService.get(this.buildURL()) .map(elem => { return <Invoice[]>elem; }); } } customer.service.ts import {Injectable} from '@angular/core'; import {Observable} from 'rxjs/Observable'; import 'rxjs/Rx'; import {BackendService} from '../shared/backend.service'; import {Customer} from './customer.model'; @Injectable() export class CustomerService { constructor(private backendService: BackendService) { } protected buildURL(): string { return `/data/customers/data.json`; } public getCustomers(): Observable<Customer[]> { return this.backendService.get(this.buildURL()) .map(response => { return <Customer[]>response; }); } }

This approach improves the solution by delegating responsibility of cross-cutting concerns to a another service. However, some duplicate code remains. For example URL building in this variant is still implemented inside each service, as well as the mapping of the JSON response.

Therefore, both invoice and customer services still violate the DRY principle, whereas the BackendService violates the SRP, as all cross-cutting concerns are to be implemented here. Especially when taking into account future additions and extensions, the BackendService will have more responsibilities and an increased complexity. Testing a backend service with such an amount of functionality will become complex very fast. Testing the customer and invoice services on the other hand will be simpler than in the last approach, but we’re still left with duplicating some test code.

Implementing a change of the log format will be simpler and more reliable compared to the last approach, as the code of only one class has to be adjusted. However, as this class can also have many other functionalities, adjusting the logging format may still be a task which involves more effort than desired.

Adding another cross cutting concern, like caching, is done inside the backend service in this case. As already mentioned, this results in an even stronger violation of SRP and more complex (testing) code.

Result: This approach is a good first step into the right direction, as duplicate code will be reduced drastically, also assuring a better consistency within the application. The downside here is the concentration of logic inside the backend service. Such an accumulation will become harder and harder to maintain as functionality grows.

Abstract base class

To further reduce boilerplate code, the duplicate aspects – including cross-cutting concerns, basic response mapping and URL building – are separated into an abstract base class, the BaseBackendService.

This approach allows including URL building as part of the base class, when a convention for the HTTP API URLs is introduced. By enforcing a common naming scheme for all endpoints (like /customers for customers and /invoices for invoices) the URL building only requires the endpoint entry path. All other URLs are then deduced automatically.

base-backend.service.ts import {Injectable} from '@angular/core' import {Http} from '@angular/http'; import {Observable} from 'rxjs/Observable'; import 'rxjs/Rx'; import {Entity} from './entity.model'; @Injectable() export abstract class BaseBackendService{ constructor(private http: Http, private name: string) { } protected buildURL(): string { return `/data/${this.name}`; } public get(): Observable<E[]> { let url = `${this.buildURL()}/data.json`; console.log(`url: ${url}`); return this.http.get(url) .map((response) => <E[]>response.json()); } }

The invoice (backend) and customer (backend) services are extending the abstract base-backend service, adding only their specific typing information, as well as information for URL building. The following example shows these aspects in bold.

invoice.service.ts import {Injectable} from '@angular/core'; import {Http} from '@angular/http'; import {Observable} from 'rxjs/Observable'; import 'rxjs/Rx'; import {BaseBackendService} from '../shared/index'; import {Invoice} from './invoice.model'; @Injectable() export class InvoiceService extends BaseBackendService<Invoice> { constructor(http: Http) { super(http,'invoices'); } }

The customer service looks similar, reducing boilerplate code to a minimum.

customer.service.ts import {Injectable} from '@angular/core'; import {Http} from '@angular/http'; import {Observable} from 'rxjs/Observable'; import 'rxjs/Rx'; import {BaseBackendService} from '../shared/index'; import {Customer} from './customer.model'; @Injectable() export class CustomerService extends BaseBackendService<Customer>{ constructor(http: Http) { super(http, 'customers'); } }

Within this scenario, the URL building, HTTP handling as well as mapping of the target entity is handled in one place – the BaseBackendService – along with the cross-cutting concerns, like logging. This reduces boiler plate code and leads to a better conformity with the DRY principle. On the other hand, it violates the SRP, as all functionality is handled inside the base backend.

In order to test an abstract class, it has to be extended by another class. So testing either the invoice or the customer service may be sufficient. Again, this is good in terms of DRY, as only one class has to be tested, but this one test file may be huge and complicated due to violation of SRP.

Requesting a change of the log format, like before, has to be handled inside a single class, the BaseBackendService. So the same advantages – like changes necessary only in one place – and similar disadvantages – like high complexity – apply.

Like before, an introduction of a new concern, caching in this example, will increase complexity of the base class.

Result: Compared to the last approach a further reduction of duplicate code is achieved. The trade-off is high complexity in one class, in this case the BaseBackendService.

Decorator (Interceptor)

This section is not necessarily related to the TypeScript specific @Decorator annotations but the decorator design pattern. A decorator, or wrapper, exposes the same interface as the underlying class, while adding additional behavior or modifying existing behavior. The underlying object does not have to support or even know that a decorator is in place.

An Angular 2 decorator resembles the interceptor approach from AngularJS without the requirement of explicit support for interceptors on the service class. It is therefore not only applicable to an HTTP service.

In our case, the logging-decorator for the HTTP service is provided via a factory function implemented with a lambda expression. The factory function itself has to be provided to the app component. For this, the useFactory property can be used.

Inside the app component

@Component({ selector: ‘app-cmp’, templateUrl: ‘...’ providers: [HTTP_PROVIDERS, { provide: Http, useFactory: (xhrBackend: XHRBackend, requestOptions: RequestOptions) => new HttpLoggingDecorator( new Http(xhrBackend, requestOptions) ), deps: [XHRBackend, RequestOptions] } ] }) export class AppComponent{}

Usually, decorators are implemented against an interface. Angular 2 lacks a separate interface for the http service. Another option is to use a derived class. To facilitate wrapping the Angular 2 Http Service by providing a constructor with a reference for delegating the http calls from the decorators to the actual Http Service, we add the HttpDecorator class.

TypeScript requires us to call constructor of the super-class from within our class. As we delegate the http calls to the wrapped Http Service, we do not need to access inherited behavior. So we call the required super constructor with null arguments. While this is not elegant, it is due to the constraints of TypeScript and the current Angular 2 API.

All methods that should be accessible from within the program have to be implemented in a similar way to the get method.

http.decorator.ts

import {Injectable} from '@angular/core'; import {Http, RequestOptionsArgs, Response} from '@angular/http'; import {Observable} from 'rxjs/Observable'; @Injectable() export abstract class HttpDecorator extends Http { constructor(private delegate: Http) { super(null, null); } get(url: string, options?: RequestOptionsArgs): Observable<Response> { return this.delegate.get(url, options); } //... }

Handling of the actual logging concern is done by overwriting the HttpDecorator’s get-Method. After logging, the call is forwarded to the HttpDecorator implementation which eventually calls the instance held as delegate.

http-logging.decorator.ts

import {Injectable} from '@angular/core'; import {Http, RequestOptionsArgs, Response} from '@angular/http'; import {Observable} from 'rxjs/Observable'; import {HttpDecorator} from './http-decorator.service'; @Injectable() export class HttpLoggingDecorator extends HttpDecorator{ constructor(http:Http) { super(http); } get(url: string, options?: RequestOptionsArgs): Observable<Response> { console.log(url); return super.get(url, options); } }

The invoice service gets injected an instance of type Http. Because we provided the HttpLoggingDecorator to the whole app in an earlier step, the logging logic is also injected into the invoice service (and the customer service as well).

Invoice.service.ts

import {Injectable} from '@angular/core'; import {Http} from '@angular/http'; import {Observable} from 'rxjs/Observable'; import 'rxjs/Rx'; import {Invoice} from './invoice.model'; @Injectable() export class InvoiceService { private url: string; constructor(private http: Http) { this.url = '/data/invoices/data.json'; } getInvoices(): Observable<Invoice[]> { return this.http.get(this.url) .map( (response) => <Invoice[]>response.json() ); } }

The customer.service.ts is implemented similarly to the invoice service, so URL building, HTTP handling and mapping of the JSON response is handled inside both services. Compared to the last approach, the DRY principle is therefore violated.

However, in this case, the SRP is followed, as all cross-cutting concerns – like logging – are separated into their own decorators. If each service requires specific handling of the result or uses a different kind of URL building, we are confronted with individual concerns and multiple implementations are justified.

Testing of the decorators will be easier compared to the former approaches, as every decorator has only limited responsibility to be tested. As for testing the customer and invoice service, duplicate program code – like URL building and HTTP handling – requires duplicate testing code with all the above mentioned pitfalls.

Changing the log format in this approach can be easily done, as only a single class has to be adapted. Just a single test class needs to be updated to verify this change.

Extending the functionality for the cross-cutting concern caching can be done in an own decorator, which can be provided for example on top of the HttpLoggingDecorator. Testing this new functionality remains an easy thing to do, as it is kept to an own class.

Result: This implementation can leverage the dependency injection features of Angular 2 to provide a custom implementation (the HttpLoggingDecorator) instead of the default implementation (the Angular 2 Http Service).

Aspect oriented programming

The goal of aspect oriented programming (AOP) is to provide a general mechanism for weaving additional logic into program code without making changes to the code itself, resulting in very loose coupling between the additional logic and the actual business logic.

AOP defines the following concepts: Joinpoint, Pointcut, Advice and Aspect. We will have a look at their respective responsibilities.

An aspect is a class which encapsulates and defines advices, which are the additional logic to be executed at certain points in a program. These points are called join points. All join points that should be used are specified in a query list called pointcut. For implementing an AOP-based solution, the aspect.js library by Minko Gechev is used in version 0.2.4. It makes use of the decorator-syntax originally proposed for the ECMAScript 2015 version, which is also already present in TypeScript and is a core building block for the Angular 2 dependency injection system.

With aspect.js, each aspect can define certain methods to be called (the advice, for examplelog()) on a specific action (the pointcut get). Our aspect defines an advice which is applied to all method calls starting with get within classes containing the regex-pattern (Invoice|Customer)Service in their name. The Metadata available to the advice may contain the actual method- and class names along with the method-call parameters.

logging.aspect.ts

import {Injectable} from '@angular/core'; import {beforeMethod, Metadata} from 'aspect.js/dist/lib/aspect'; @Injectable() export class LoggingAspect { @beforeMethod({ classNamePattern: /(Invoice|Customer)Service/, methodNamePattern: /^(get)/ }) invokeBeforeMethod(meta: Metadata) { console.log(`Inside of the logger. Called ${meta.className}.${meta.method.name} with args: ${meta.method.args.join(', ')}.` ); } }

To make a TypeScript class available to an aspect, it has to be decorated with the @Wove annotation.

invoice.service.ts

import {Injectable} from '@angular/core'; import {Http} from '@angular/http'; import {Observable} from 'rxjs/Observable'; import 'rxjs/Rx'; import {Wove} from 'aspect.js/dist/lib/aspect'; import {Invoice} from './invoice.model'; @Injectable() @Wove() export class InvoiceService{ private url: string; constructor(private http: Http) { this.url = '/data/invoices/data.json'; } get(): Observable<Invoice[]> { return this.http.get(this.url) .map( (response) => <Invoice[]>response.json() ); } }

In this approach we implement URL building, HTTP handling and mapping of the JSON response in the invoice as well as the customer service. Therefore, the DRY principle will be violated. Logging on the other hand is performed by only one single aspect, adhering to the SRP and DRY principles.

As each aspect serves one responsibility, testing an aspect is simple. For the invoice and customer services, test code has to be duplicated due to duplicated program code.

Changing the logging format is easy in this approach, as only one class has to be adjusted. To introduce another cross cutting concern like caching, only a new aspect (e.g.CachingAscpect) has to be introduced and one must make sure that the class to get advice is decorated with an @Wove decorator. As this new aspect again adheres to the SRP, it will be easy to test.

Result: Designing cross-cutting concerns in Angular 2 apps with an aspect oriented approach is a nice option. However, AOP libraries for javascript/typescript are not (yet) mature. The most advanced library is aspect.js from Minko Gechev. This library needs an@Wove-annotation on a pointcut to target a specific class. This reduces the benefit of loose coupling, which AOP normally introduces.

Monkey patching

A common JavaScript approach to patch a new behavior into a system during runtime is monkey patching. Although it is similar to AOP, it is required to explicitly replace methods with the patched counterparts and handle delegation as part of the replacement code.

In our example, we might patch the cross cutting concern logging into the Angular 2 Http Service. For this we inject the Http Service into our app component and overwrite its get-function with a new function which handles the logging and then calls the original get-function. Note that opposed to a traditional JavaScript function, a TypeScript lambda expression does not mark the beginning of a new scope, so this keyword can be used within the lambda expression to reference the AppComponent’s context.

Inside the app component:

@Component selector: 'app-cmp', templateUrl: '...', providers: [HTTP_PROVIDERS] }) export class AppComponent { constructor(private http: Http) { let get = this.http.get; this.http.get = (url: string, options?: RequestOptionsArgs): Observable<Response> => { console.log(url); return get.call(this.http, url, options); }; } }

In this approach the invoice and customer service classes would look the same like in the decorator example. Therefore, this approach has the same implications for SRP and DRY as the decorator approach.

A downside to this approach is the monkey patch being applied at runtime. This and the fact that the monkey patch is applied from within the main component make testing the patch very difficult. Monkey patching from within the main component also violates the SRP.

The requirement to change the logging format can be implemented via changing the monkey patching code, which would be rather simple – after locating the correct part of the code that adds the patches. Implementation of additional caching also has to go into the AppComponent, further violating the SRP. When separating the monkey patches into different classes, one has to be careful to not override other monkey patches to the same function.

Result: While possible, it is not recommended to use this approach because of strong violations of the SRP and general issues like limited testability.

Conclusion

Depending on the context and goals, different design approaches are the right choice. Sometimes it makes sense to combine different designs. If, for example, the result data mapping is always implemented in an identical way, an abstract base class or helper class is a good decision.

Cross-cutting concerns should not be implemented using inheritance, as inheritance is very limiting when it comes to implementing different concerns. Therefore, cross-cutting concerns can best be handled from within a decorator or an AOP-Aspect. Usage of decorators or AOP also promotes the SRP, as for each concern an own decorator/aspect is created.

The DRY-ness of each approach can be inferred from the number of times each concern is actually implemented. In our example we have two services (invoice and customer). If these two services would have to use 5 methods (get, put, post, delete, options), the logging concern would have to be implemented 5 x 2 = 10 times within the naive approach. With the decorators, each concern has to be implemented for the five different methods, 5 x 1 = 5. With AOP, each aspect can be defined – thanks to the regular expressions – in such a way that each concern only has to be implemented once in general, 1 x 1 = 1. A detailed listing of the DRY-ness of the approaches can be found in the following table.

The following table summarizes this article by showing where the different concerns are implemented for the different approaches. For example, the general concern “Http access” is handled within the business services (invoice and customer) for the naive, decorator and AOP approaches. The cross-cutting concerns ‘logging’ and ‘caching’ on the other hand are implemented in the business services only for the naive approach.

Additionally, the number of implementations of each cross cutting concern is shown in a generalized form. It is assumed that each cross-cutting concern has to be used by n different services, which need to implement m different methods.

Most of the discussion in this article applies to other frameworks like AngularJS or React.js as well, even if they don’t use TypeScript as a source language.

So regardless of your decisions, keep in mind that good design should not be taken for granted. Plus, it is also not a golden hammer that can solve all problems.