Angular Architecture: A book on applying enterprise patterns and practices in Angular. This sample chapter talks about cross-cutting concerns:
- what they are
- benefits
- how to implement your own
- how to provide configuration to a cross-cutting concern
See the book on Leanpub.com - Angular Architecture by Matt Vaughn
What is a cross-cutting concern?
It is a concern that crosses the boundaries of your application layers and domain verticals. They are typically agnostic to domain features of the application. They provide useful features to applications like configuration, logging, and error handling. Most applications have requirements to log application events, handle errors and log those errors to a centralized repository. This allows administrators to monitor the health of the application.
A typical cross-cutting concern is something that is not specific to the domain of your application. For example, if you are building a learning management system that include features for authors, students, courses, and videos - it would be something so generic, yet functional, that is used within all or quite a few domain features.
For example, instead of implementing logging directly within an application, we can abstract and separate the cross-cutting concern from the application. I recommend implementing these as library projects. This allows other applications and libraries within an Angular Workspace to use the specified cross-cutting concern library.
Cross-cutting concerns are like many of the infrastructure items that are often not thought about, discussed, or included in the early architectural decisions. Let's say your company has spent 2 or more years of development on a specific project - the current project doesn't have any centralized error handling or logging of information, events, or even errors. How can you quantify that the application is working properly? How will you know if there is a problem with the latest build or deployment? This may seem like an extreme example. However, as a consultant I have seen this very scenario more than once.
It is easier to determine what cross-cutting concerns the application requires early and most likely before a single line of domain feature code is written. I like to think of cross-cutting concerns as required infrastructure concerns that provide value during the development, maintenance, and runtime of the application.
These concerns are like the electrical, plumbing, and air systems that are part of a home. Each room of a house has a specific concern and set of features - however, most likely each of the rooms require one if not all of the infrastructure elements listed above. If someone built a new home and these cross-cutting concerns were not addressed before construction, where and how would they be put in retroactively? The home would be an odd mess. We normally follow convention in many parts of our lives, in building homes and other things. Do not let the excitement of building out the features of a new application outweigh the importance of having a good foundation for your application which includes cross-cutting concerns.
Pro Tip: The minimal cross-cutting concerns for an enterprise Angular application are logging and error handling. I cannot stress the value and importance of these (2) concerns. If there are any issues in your code, you know early if you log and handle errors.
Identify Candidates for Reuse
A good rule to follow when developing applications is to identify candidates to share with multiple applications or libraries. Implement these candidates as stand-alone library projects. The Angular Workspace with library projects gives us the ability to share and reuse code effectively. Consider it thoughtful development when you think through the solutions with this level of detail.
Putting reusable code in their own libraries is a great organizational strategy. It provides a single source of truth for the specific feature. If the feature needs an enhancement or improvement due to a defect, then you have one place to make that change and then all of the other applications or libraries benefit. This eliminates the need to go to many different applications or many different areas within an application to fix something that should have been implemented as a reusable library.
Pro Tip: Some or just enough analysis, design, and planning can make a difference of success or failure. Look back at some failed projects or features to determine if a little more preparation could have made a difference.
It is interesting to think that setting up a new development environment could have so much code and features without a single line of the domain application code. I have found over the years that most applications, if not all of them, require these very common cross-cutting concerns. Therefore, implementing them as reusable libraries makes a lot of sense. It is more efficient and effective in terms of managing your code, improving the code, and allowing other applications and libraries to share the code.
Creating reusable libraries in the Angular Workspace is a relatively new topic. The Angular Workspace is now a capability since version 6 of Angular. If you or your team are not taking advantage of the Angular Workspace by creating reusable libraries, then you are missing out on one of the biggest game-changers in Angular and web development. These are new capabilities that many web development teams have not had the luxury. Using cross-cutting concern libraries simplifies the implementation of your domain code. The code is much cleaner, the signatures of constructors and methods are specific to the domain and do not include any cross-cutting concern items.
Separate early and often. Do not implement things that should be shared directly in an application project.
I take advantage of a configuration library that provides or pushes configuration to each of the cross-cutting concerns during runtime. Many of these cross-cutting concern libraries contain services that can be injected into your application (i.e., services or components). Thus, we get the ability to use dependency injection and to have the ability to use these cross-cutting concerns very easily throughout our Angular application projects.
The reason why I bring up these cross-cutting concerns when we are talking about implementing a feature module is that every feature module requires these things. Therefore, it is very important that the architect or team lead take the time to define the patterns (recipes) for implementing cross-cutting concerns in feature modules. The architect determines and demonstrates how each feature module service or component logs information, how they handle errors, and how they provide information back to the user. The team lead or architect is also responsible for establishing the different layers of the application within a feature module. They define how each layer communicates with other layers within the feature module.
It may seem odd to a teammate to see so much defined upfront. Some team members may not understand why everything needs to be defined upfront before developing the feature module. However, if the patterns, practices, and recipes to create each of the feature module elements are well defined, there is more consistency in the implementation. Therefore the code is more consistent and maintainable throughout the lifetime of the application. I have seen entire codebases for large applications to have such consistency; that you cannot tell which team member implemented what part of the code.
Remember that any application that is deployed to production is an application that requires maintenance.
I have heard some developers say that with so much structure, planning, and design - that it inhibits innovation and creativity. This is not true. To truly innovate and be creative a person needs a full-understanding and knowledge of how something works, not superficial knowledge. This type of understanding also requires experience over time or at least a strong analysis of the domain. With this level of understanding and knowledge a developer can truly be creative and innovative. Without it, it is just guesswork with getting lucky occasionally. Do not confuse the occasional success as full understanding and knowledge of how something works if you didn't do a good-enough analysis of the domain item.
It is not sufficient to know how to write code and compile it. You need to understand the domain of the application along with its concerns, why it is important and beneficial, who uses it, the business rules and workflow. This takes discipline and commitment.
Putting thought, design and a plan in place early allows for easier maintenance and extensibility later. For example I propose that services and components should all extend from a base class. This base class provides the structure and opportunity to provide common elements, features, methods, and properties that all components or services can share. It is an excellent extensibility point.
Logging
Before we write any domain code for the feature we might want to think about how are we going to handle logging or writing information about events that happen in the application. It is important for the developers of the application to know when and where and also in what sequence things take place when business logic is executed. Therefore our application requires a logging service so that we can persist this information.
The simplest way to do this in Angular is to create a service that can be injected into our components and services. This service at the minimum logs information to the console of the application browser or Visual Studio Code environment. This allows you to see information about events, errors, and details about the application during development.
Use the following CLI command to create a new library project within your Angular Workspace. This allows other projects to use the features of the logging module/service.
ng g library logging --publishable
LoggingService
The following LoggingService is a typical implementation that I use to provide the capture of log events for an application. It only does a few things.
- retrieves configuration for logging from the configuration service
- uses a logEntries\$ Observable to publish log events
- the log() method is used by consumers of the LoggingService to add a new log event
Note: the logging service requires some configuration. Please see the Configuration section for more information on how to provide configuration to cross-cutting concern modules and services.
import { Injectable, Optional } from "@angular/core";
import { Severity } from "./severity.enum";
import { IConfiguration } from "@angularlicious/configuration";
import { ConfigurationService } from "@angularlicious/configuration";
import { LogEntry } from "./log-entry";
import { ReplaySubject, Observable } from "rxjs";
import { ILogEntry } from "./i-log-entry";
import { LoggingConfig } from "@angularlicious/configuration";
import { Guid } from "guid-typescript";
import { take } from "rxjs/operators";
@Injectable()
export class LoggingService {
serviceName = "LoggingService";
source: string;
severity: Severity;
message: string;
timestamp: Date = new Date();
applicationName: string;
version: string;
isProduction: boolean;
config: LoggingConfig;
id: Guid = Guid.create();
private logEntriesSubject: ReplaySubject<ILogEntry> = new ReplaySubject<
ILogEntry
>(1);
logEntries$: Observable<ILogEntry> = this.logEntriesSubject.asObservable();
/**
* The [LoggingService] constructor.
*/
constructor(@Optional() public configService: ConfigurationService) {
this.log(
this.serviceName,
Severity.Information,
`Starting logging service [${this.id.toString()}] at: ${this.timestamp}`
);
this.initializeService(configService);
}
/**
* Use to initialize the logging service. Retrieves
* application configuration settings.
*
* @param configService contains the configuration settings for the application
*/
private initializeService(configService: ConfigurationService) {
if (configService) {
this.configService.settings$
.pipe(take(1))
.subscribe(settings => this.handleSettings(settings));
}
}
/**
* Use to handle settings from the configuration service.
* @param settings
*/
handleSettings(settings: IConfiguration) {
if (settings) {
this.config = settings.loggingConfig;
this.applicationName =
this.config && this.config.applicationName
? this.config.applicationName
: "Angular";
this.version =
this.config && this.config.version ? this.config.version : "0.0.0";
this.isProduction =
this.config && this.config.isProduction
? this.config.isProduction
: false;
}
}
/**
* Use this method to send a log message with severity and source information
* to the application's logger.
*
* If the application environment mode is [Production], the information will
* be sent to a centralized repository.
*
* @param source
* @param severity
* @param message
*/
log(source: string, severity: Severity, message: string, tags?: string[]) {
this.source = `${this.applicationName}.${source}`;
this.severity = severity;
this.message = message;
this.timestamp = new Date(Date.now());
if (tags) {
tags.push(`LoggerId:${this.id.toString()}`);
} else {
tags = [`LoggerId:${this.id.toString()}`];
}
const logEntry = new LogEntry(
this.applicationName,
this.source,
this.severity,
this.message,
tags
);
this.logEntriesSubject.next(logEntry);
}
}
Log Entry Items
Notice that there is no code in the LoggingService to actually log or write an event to a repository or to the application console. The logging service publishes log entries using an Observable. This allows a separation of concerns to capture log entries and the actual mechanism to write them. How and where to write a log entry is up to you. All we need is a log writer that is responsible for logging events to a console or a Web API. This log writer subscribes to the logEntries\$ Observable and then write new log events from the data stream of new log items. The application creates log items and the LoggingService just publishes them.
Here is the ILogEntry interface that provides the definition of a log entry.
import { Severity } from "./severity.enum";
export interface ILogEntry {
source: string;
application: string;
severity: Severity;
message: string;
timestamp: Date;
tags?: string[];
}
When you create a new log entry, you can define the Severity level of the log item. The Severity allows you to designate the level of the log entry. During development, you may want to be verbose in your logging and log all entries. You might have a different strategy for production deployments. For example, you may only want to log entries that are Warning or Error designation only. You can add logic to your writers that log information based on the environment and the severity level of the log entry.
I'll use the Severity.Information
enum option to define log entries throughout the application to see a sequence of events and operations. I rely on these to show and display information relevant to the specific operation and feature. Here is an example of an informational log entry.
this.loggingService.log(
this.componentName,
Severity.Information,
`Preparing to load the provider(s) for authentication.`
);
Here is a sample Severity enum that includes some options that may be useful for your implementation.
export enum Severity {
Information = 1,
Warning = 2,
Error = 3,
Critical = 4,
Debug = 5
}
Log Entry Writer
So far, we've created a logging service that captures log events from an application. However, these log entries are not very useful unless we can view and monitor the log events. We can create a helper for this cross-cutting concern to actually write the log entries. Now we have to determine the destination of the log entries.
The easiest destination is the application console. Use the CLI to create a ConsoleWriter service in the Logging library project. Below is an example of a console writer. This implementation is a little unique. Notice that the service extends a LogWriter class. This LogWriter is a TypeScript abstract class that provides the structure and implementation details to implement any number of log writers for the application.
Remember that a log writer only needs to subscribe to the LoggingService and then handle any published log entry events. The writer is a service that is decorated with @Injectable which allows it to be provided to the application when it initializes, just like any other provider. Therefore, you have some control as to what log writers you want to provide for the application.
- The log writer has a LoggingService injected into the constructor.
- Subscribes to the logEntries\$ Observable
- handles published log events
- writes the log item to the specified target (console)
import { LogWriter } from "./log-writer";
import { ILogEntry } from "../i-log-entry";
import { Severity } from "../severity.enum";
import { Injectable } from "@angular/core";
import { LoggingService } from "./../logging.service";
/**
* Use this writer to log information to the browser console.
*/
@Injectable()
export class ConsoleWriter extends LogWriter {
constructor(private loggingService: LoggingService) {
super();
this.loggingService.logEntries$.subscribe(logEntry =>
this.handleLogEntry(logEntry)
);
}
handleLogEntry(logEntry: ILogEntry) {
this.targetEntry = logEntry;
this.execute();
}
/**
* No setup required for the console writer.
*/
public setup(): void {}
/**
* Implementation of the abstract method. This will perform the
* actual `write` action for the specified writer.
*/
public write(): void {
switch (this.targetEntry.severity) {
case Severity.Debug:
console.debug(this.targetEntry);
break;
case Severity.Information:
console.info(this.targetEntry);
break;
case Severity.Warning:
console.warn(this.targetEntry);
break;
case Severity.Error:
console.error(this.targetEntry);
break;
case Severity.Critical:
console.error(this.targetEntry);
break;
default:
break;
}
}
}
Log Writer Abstract Class
The LogWriter is an abstract class. This means that class cannot be instantiated directly - but it can provide the overall structure for any class to become a log writer. Only classes that extend this abstract class can be initialized. Abstract classes are unique in that they can define a set of abstract members (e.g., methods, properties) that require implementation from classes that extend from them. They can also provide implementation members - these members are consumed and available by classes that extend the abstract class. So they provide abstract members like an interface but have the capability to provide implementations for any public members.
Abstract classes are useful to provide common behaviors and a structure for any sub-classes that extend from it. It is like a super base class that is extensible. They are like a miniature blueprint for a specialized class. Abstract classes are not part of the JavaScript specification - they are built into TypeScript and support many Object-Oriented design principles.
Pro Tip: Use abstract classes and the template method design pattern. It is a powerful pattern to provide a consistent implementation for performing the same operation but using a distinct implementation. Can you think of anything in Angular that uses this pattern? Wait for it...the Angular Component has a life-cycle of events and methods - same pattern.
The template method abstract class implements the ILogWriter interface which has an execute()
method that requires implementation. You can name your entry point method anything you want - the important thing is that it provides a wrapper around the set of template methods.
import { ILogEntry } from "../i-log-entry";
export interface ILogWriter {
execute(): void;
}
The execute()
method is the entry point into the template method pattern for our writers. We have an entry method that is a controller for a set of methods that define the template or recipe. The recipe in this example is set of methods that provide the implementation of writing log entries. Here is the list or our methods that work together to provide this log writing capability.
- setup()
- validateEntry()
- write()
- finish()
The following is the actual template method that demonstrates the flow of the template methods. Some call this the pipeline or lifecycle of something. It is interesting to note that when you use the RxJS pipe()
method, you are creating a pipeline of methods that run in a specified sequence when you add any of the RxJS operator methods within the pipe.
execute(): void {
this.setup();
if (this.validateEntry()) {
this.write();
}
this.finish();
}
The LogWriter class below provides a default implementation for any classes that extend from it. The template method pattern is very extensible. You can add more methods to the template and provide a default implementation and then all other classes that extend from this abstract class now have that behavior.
The validateEntry()
method provides a default validation of the information required to log an entry whether it is to the console or to a Web API. If there are no rule violations, the writer invokes the specific writer's write()
method. In our current code example, it writes the entry to the application's console.
Note: I use another cross-cutting concern that provides the ability to use default and/or create custom rules for validation. I can reuse the rules-engine in services, components, business logic layers - anywhere there is a need to validate things in a consistent and reliable manner.
import { ILogWriter } from "./i-log-writer";
import {
ValidationContext,
IsTrue,
IsNotNullOrUndefined,
StringIsNotNullEmptyRange
} from "@angularlicious/rules-engine";
import { ILogEntry } from "../i-log-entry";
// @Injectable()
export abstract class LogWriter implements ILogWriter {
hasWriter: boolean; // = false;
targetEntry: ILogEntry;
/**
* Use this method to execute the write process for the
* specified [Log Entry] item.
*
* Using the [template method] design pattern.
*/
execute(): void {
this.setup();
if (this.validateEntry()) {
this.write();
}
this.finish();
}
/**
* Use to perform an setup or configuration of the [writer].
* The [setup] method runs on all executions of the writer - and
* is called before the [write] method.
*/
public abstract setup(): void;
/**
* Use to validate the [log entry] before attempting to write
* using the specified [log writer].
*
* Returns a [false] boolean to indicate the item is not valid.
*/
public validateEntry(): boolean {
const validationContext = new ValidationContext();
validationContext.addRule(
new IsTrue(
"LogWriterExists",
"The log writer is not configured.",
this.hasWriter
)
);
validationContext.addRule(
new IsNotNullOrUndefined(
"EntryIsNotNull",
"The entry cannot be null.",
this.targetEntry
)
);
validationContext.addRule(
new StringIsNotNullEmptyRange(
"SourceIsRequired",
"The entry source is not valid.",
this.targetEntry.source,
1,
100
)
);
validationContext.addRule(
new StringIsNotNullEmptyRange(
"MessageIsValid",
"The message is required for the [Log Entry].",
this.targetEntry.message,
1,
2000
)
);
validationContext.addRule(
new IsNotNullOrUndefined(
"TimestampIsRequired",
"The timestamp must be a valid DateTime value.",
this.targetEntry.timestamp
)
);
return validationContext.renderRules().isValid;
}
/**
* Use to implement the actual write of the [Log Entry].
*/
public abstract write(): void;
/**
* Use to finish the process or clean-up any resources.
*/
public finish(): void {}
}
Centralized Log Repository
Now that we have a LoggingService and a recipe for creating log writers we can add different writers that target a different destination (i.e., Web API/database). This is where logging get interesting. When a single page application is released into production, the application runs on a web browser: the client. Any activity or events that you want to log need to be stored in a centralized repository or location. We do not have access to the browser console anymore.
Adding log events to the browser's console does not provide any insight into what the application is doing or its current health. Therefore, we need (2) things:
- a centralized repository to store and persist log information
- a way to view log items - which may include searching and filtering
There are several cloud-based solutions that allow you to store application log information and to use their reporting tools to view the log events. I use Loggly. Usually, the cloud-based providers have entry-level offerings that provide basic features for free or at a low cost.
The snippet below is an implementation of our LogWriter for Loggly.
- configuration information is injected into the constructor (ConfigurationService)
- contains the API key for your Loggly account
- a boolean indicator to determine if the log item should be written to the console (sendConsoleErrors)
- the writer subscribes to the logEntries\$ Observable in the LoggingService
- when the logging service publishes a new log entry, this writer prepares it for Loggly
- the writer formats the message and pushes the new item onto the LogglyService to send the log information to the cloud-based repository
import { LogWriter } from "./log-writer";
import { ILogEntry } from "../i-log-entry";
import { ConfigurationService } from "@angularlicious/configuration";
import { Optional } from "@angular/core";
import { LogglyService } from "ngx-loggly-logger";
import { LoggingService } from "../logging.service";
import { IConfiguration, LogglyConfig } from "@angularlicious/configuration";
export class LogglyWriter extends LogWriter {
config: LogglyConfig;
constructor(
@Optional() private configService: ConfigurationService,
private loggingService: LoggingService,
private loggly: LogglyService
) {
super();
if (this.configService && this.loggingService) {
this.configService.settings$.subscribe(settings =>
this.handleSettings(settings)
);
this.loggingService.logEntries$.subscribe(entry =>
this.handleLogEntry(entry)
);
}
}
handleSettings(settings: IConfiguration) {
if (settings) {
this.config = settings.logglyConfig;
this.hasWriter = true;
console.log(`Initializing Loggly writer for messages.`);
}
}
handleLogEntry(entry: ILogEntry) {
if (this.hasWriter) {
this.targetEntry = entry;
this.execute();
}
}
/**
* This method is part of the [execute] pipeline. Do not call
* this method outside of the context of the execution pipeline.
*
* Use to setup the [Loggly] writer with an [apiKey] from the
* configuration service.
*
* It will use the configuration service to configure and initialize
* and set up a new call to log the information to the writer.
*/
public setup(): void {
if (this.hasWriter) {
try {
this.loggly.push({
logglyKey: this.config.apiKey,
sendConsoleErrors: this.config.sendConsoleErrors
});
if (this.targetEntry.tags && this.targetEntry.tags.length > 0) {
const tags = this.targetEntry.tags.join(",");
this.loggly.push({ tag: tags });
}
} catch (error) {
const message = `${this.targetEntry.application}.LogglyWriter: ${{
...error
}}`;
console.error(message);
}
}
}
/**
* This method is part of the [execute] pipeline - it will be called if the
* current [Log Entry] item is valid and the writer is initialized and ready.
*/
public write(): void {
this.loggly.push(this.formatEntry(this.targetEntry));
}
/**
* Use this function to format a specified [Log Entry] item. This should be moved
* to a specific [formatter] service that can be injected into the specified
* writer.
* @param logEntry
*/
formatEntry(logEntry: ILogEntry): string {
return `application:${logEntry.application}; source:${
logEntry.source
}; timestamp:${logEntry.timestamp.toUTCString()}; message:${
logEntry.message
}`;
}
}
The image below shows the browser console with log events. This same information is sent to Loggly. The HTTP request contains the following payload.
{
"text": "application:angularlicious; source:angularlicious.GuideComponent; timestamp:Sat, 16 Nov 2019 13:02:43 GMT; message:Preparing to set [Google Analytics] page view for [/custom-angular-modules].",
"sessionId": "9e71eb86-2352-42e5-959e-8efbf60946f5"
}
You can use the Loggly website to view, filter, and search for specific log items. This is very practical when you need to determine if there are any health or diagnostic concerns for your application.
Error Handling
Another thing to be concerned about is error handling. Angular provides a default error handler.The default error handler writes events to the console on the developer tools in the browser. So we also see a relationship between error handling and logging events in our application. We can provide a custom error handler for our application that does much more than just write the event to the console.
Errors and exceptions happen. Period. There are too many factors and dependencies to prohibit any errors from happening. Therefore, they need to be handled. And systems, users, and application administrators need notifications about errors. It is better to know about an error or exception during development than after a deployment to production.
It would be an error on our part to not consider adding a custom Error Handler to your Angular Workspace. You need it. This is not one of those things that you put into the category of we'll add this later when we have time.
Here are some things to consider for your error handling scenarios.
- Error Handling
- Determine where error handling should take place in the application - responsibility.
- Should there be a single source of error handling?
- What do you do with the error details and source?
- Do you deliver a generic error message, "Oops!" to the user?
- How do you handle different types of errors?
- HttpClient
- Application
- 3rd-party library
- API/Server
- Error Notification
- Determine if the end-user should be notified of the error.
- Should application/system administrators be notified - how?
- Error Logging (Tracking)
- Determine what is required for logging/tracking.
- Need to understand the context of the error.
- Do not log too little - need relevant information.
- When did it occur? What additional information should be included in the log message?
- Custom Error Classes
- instanceOf
- extending Error Classes
- adding rich meta data
Error Sources
We can categorize error sources
in (3) groups.
- External
- Internal
- Application
External Errors
External errors are external
from the running application. In our case, they are external to our Angular application running in a client browser. These occur on servers or APIs outside of our application's runtime environment. Server errors happen while attempting to process the request or during processing on the server.
- database connection errors
- database errors
- server exceptions
- application not available
Server
Most Angular applications use some kind of back end API(s) or server to perform additional application processing. Even if the Angular application is serverless
- meaning that it doesn't have its own specific server associated to the application. In this case, the Angular application may use several APIs and functions that are hosted by 3rd-party providers (think: APIs for MailChimp, Contentful, Firebase, Medium, or Google Cloud Platform, etc.).
Regardless of the source of these external
errors, an Angular application needs to handle them gracefully.
- 500 Errors: The server failed to fulfill a request.
Response status codes in the 500-range indicate cases in which the server
is aware that it has encountered an error or is otherwise incapable of performing the
request. Except when responding to a HEAD request, the server should include an entity
containing an explanation of the error situation, and indicate whether it is a
temporary or permanent condition. Likewise, user agents should display any included
entity to the user. These response codes are applicable to any request method.
Here is an example of some of the types of 500
Server Errors that can happen.
- 500 Internal Server Error: A generic error message, given when an unexpected condition was encountered and no more specific message is suitable.[62]
- 501 Not Implemented: The server either does not recognize the request method, or it lacks the ability to fulfill the request. Usually, this implies future availability (e.g., a new feature of a web-service API).[63]
- 502 Bad Gateway: The server was acting as a gateway or proxy and received an invalid response from the upstream server.[64]
- 503 Service Unavailable: The server is currently unavailable (because it is overloaded or down for maintenance). Generally, this is a temporary state.[65]
Internal Errors
The error may be due to invalid input provided by the request, failed business rules or failed data validation. The request may be mal-formed - and therefore, cannot be processed (wrong media-type, payload too large, invalid permissions).
- 400 Errors
This class of status code is intended for situations in which
the error seems to have been caused by the client. Except when responding to a HEAD
request, the server should include an entity containing an explanation of the
error situation, and whether it is a temporary or permanent condition. These status
codes are applicable to any request method. User agents should display any included
entity to the user.
Client (Browser) - JavaScript
JavaScript has an Error object that all errors in JavaScript derive from. The standard available properties for an error are as follows:
- columnNumber
- fileName
- lineNumber
- message
- name
- stack
This is the information that we see in the Console of the browser's developer tools. Here is a list of specialized types of errors that can occur.
- EvalError (not thrown any more (remains for compatibility)
- InternalError
- RangeError
- ReferenceError
- SyntaxError
- TypeError
- URIError
Application Errors
Believe it or not, applications can also be the source of errors
. These could be exceptional - meaning that they are unanticipated. However, when they do happen, the current flow of application flow is redirected to a registered provider for handling the error.
Developers, coders, and software engineers cannot write perfect code. The vast number of conditions, processing paths, algorithms, calculations, and other things that happen during the runtime of an application make it impossible to anticipate all scenarios.
Therefore, errors happen and we see them in the following cases:
- Business Rule Violations
- Data Validation Errors
- Application Exceptions
Handle Errors in Angular
Regardless of the origination of an error, an Angular application needs the ability to handle errors. Angular has a default ErrorHandler
that is provided to the application when the application is initialized
. This ErrorHandler
catches and handles all unanticipated errors.
Handling errors means:
- handling the error gracefully
- not allowing the error to disable the progress of the application
- storing information about the error event in a centralized repository
- providing notifications to interested parties.
It is really nice that the Angular platform includes such a feature.
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {
ERROR_ORIGINAL_ERROR,
getDebugContext,
getErrorLogger,
getOriginalError
} from "./errors";
export class ErrorHandler {
/**
* @internal
*/
_console: Console = console;
handleError(error: any): void {
const originalError = this._findOriginalError(error);
const context = this._findContext(error);
// Note: Browser consoles show the place from where console.error was called.
// We can use this to give users additional information about the error.
const errorLogger = getErrorLogger(error);
errorLogger(this._console, `ERROR`, error);
if (originalError) {
errorLogger(this._console, `ORIGINAL ERROR`, originalError);
}
if (context) {
errorLogger(this._console, "ERROR CONTEXT", context);
}
}
/** @internal */
_findContext(error: any): any {
if (error) {
return getDebugContext(error)
? getDebugContext(error)
: this._findContext(getOriginalError(error));
}
return null;
}
/** @internal */
_findOriginalError(error: Error): any {
let e = getOriginalError(error);
while (e && getOriginalError(e)) {
e = getOriginalError(e);
}
return e;
}
}
export function wrappedError(message: string, originalError: any): Error {
const msg = `${message} caused by: ${
originalError instanceof Error ? originalError.message : originalError
}`;
const error = Error(msg);
(error as any)[ERROR_ORIGINAL_ERROR] = originalError;
return error;
}
The actual code for the Angular ErrorHandler
contains comments and an example.
Provides a hook for centralized exception handling.
The default implementation of
ErrorHandler
prints error messages to theconsole
. To intercept error handling, write a custom exception handler that replaces this default as appropriate for your app.
The recommendation, although embedded in a comment, is to create our own implementation of the ErrorHandler
to provide more direct and customized handling of errors. The default error handler writes errors to the application's console. This is great during development. But it doesn't solve a requirement of having a centralized repository of error messages.
Angular applications are single-page applications where each application instance is on a browser. We do not have access to a user's browser when the application is published. We want a centralized repository for this information.
The code sample shows that we can create our own class that implements the ErrorHandler
interface. A custom handler
needs to override and provide a concrete implementation of the handleError()
method. Additionally, add the @Injectable
decorator to allow the provider to participate in Angular dependency injection.
There are (2) ways to provide a singleton service for your application.
- use the
providedIn
property, or- provide the module directly in the AppModule of the application
Since we want to override the default ErrorHandler for the Angular application, please remove the providedIn
configuration
@Injectable({
//providedIn: "root"
})
class MyErrorHandler implements ErrorHandler {
handleError(error) {
// do something with the exception
}
}
Custom Error Handler
Here is a sample Error Handling service that will log error messages using a LoggingService. This implementation requires some configuration to be provided to the service. The handleError()
determines the type of error and processes accordingly.
Note: If the error type is a HttpErrorResponse (i.e., response status code is 400s or 500s) type and is not an ErrorEvent (i.e., generalized JavaScript error) - there is no operation or logging performed. The HTTP service (a different cross-cutting concern) handles an error during the execution of HTTP calls. If there are HTTP errors or an HTTP response contains a known API error response body, the HTTP service will throw an Observable error to allow the consumer to handle it using the Observable pattern.
- the configuration provides an
includeDefaultErrorHandling
boolean property to indicate if logging to the application console should be performed. Normally this is set to false during for production environment deployments. - a LoggingService is injected into the constructor to allow the service to log error information
Note: the logging service requires some configuration. Please see the Configuration section for more information on how to provide configuration to cross-cutting concern modules and services.
@Injectable({
providedIn: "root"
})
export class ErrorHandlingService extends ErrorHandler {
serviceName = "ErrorHandlingService";
config: ErrorHandlingConfig;
hasSettings: boolean;
constructor(
private configService: ConfigurationService,
private loggingService: LoggingService
) {
super();
this.init();
}
init() {
this.config = new ErrorHandlingConfig();
this.config = {
applicationName: "Angular",
includeDefaultErrorHandling: true
};
this.config.applicationName = "ErrorHandlerService";
this.config.includeDefaultErrorHandling = false;
console.warn(`Application [ErrorHandler] is using default settings`);
this.configService.settings$
.pipe(take(1))
.subscribe(settings => this.handleSettings(settings));
}
handleSettings(settings: IConfiguration) {
if (settings && settings.errorHandlingConfig) {
this.config = settings.errorHandlingConfig;
this.hasSettings = true;
this.loggingService.log(
this.config.applicationName,
Severity.Information,
`Application [ErrorHandler] using configuration settings.`
);
}
}
/**
* Use to handle generalized [Error] items or errors from HTTP/Web
* APIs [HttpErrorResponse].
*
* @param error
*/
handleError(error: Error | HttpErrorResponse): any {
if (this.config.includeDefaultErrorHandling) {
// use the [super] call to keep default error handling functionality --> console;
super.handleError(error);
}
if (this.hasSettings) {
// A. HANDLE ERRORS FROM HTTP
if (error instanceof HttpErrorResponse) {
if (error.error instanceof ErrorEvent) {
// A.1: A client-side or network error occurred. Handle it accordingly.
const formattedError = `${error.name}; ${error.message}`;
this.loggingService.log(
this.config.applicationName,
Severity.Error,
`${formattedError}`
);
} else {
// A.2: The API returned an unsuccessful response (i.e., 400, 401, 403, etc.).
/**
* The [HttpService] should return a response that is consumable by the caller
* of the API. The response should include relevant information and error messages
* in a format that is known and consumable by the caller of the API.
*/
noop();
}
} else {
// B. HANDLE A GENERALIZED ERROR FROM THE APPLICATION/CLIENT;
const formattedError = `${error.name}; ${error.message}`;
this.loggingService.log(
this.config.applicationName,
Severity.Error,
`${formattedError}`
);
}
}
}
}
There is a lot of logic in the handleError()
method. First, it will use the configuration information to determine if it should log the event using the default base class. This will essentially log the item to the browser console.
if (this.config.includeDefaultErrorHandling) {
// use the [super] call to keep default error handling functionality --> console;
super.handleError(error);
}
Next, there is a check to determine the type of error to handle. There are (2) possible sources and the processing paths are different based on the type.
A. HANDLE ERRORS FROM HTTP
B. HANDLE A GENERALIZED ERROR FROM THE APPLICATION/CLIENT;
A. HANDLE ERRORS FROM HTTP
If the source of the error is from an HTTP operation, the error could originate from JavaScript while preparing the request or when handling the response. We can check the error to determine if it is an instanceof ErrorEvent
. This type of error is most-likely unanticipated and should be logged.
if (error.error instanceof ErrorEvent)
The second type of HTTP error is based on the HTTP Status Code of the HTTP response. If the status code is in the 400 or 500 categories, the response is considered to be in an error state. However, since we are most likely working with the application's Web API, we want to defer to the processing logic of our HTTP service (See the Handle HTTP Errors section). There are a few reasons for this.
- the web API could be sending a valid response - it just has a specified status code that indicates an error of some sort
- the web API may include and probably should include a payload that provides information about the specified request and any error messages. See API Response Schema/Model section.
- perhaps sending a list of validation error messages
- or, it may be a failed business rule
- the consumer of the web API did not receive a successful response and needs to provide information to the user about the status of the request.
- the consumer of the web API response is expecting an Observable event to handle and process
- the HTTP service can return the response with the payload that includes the error information as an Observable (application validation or business rule failure)
- if the web API response payload is not known (server error), the HTTP service can wrap a generic error message in a response format that the consumer is expecting and return it as an Observable.
In this scenario, we will do nothing and allow the HTTP service to handle the error.
// A.2: The API returned an unsuccessful response (i.e., 400, 401, 403, etc.).
/**
* The [HttpService] should return a response that is consumable by the caller
* of the API. The response should include relevant information and error messages
* in a format that is known and consumable by the caller of the API.
*/
noop();
B. HANDLE A GENERALIZED ERROR FROM THE APPLICATION/CLIENT
This is truly an unexpected JavaScript error of type Error
. We want the application's custom Error Handler to handle the error by creating a new log event item with a severity status of Error.
const formattedError = `${error.name}; ${error.message}`;
this.loggingService.log(
this.config.applicationName,
Severity.Error,
`${formattedError}`
);
Provide Custom Error Handler
Provide the custom error handler in the applications root module AppModule
, use the providers
configuration and the useClass
property with the type of the new ErrorHandler
. This basically injects our custom class into the application as the new default error handler provider. Providing it at the root level of the application makes the MyErrorHandler
globally available to the entire application.
@NgModule({
providers: [
{
provide: ErrorHandler,
useClass: MyErrorHandler
}
]
})
class AppModule {}
The Angular ErrorHandler
is initialized very early in the application's load life cycle. Therefore, you need to initialize a custom provider early.
Error Handling References
- Error Handling and Angular
- HTTP Status Codes
- JavaScript Error Object
- Exceptional Exception Handling in JavaScript
- Angular ErrorHandler (error_handler.ts)
- Angular HttpClient :: Error Handling
- Angular HttpResponse
- Angular HttpResponseBase
- Chocolate in my Peanut Butter
Configuration
Most Angular applications that require configuration will take advantage of the environment constant where you can set application-wide configuration items for each of the environments. Angular provides a fileReplacement mechanism to assist in this regard. Therefore, we have a convenient mechanism to provide an environment-specific configuration for an application.
We can take advantage of the same pattern and create a configuration for our cross-cutting concern providers (i.e., services) that need an environment and application-specific configuration.
Provide Configuration to Cross-Cutting Concern Libraries
Library projects cannot reference or import anything from an application project. If this was possible it would immediately create a circular dependency for the cross-cutting libraries. Therefore, we need a way for a library to get configuration information during the runtime of an application instance.
Define the Configuration
Create a constant that provides the structure for your configuration. In this example, an interface defines the members of the configuration container. Notice that each of the cross-cutting concerns that need configuration has its own definition (schema/interface) - and it should specific to the environment as well.
- logging
- error handling
- loggly log writer (cloud-based) repository for log items
Each cross-cutting library (module) that needs configuration has a corresponding interface to define the specific configuration members of that item. This separation of concerns keeps things organized and reduces the chance of using the wrong configuration.
import { ILoggingConfig } from './config/i-logging-config';
import { IErrorHandingConfig } from './config/i-error-handling-config';
import { ILogglyConfig } from './config/i-loggly-config';
export interface IConfiguration {
applicationName: string;
loggingConfig: ILoggingConfig;
errorHandlingConfig: IErrorHandingConfig;
logglyConfig: ILogglyConfig;
}
The TypeScript const contains a concrete implementation for the application configuration.
import {
IConfiguration,
} from '@angularlicious/configuration';
import {} from '@angularlicious/error-handling';
export const AppConfig: IConfiguration = {
applicationName: 'Angularlicious.LMS',
loggingConfig: {
applicationName: 'Angularlicious.LMS',
isProduction: false,
},
errorHandlingConfig: {
applicationName: 'Angularlicious.LMS',
includeDefaultErrorHandling: true,
},
logglyConfig: {
applicationName: 'Angularlicious.LMS',
apiKey: '11111111-1111-1111-1111-111111111111',
sendConsoleErrors: true,
};
}
Transform the Configuration (File Replace)
Most of the time, there are subtle differences between development and production environments. Some enterprise applications may have additional environments. We can use the configuration node in the architect|build section (see: angular.json file) to configure any file replacements that the configuration requires. The fileReplacements is an array of {replace, with}
items.
Note: The build configuration for all of the workspace projects is in the angular.json file. Find the specific project and update the configuration|production|fileReplacements section.
"configurations": {
"production": {
"fileReplacements": [
{
"replace": "apps/lms-admin/src/environments/environment.ts",
"with": "apps/lms-admin/src/environments/environment.prod.ts"
},
{
"replace": "apps/lms-admin/src/assets/app-config.ts",
"with": "apps/lms-admin/src/assets/app-config.production.ts"
}
],
}
}
Load the Configuration
Angular applications take advantage of dependency injection to share instances of providers. A provider may be a service, class, or value that is loaded into an injector. We also need to provide the configuration which is currently a TypeScript Object Literal, essentially an object with data. Each Angular application contains a single root injector that is responsible for injecting all things provided at the root-level.
It is recommended that providers are added to the root-level injector by default if at all possible. This is exactly what needs to happen for the application's configuration and cross-cutting concern services (i.e., logging, error handling, etc.). The application only needs a single instance of these providers.
A root-level injector implies that there can be other levels or branches of injectors within an application. For example, when a module is lazy-loaded a new injector is created for that module. This impacts the use of providers from a shared module. It is a common practice to create a shared module to group related things together; as well as provide a set of services. When a lazy-loaded module would like to use the items provided by a shared module it has no way of accessing those items. Why? It is because they are scoped to the shared module at this time. However, if we could just provide those items from a shared module to the root-level injector.
In this example, the shared module to configures and provides all of the cross-cutting providers (i.e., services like configuration, error handling, logging, and log writers). When the lazy-loaded module requires a provider with the same Key as one that is loaded in the root-level injector, you would think by default that the lazy-loaded module would get the provider from the root-level injector. This is not the case! What? The lazy-loaded module has its own injector and will initialize its own set of providers required by the module that have the same key/name. But there is another, way that is.
Note: You may need to create a unique identifier or timestamp for these providers if you want to see that they are actually different instances than the singletons contained in the root-level injector of the application.
In some cases, there may not be any side effects of this behavior. However, if you are expecting all modules lazy-loaded or not to use the same provider (i.e., as a singleton) instance throughout the application we have to do something a little different.
Learn more about Shared Modules and Dependency Injection at Rangle.io.
We will not add items to the providers array in the shared @NgModule
decorator because this will basically provide them using an injector that is specific to the shared module and not the application's root-level injector. Instead, a static forRoot()
is created to allow a consumer of this shared module and its providers to basically push the providers to the root-level injector by calling this method.
To provide items from a shared module to the root-level injector, use the forRoot()
static method when importing the module in the AppModule. Learn more about Sharing the Same Dependency at Rangle.io. Now that the providers (all of our cross-cutting concern services) are contained in the root-level injector of the application, they will also be available to all lazy-loaded modules - magic!
@NgModule({
imports: [
CrossCuttingModule.forRoot(),
SharedModule,
SiteModule,
AppRoutingModule,
BrowserAnimationsModule,
],
declarations: [AppComponent, AdminLayoutComponent],
exports: [],
providers: [],
bootstrap: [AppComponent],
})
export class AppModule {}
The return object of the forRoot() method is an object literal that returns the shared module and all of its providers - including the ones that require some special handling with the APP_INITIALIZER.
/**
* The factory function to initialize the logging service and writer for the
* application.
*
* @param loggingService
* @param consoleWriter
*/
export function initializeLogWriter(consoleWriter: ConsoleWriter) {
console.log(`Initializing [Console Writer] from [AppModule]`);
return () => {
return consoleWriter;
};
}
@NgModule({
declarations: [],
imports: [
CommonModule,
ErrorHandlingModule,
LoggingModule,
ConfigurationModule.forRoot({ config: AppConfig }),
SecurityModule,
],
providers: [
// DO NOT ADD PROVIDERS HERE WHEN USING [SHARED] MODULES; USE forRoot();
],
})
export class CrossCuttingModule {
static forRoot(): ModuleWithProviders {
return {
ngModule: CrossCuttingModule,
providers: [
ConfigurationService,
LoggingService,
ConsoleWriter,
LogglyWriter,
{
provide: APP_INITIALIZER,
useFactory: initializeLogWriter,
deps: [LoggingService, ConsoleWriter, LogglyWriter],
multi: true,
},
{
provide: ErrorHandler,
useClass: ErrorHandlingService,
deps: [ConfigurationService, LoggingService],
},
AuthenticationService,
],
};
}
}
Push the Configuration
There is now a mechanism to define the configuration and to load providers at the root-level injector of the application. There is one last step to this entire process. We still need to get the configuration to each provider that has a configuration concern. Leverage the capabilities of Angular and RxJS to push the configuration when it is available.
In the CrossCuttingModule, the import of the ConfigurationModule calls the forRoot()
method to provide the configuration to the module. What does this mean? It basically does what we were doing for the shared module - it provides the configuration to the root-level dependency injector. Now the configuration is available to the application.
}
@NgModule({
declarations: [],
imports: [
CommonModule,
ErrorHandlingModule,
LoggingModule,
ConfigurationModule.forRoot({ config: AppConfig }),
SecurityModule,
],
providers: [
// DO NOT ADD PROVIDERS HERE WHEN USING [SHARED] MODULES; USE forRoot();
],
})
The ConfigureContext is provided to the application with the AppConfig that contains all of the required configuration information. Now that the configuration is provided and available to access, the ConfigurationService can use the configuration.
import { NgModule, ModuleWithProviders } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ConfigurationContext } from './configuration-context';
@NgModule({
imports: [CommonModule],
})
export class ConfigurationModule {
static forRoot(configContext: ConfigurationContext): ModuleWithProviders {
console.log(`Preparing to handle configuration context.`);
return {
ngModule: ConfigurationModule,
providers: [
{
provide: ConfigurationContext,
useValue: configContext,
},
],
};
}
}
The ConfigurationContext is injected into the constructor of the ConfigurationService. The service will now take advantage of publishing the configuration via a readonly Observable. Notice that the Observable settings$
accessibility is read-only. Consumers of the configuration are not allowed to publish any changes to the configuration. Changes are published once when the configuration is available using the ReplaySubject of 1.
private settings: Subject<IConfiguration> = new ReplaySubject<IConfiguration>(1);
public readonly settings$: Observable<IConfiguration> = this.settings.asObservable();
The ConfigurationService is a humble service but its job is very important. Its job is to act as a mediator between the application and the providers to push the configuration to any providers that subscribe to the settings$ Observable.
Note: the ConfigurationService pushes the configuration to the subscribers. This is the Angular way. There is no need for each provider to retrieve its own configuration - the dependency injection pattern is to provide what is asked for and to not allow consumers to create or provide their own dependencies.
import { Injectable, Optional } from '@angular/core';
import { Subject, ReplaySubject, Observable } from 'rxjs';
import { IConfiguration } from './i-configuration';
import { ConfigurationContext } from './configuration-context';
@Injectable({
providedIn: 'root',
})
export class ConfigurationService {
private settings: Subject<IConfiguration> = new ReplaySubject<IConfiguration>(1);
public readonly settings$: Observable<IConfiguration> = this.settings.asObservable();
constructor(@Optional() context: ConfigurationContext) {
if (context) {
this.settings.next(context.config);
}
}
}
Configuration Summary
The configuration is a very important aspect of an enterprise application. The more that we make use of libraries and cross-cutting concerns - the more important it is to have the capability to provide configuration to different parts of the application. These may also include core domain libraries that contain application-specific business logic for that specified domain. Therefore, create a reliable, consistent, and repeatable way to:
- define configuration schema/interface for a specific concern
- create a concrete configuration based on the interface
- push the configuration to any subscribers
- provide the configuration and cross-cutting concerns to the root-level dependency injector
API Response Schema/Model
Many applications communicate with 3rd-party APIs or the application's own API hosted on a server. Therefore, one main aspect of the application is the ability to make HTTP requests and to properly handle the HTTP response. I think it is important to define a schema for the HTTP response. A schema is a contract between the client and back end Web APIs. This works when you are building the back end Web APIs to support the application and can coordinate what the shape or schema of the response will look like.
This response schema should include an indicator of success. An API scheme should also include a payload property that contains the data returned by the back end server. If the API has any messages that it would like to provide to the user or the consuming application, it should provide these in a message list in the API response schema.
Having a well defined and well-known API schema is very important for the consuming application to handle and process the responses from the back end server. It allows the client application to have a consistent and reliable format and mechanism for processing the response of any API request.
Why Define an API Response
It might seem like an added layer of complexity at the moment. Or, it may seem like it isn't that important. You may be satisfied with making an HTTP/Web API call and dealing with everything in an optimistic mindset as if there will never be any errors in the processing of a request.
The truth is that error will happen. Not all requests are successful and return the payload of data. Additionally, if something goes wrong would it be nice to have the capability to display information to the user to let them know what happened or what they may do to correct the problem.
To put it into perspective, I recently worked on a web form that literally had 6 different formats for various responses (i.e., asynchronous validators and form submission). Displaying any relevant information to the user was an exercise in chaos. The original implementation just displayed the word Error without any other information for the user. Very sad! By the way, this application contains about 150 HTTP/Web API calls and each one of them is a snowflake (i.e., each request has a different implementation on handling the response and any errors). Many of the responses came back as a success, status code 200/OK, but only contained cryptic error messages. This is confusing and unnecessary with little design and planning. Keep it consistent by the plan.
Pro Tip: Define an API Response that is reliable. It will save you hours of frustration during development - and it will allow for a consistent mechanism to communicate information with the users. It supports consistent error handling and processing of HTTP requests.
A Base Response
The ApiResponse<T>
is an abstract class that provides the basic structure for any HTTP response. This is one that I am currently using. However, I recommend that you work with the Web API developers and agree upon a response schema. There are quite a few styles for a response schema. Please do some research and determine what is most appropriate for you application and context.
- is an abstract class to allow for a success and error type response sub-classes
- uses Generics to allow you to indicate the expected type for the model/entity
- IsSuccess to indicate if the response is successful or not; seems arbitrary at the moment, but is very useful in determining the processing path of the response if it contains any error messages
- Timestamp: use to indicate the date and time the response was issued by the server
export abstract class ApiResponse<T> {
IsSuccess: boolean;
Message: string;
StatusCode: number;
Timestamp: Date;
}
Success API Response
It is likely that most of the responses you get from your application's Web API are successful and return with a payload of data. The SuccessApiResponse<T>
class just extends the base class so that it also has common information - no matter if it is successful or not.
- contains a
Data
property of typeT
to allow the client to indicate what the data payload type should be. - contains a list of ApiMessage items to allow the Web API to return any information useful to the application or to display to the user.
import { ApiResponse } from './api-response';
import { ApiMessage } from './api-message';
/**
* Use to define a successful API response. A successful response will
* most likely include a payload of data (i.e., use the Data property).
*/
export class SuccessApiResponse<T> extends ApiResponse<T> {
Data: T;
Messages: ApiMessage[];
}
Error API Response
In the unlikely event, the application's Web API returns an error or failure response it should be in a well-defined format to allow the application to process it and retrieve any messages from the response.
Note that this response does not contain a Data property. The expected payload is not available or provided by the API when the operation is in an error state.
You get a list of messages that provide valuable information to the application and also to the user. If you need a consistent mechanism to provide information to the user from the back end of your application this is the way to do it.
import { ApiResponse } from './api-response';
import { ApiMessage } from './api-message';
/**
* Use to provide error information from an API. You can also
* use this class to create a response with errors while doing
* error handling.
*
* Errors: is a list om [ApiErrorMessage] items that contain specific
* errors for the specified request.
*/
export class ErrorApiResponse<T> extends ApiResponse<T> {
Errors: ApiMessage[] = [];
}
API Messages
This is the equivalent of two tin cups and a string that attaches them together - it is a way to communicate a message from one end of the application to the other end (client). If the application's Web API needs to communicate any information to the application and/or the user of the application it needs a message.
This is a message format that is generic enough to provide messages that are informational, warning, or indicate an error of some sort.
- contains an ApiMessageSeverity enum property to indicate the level of severity
- use the isDisplayable boolean indicator to determine whether the message is intended for the user or not
import { ApiMessageSeverity } from './api-message-severity.enum';
export class ApiMessage {
id?: string;
message: string;
severity: ApiMessageSeverity;
isDisplayable: boolean;
/**
* Use to create a new [ApiErrorMessage]
* @param message The error from the API.
* @param displayable Use to indicate if the error should be displayed to the user.
* @param id An optional identifier for the error.
*/
constructor(message: string, displayable: boolean, id: string | null) {
this.message = message;
this.isDisplayable = displayable;
if (id) {
this.id = id;
}
}
}
The ApiMessageSeverity is useful if you need to style or format the display of the information based on the severity level. It is nice to be able to provide error messages, but it also a nice feature to provide messages that informative to the user.
export enum ApiMessageSeverity {
Information = 'Information',
Warning = 'Warning',
Error = 'Error',
}
HTTP Service
Most applications require the use of Web APIs or a dedicated back end to retrieve and persist information. If the application makes HTTP requests and processes HTTP responses to provide useful data or information, it will need to use the Angular HttpClient. In my experience, I have seen many different (i.e., how) implementations of the HttpClient to perform HTTP related concerns. Also, where these operations are implemented is interesting - never in the same place.
Pro Tip: When using a layered architecture determine where and how to implement HTTP requests. It should be consistent throughout all of the domain verticals and/or specified layer of the domain item. This is definitely one item where variance, deviation from acceptable patterns/recipes, and chaos could make an application virtually unmaintainable. The long-term effects of snowflake implementations is a pile of technical debt that is not easy to overcome. The best thing to do is to eliminate technical debt with proper planning and implementation.
Each domain section will have its own HTTP service that is specific to the API operations that is requires. However, the way or mechanism to construct, execute and handle an HTTP request should be consistent and maintainable. Therefore, make use of a library project to create a new cross-cutting concern for HTTP-related things.
Goal: To enable HTTP/Web API calls to be constructed, managed, and executed using a repeatable and reliable mechanism. Most if not all HTTP requests should be constructed using the same pattern/recipe. There should be little or no reason to not use such a mechanism. When you want to extend or add new features - there will be a single location to make such changes. The current application I'm working on has over 150 variations of processing HTTP requests.
HTTP Service Responsibilities
The HTTP Service has some basic responsibilities and concerns.
- create the HTTP request options
- set the request method (i.e., post, get, put, etc.)
- add header information to the request if required
- set the target URL for the request
- include an optional body payload
- execute the request
- handle any errors
The following HttpService class is basically using and wrapping some concerns using the HttpClient from @angular/common/http
. It may seem trivial at first to create such a class. Consider, where HTTP calls should be made within your application - location matters. Consider how they are implemented and really who or what should have the responsbility or concern. When you think about it, an HTTP call is one of the last tasks you perform within an application for a specific operation. The request is executed and the application has to wait for some kind of response.
Create a new HttpService library project and service with the Angular CLI:
ng g library httpService
ng g service httpService --project=http-service
The sample class below is only doing a few things. Mostly, creating the options for an HTTP request. The options is a container for the information required to execute an HTTP request. The execute<T>()
makes use of the Angular HttpClient. Unless you practice the mystical art of optimistic programming, there will be errors and exceptions during HTTP operations - please do not ignore these, but handle them.
-
createOptions()
: a primary method to construct HTTP options using a specific recipe -
createHeader
: a helper method to add header information to the HTTP options. Modify to suite your needs -
execute<T>()
: a primary method of the service to wrap the HttpClient request -
handleError()
: use to handle errors when they happen
import { Injectable } from '@angular/core';
import { HttpRequestMethod } from './http-request-methods.enum';
import {
HttpHeaders,
HttpClient,
HttpResponse,
HttpErrorResponse,
} from '@angular/common/http';
import { HttpRequestOptions } from './http-request-options';
import { Observable, throwError } from 'rxjs';
import { catchError, retry } from 'rxjs/operators';
import { ApiResponse } from '@angularlicious/foundation';
import { ErrorApiResponse } from '@angularlicious/foundation';
import { ApiErrorMessage } from '@angularlicious/foundation';
@Injectable()
// { providedIn: 'root', }
export class HttpService {
constructor(private httpClient: HttpClient) {}
/**
* Use to create [options] for the API request.
* @param method Use to indicate the HttpRequest verb to target.
* @param headers Use to provide any [HttpHeaders] with the request.
* @param url Use to indicate the target URL for the API request.
* @param body Use to provide a JSON object with the payload for the request.
* @param withCredentials Use to indicate if request will include credentials (cookies), default value is [true].
*/
createOptions(
method: HttpRequestMethod,
headers: HttpHeaders,
url: string,
body: any,
withCredentials: boolean = true
): HttpRequestOptions {
let options: HttpRequestOptions;
options = new HttpRequestOptions();
options.requestMethod = method;
options.headers = headers;
options.requestUrl = url;
options.body = body;
options.withCredentials = withCredentials;
return options;
}
/**
* Use to create a new [HttpHeaders] object for the HTTP/API request.
*/
createHeader(): HttpHeaders {
let headers = new HttpHeaders();
headers = headers.set('Content-Type', 'application/json');
return headers;
}
/**
* Use to execute an HTTP request using the specified options in the [HttpRequestOptions].
* @param requestOptions
*/
execute<T>(requestOptions: HttpRequestOptions): Observable<ApiResponse<T>> {
console.log(
`Preparing to perform request to: ${requestOptions.requestUrl}`
);
return this.httpClient
.request<T>(
requestOptions.requestMethod.toString(),
requestOptions.requestUrl,
{
body: requestOptions.body,
headers: requestOptions.headers,
reportProgress: requestOptions.reportProgress,
observe: requestOptions.observe,
params: requestOptions.params,
responseType: requestOptions.responseType,
withCredentials: requestOptions.withCredentials,
}
)
.pipe(
retry(1),
catchError((errorResponse: any) => {
return this.handleError(errorResponse);
})
);
}
/**
* Use to handle errors during HTTP/Web API operations. The caller expects
* an Observable response - this method will either return the response from
* the server or a new [ErrorApiResponse] as an Observable for the client to
* handle.
*
* @param error The error from the HTTP response.
*/
protected handleError(error: HttpErrorResponse): Observable<any> {
const apiErrorResponse = new ErrorApiResponse();
apiErrorResponse.IsSuccess = false;
apiErrorResponse.Timestamp = new Date(Date.now());
apiErrorResponse.Message = 'Unexpected HTTP error.';
if (error.error instanceof ErrorEvent) {
// A client-side or network error occurred. Handle it accordingly.
apiErrorResponse.Errors.push(
new ApiErrorMessage(
`A client-side or network error occurred. Handle it accordingly.`,
true,
null
)
);
return throwError(apiErrorResponse);
} else {
// The API returned an unsuccessful response.
if (error instanceof ErrorApiResponse) {
// A known error response format from the API/Server; rethrow this response.
return throwError(error);
} else {
// An unhandled error/exception - may not want to lead/display this information to an end-user.
// TODO: MIGHT WANT TO LOG THE INFORMATION FROM error.error;
apiErrorResponse.Errors.push(
new ApiErrorMessage(
`The API returned an unsuccessful response. ${error.status}: ${error.statusText}. ${error.message}`,
false,
null
)
);
return throwError(apiErrorResponse);
}
}
}
}
Using the HTTP Service
If the processing of business rules and/or data validation goes well in the business layer of the application the next task to follow is typically an HTTP request to either retrieve, save, or update some information. Each domain vertical probably has a distinct set of Web API calls. Create a new service to handle the specific HTTP requests. An @Injectable service for HTTP calls is injected into a business provider within the specific domain service. A service of this type could also be the recipient of configuration information that includes base URL or other information required to execute the request.
The sample domain-specific HTTP service below contains an injected HttpService. The LoggingService cross-cutting concern is also available for this service. Reusing code creates such a good feeling, right? There are only a few things to do to perform an HTTP operation:
- configure the URL endpoint for the request
- setup the options for the request (uses the URL)
- execute the request using the new HttpService.
Note the simplicity in the following example of an application's HTTP service for the ThingsToDo domain feature. Consistent code is maintainable code - it is also easier to identify any deviations early.
import { Injectable, Inject } from '@angular/core';
import { Observable } from 'rxjs';
import { HttpClient } from '@angular/common/http';
import { ApiResponse, ServiceBase } from '@tc/foundation';
import { HttpService, HttpRequestMethod } from '@tc/http-service';
import { LoggingService } from '@tc/logging';
import { IThingsToDoHttpService } from './i-things-to-do-http.service';
@Injectable({
providedIn: 'root',
})
export class ThingsToDoHttpService extends ServiceBase
implements IThingsToDoHttpService {
baseUrl = 'http://mybackend.com/api/';
noCredentials = false;
credentialsRequired = true;
constructor(
@Inject(HttpService) public httpService: HttpService,
public loggingService: LoggingService
) {
super(loggingService, 'ThingsToDoHttpService');
}
RetrieveThingsToDo<T>(): Observable<ApiResponse<T>> {
const requestUrl = this.baseUrl.concat('things');
const options = this.httpService.createOptions(
HttpRequestMethod.get,
this.httpService.createHeader(),
requestUrl,
null,
this.noCredentials
);
return this.httpService.execute<T>(options);
}
}
The use and implementation of an interface IThingsToDoHttpService are totally optional. There may be a case to use such a feature during initial development when the real API is not available or online.
import { Observable } from 'rxjs';
import { ApiResponse } from '@tc/foundation';
export interface IThingsToDoHttpService {
/**
* Use to retrieve things to do.
*/
RetrieveThingsToDo<T>(): Observable<ApiResponse<T>>;
}
An interface-based approach allows you to create a fake HTTP service to provide an implementation that returns fake data. There are many different approaches to using and creating fake data from an API. Interfaces may make the implementation a little easier.
import { Injectable } from '@angular/core';
import { Observable, of, BehaviorSubject, throwError } from 'rxjs';
import { ThingToDo } from '../models/thingToDo.model';
import { IThingsToDoHttpService } from './i-things-to-do-http.service';
import {
ApiResponse,
ServiceBase,
SuccessApiResponse,
ErrorApiResponse,
ApiErrorMessage,
} from '@angularlicious/foundation';
export class ThingsToDoFakeHttpService implements IThingsToDoHttpService {
things: ThingToDo[] = [];
RetrieveThingsToDo<T>(): Observable<ApiResponse<T>> {
const response = new SuccessApiResponse();
response.IsSuccess = true;
response.Message = 'Successfully processed request for things.';
response.Timestamp = new Date(Date.now());
response.Data = this.loadThings();
const subject: BehaviorSubject<any> = new BehaviorSubject(response);
return subject.asObservable();
}
loadThings() {
this.things = [];
this.things.push(
new ThingToDo(
1,
'Denver Taco Festival',
'RiNo',
'Eat tacos with your friends and some many Chihuahuas.'
)
);
this.things.push(
new ThingToDo(
2,
'Smooth Jazz Concert',
'Arvada, CO',
'Listen to Kenny G play his favorites.'
)
);
return this.things;
}
}
The feature module can provide the correct HTTP service based on the environment of the application runtime. The sample below demonstrates providing a service with the useClass and a ternary operator to determine which service to load: fake or real?
@NgModule({
declarations: [VisitorIndexComponent, ThingsToDoComponent],
imports: [
CommonModule,
SharedModule,
SiteComponentsModule,
VisitorRoutingModule,
],
providers: [
// ThingsToDoService, // NOT PROVIDED HERE, EACH COMPONENT REQUIRES A DISTINCT INSTANCE
ThingsToDoBusiness,
{
provide: 'IThingsToDoHttpService',
useClass: environment.production
? ThingsToDoHttpService
: ThingsToDoFakeHttpService,
},
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class VisitorModule {}
Handle HTTP Errors
Errors fall into the category of when and not if. It is only a matter of time and circumstance that there will be an error during the operation of an HTTP request. Handling errors is a common practice and discipline in programming. We can hope that errors never occur- however, hope is never a good strategy for most things in life. A little preparation and forward-thinking will make the application safer.
Many times, the error is from the back end of an application - the Web API. If an error occurs on the back end, you typically get a 400 or 500 status code from the server. However, during the processing of an HTTP request, it is possible to get an error related to the processing of the HTTP request or the response. Basically, there is a lot of opportunity for things to go wrong.
Note: This is a specialized handling of errors to use the Observable pattern while performing HTTP operations.
Do not be tempted to use HttpClient
calls directly in your application components. We need a more reliable, consistent, and maintainable mechanism to handle errors while performing actions that use HTTP
and Observables. It is also not an ideal situation to display raw
error information to users.
For example, when using HttpClient
you can call the request()
method. Using the RxJs pipe()
you can also use the catchError()
which returns an HttpErrorResponse
to be handled. Handling an error in a single location (not in the component Observable response) allows for greater control - it is also contained in a single location. I know this from experience, A current project that I joined after almost 3 years of development has approximately 200 variations of making HTTP calls and handling responses. The number of variations of error handling in this code is mind-boggling considering that only 2 developers implemented the API calls. Therefore, please consider the following principles in regard to error handling.
- single-responsibility
- DRY: don't repeat yourself
- separation of concerns
Pro Tip: There are many ways to handle errors. Some of the implementation details may be specific
to the technology. Become familiar with error and exception handling practices.Here are the goals for handling the error:
- inspection: proactively expect exceptions and provide a mechanism to catch them
- interpretation: provide some information local to the error that gives meaning and context to the error
- resolution: catch and handle unexpected errors or exceptions; provide information to user or application that > helps mitigate or resolve an issue
The sample code below expects that there may be an error while processing the request. If an HTTP error happens, it will attempt to retry the operation at least once with retry(1)
in the Observable pipeline. If there continues, it will be caught with the catchError()
RxJS pipeline operation and handled. Handling HTTP errors is a specialized process because the process needs to determine the source of the error to engage the proper handling path. More information about Handling HTTP Errors.
execute<T>(requestOptions: HttpRequestOptions): Observable<HttpResponse<ApiResponse<T>>> {
try {
return this.httpClient.request<T>(
requestOptions.requestMethod.toString(),
requestOptions.requestUrl,
{
headers: requestOptions.headers,
observe: requestOptions.observe,
params: requestOptions.params,
reportProgress: requestOptions.reportProgress,
withCredentials: requestOptions.withCredentials
}
).pipe(
retry(1)
catchError((errorResponse: any) => {
return this.handleError(errorResponse);
})
);
} catch (error) {
this.handleError(error);
}
}
The HttpErrorResponse
contains details to determine the source of the error. Was it from the server/http or from within the application. This helps us to determine what type of information to provide the user if any. At a minimum, you could log this information to help monitor the health of the application and determine if any improvements should be made.
HttpErrorResponse: A response that represents an error or failure, either from a non-successful HTTP status - an error while executing the request, or some other failure which occurred during the
parsing of the response.
I updated the signature of the handleError()
method to include either type of Error
or type of HttpErrorResponse
- this allows for specialized handling based on the type of error.
protected handleError(error: Error | HttpErrorResponse): Observable<any> {
if(error.error instanceof ErrorEvent) {
// A client-side or network error occurred. Handle it accordingly.
} else {
// The API returned an unsuccessful response.
}
// handler returns an RxJS ErrorObservable with a user-friendly error message.
// Consumers of the service expect service methods to return an Observable of
// some kind, even a "bad" one.
//
// return throwError(error);
return throwError(`Hey, you got my chocolate in your peanut butter.`);
}
Notice that the HttpErrorResponse
type implements Error
. Therefore, it contains information about the HTTP Request and also error information. These classes are already part of the Angular error handling eco-system. We can leverage the use of these types to create a more robust error handling flow for our applications.
class HttpErrorResponse extends HttpResponseBase implements Error {
constructor(init: {...})
get name: 'HttpErrorResponse'
get message: string
get error: any | null
get ok: false
// inherited from common/http/HttpResponseBase
constructor(init: {...}, defaultStatus: number = 200, defaultStatusText: string = 'OK')
get headers: HttpHeaders
get status: number
get statusText: string
get url: string | null
get ok: boolean
get type: HttpEventType.Response | HttpEventType.ResponseHeader
}
The abstract base class for the HttpResponse
provides the structure for other HTTP Response classes:
- HttpErrorResponse
- HttpHeaderResponse
- HttpResponse
abstract class HttpResponseBase {
constructor(init: {...}, defaultStatus: number = 200, defaultStatusText: string = 'OK')
get headers: HttpHeaders
get status: number
get statusText: string
get url: string | null
get ok: boolean
get type: HttpEventType.Response | HttpEventType.ResponseHeader
}
HTTP Error Processing
As mentioned previously, processing HTTP errors during Web API operations involve Observables. We will use the RxJS catchError()
and then handle the error. The source of the error may be from the status code of the HTTP response or it may be a more generalized JavaScript error while attempting to send the request or handle the response. Therefore, we will need to determine the source of the error.
The handleError()
implementation will determine what to do with the response. If the HTTP response status code is in an error state and the body of the response is in the expected ErrorApiResponse format, we can simply use the RxJS throwError()
and send it on its way for handling by the consumer of the API.
If the error doesn't contain a body in our expected format, we can wrap the error information into the expected format (generic message) and send it forward.
import { Injectable } from "@angular/core";
import { HttpRequestMethod } from "./http-request-methods.enum";
import {
HttpHeaders,
HttpClient,
HttpResponse,
HttpErrorResponse
} from "@angular/common/http";
import { HttpRequestOptions } from "./http-request-options";
import { Observable, throwError } from "rxjs";
import { catchError, retry } from "rxjs/operators";
import { ApiResponse } from "@angularlicious/foundation";
import { ErrorApiResponse } from "@angularlicious/foundation";
import { ApiErrorMessage } from "@angularlicious/foundation";
@Injectable()
// { providedIn: 'root', }
export class HttpService {
constructor(private httpClient: HttpClient) {}
/**
* Use to create [options] for the API request.
* @param method Use to indicate the HttpRequest verb to target.
* @param headers Use to provide any [HttpHeaders] with the request.
* @param url Use to indicate the target URL for the API request.
* @param body Use to provide a JSON object with the payload for the request.
* @param withCredentials Use to indicate if request will include credentials (cookies), default value is [true].
*/
createOptions(
method: HttpRequestMethod,
headers: HttpHeaders,
url: string,
body: any,
withCredentials: boolean = true
): HttpRequestOptions {
let options: HttpRequestOptions;
options = new HttpRequestOptions();
options.requestMethod = method;
options.headers = headers;
options.requestUrl = url;
options.body = body;
options.withCredentials = withCredentials;
return options;
}
/**
* Use to create a new [HttpHeaders] object for the HTTP/API request.
*/
createHeader(): HttpHeaders {
let headers = new HttpHeaders();
headers = headers.set("Content-Type", "application/json");
return headers;
}
/**
* Use to execute an HTTP request using the specified options in the [HttpRequestOptions].
* @param requestOptions
*/
execute<T>(requestOptions: HttpRequestOptions): Observable<ApiResponse<T>> {
console.log(
`Preparing to perform request to: ${requestOptions.requestUrl}`
);
return this.httpClient
.request<T>(
requestOptions.requestMethod.toString(),
requestOptions.requestUrl,
{
body: requestOptions.body,
headers: requestOptions.headers,
reportProgress: requestOptions.reportProgress,
observe: requestOptions.observe,
params: requestOptions.params,
responseType: requestOptions.responseType,
withCredentials: requestOptions.withCredentials
}
)
.pipe(
retry(1),
catchError((errorResponse: any) => {
return this.handleError(errorResponse);
})
);
}
/**
* Use to handle errors during HTTP/Web API operations. The caller expects
* an Observable response - this method will either return the response from
* the server or a new [ErrorApiResponse] as an Observable for the client to
* handle.
*
* @param error The error from the HTTP response.
*/
protected handleError(error: HttpErrorResponse): Observable<any> {
const apiErrorResponse = new ErrorApiResponse();
apiErrorResponse.IsSuccess = false;
apiErrorResponse.Timestamp = new Date(Date.now());
apiErrorResponse.Message = "Unexpected HTTP error.";
if (error.error instanceof ErrorEvent) {
// A client-side or network error occurred. Handle it accordingly.
apiErrorResponse.Errors.push(
new ApiErrorMessage(
`A client-side or network error occurred. Handle it accordingly.`,
true,
null,
null
)
);
return throwError(apiErrorResponse);
} else {
// The API returned an unsuccessful response.
if (error instanceof ErrorApiResponse) {
// A known error response format from the API/Server; rethrow this response.
return throwError(error);
} else {
// An unhandled error/exception - may not want to lead/display this information to an end-user.
// TODO: MIGHT WANT TO LOG THE INFORMATION FROM error.error;
apiErrorResponse.Errors.push(
new ApiErrorMessage(
`The API returned an unsuccessful response. ${error.status}: ${error.statusText}. ${error.message}`,
false,
null,
error.status.toString()
)
);
return throwError(apiErrorResponse);
}
}
}
}
Top comments (1)
Hi Matt, another thorough article, thx.
But what is "ServiceBase"?
And how's the book coming (last updt was Sept?)? I would like to study it as soon as possible.
Regards, Rick.