This is my second article about the way I would design LinkedIn’s web app architecture using Angular. My first article focused on decomposition and routing, whereas this article focuses on how the components interact and retrieve their data: it’s about services and modules.
In my previous article (‘Angular App Architecture’), I described the components of the LinkedIn app as I would design it. One of these components was the navigation bar that enables the user to select a specific route, resulting in the component being rendered for that route.
Another one is the component for showing the profile summary, but that component lacked data. Now it’s time to determine how our components interact, how data is retrieved and how it is provided to all components that render the data.
A short reminder of what I mentioned in my previous article (‘Angular App Architecture’): my colleague Martijn Kloosterman wrote “How to talk to your children and listen to what they have to say” part 1 and part 2 where he explains parent-child communication and peer-to-peer communication between components. In this article, I assume knowledge of the techniques he described. If you are not sure, read Martijn’s blog posts first.
Providing data to our app
When designing the LinkedIn app, I ended with some components like the NavBarComponent, the HomePageComponent and the ProfileSummaryComponent. (This is not the full set of components required for the complete LinkedIn app, but enough to describe the design process.) The structure of our app is shown below.
The most obvious dependency of the NavBarComponent is the routing table, which we created in the app-routing.module.ts. The ProfileSummaryComponent needs to load the user’s name, avatar image, employer, job title and an indication of whether the user is logged in or not. This information is not hard-coded in our app; it is stored somewhere in a database.
In order to provide our app with this data, we need some kind of API service that reads from the database and will return the data when requested by a http(s) call. As an example, I used an out-of-the-box ASP.Net Core WebAPI service (see my GIT repo at the end of this article).
Services
Let’s take a closer look at the ProfileSummaryComponent. The component best suited for performing the request and handling its response is – of course – an Angular service.
A service is a component that is decorated as an Injectable. This means that Angular’s DI container allows it to be registered and injected into components, directives or other services. Within the scope of the DI container, a service is a singleton.
Angular provides its own services as well, e.g. the HttpService provides functions for performing all kinds of http(s) requests.
For providing data to the ProfileSummaryComponent, we define a HomeDataService:
ng g s home-data
(remember the naming convention: home-data results in the HomeDataService)
The Angular CLI doesn’t know which injector it should register the service with, so we need to add a registration for the HomeDataService simply by adding it to the providers array in our AppModule:
providers: [ HomeDataService ]
Now we can inject it into some component by adding it as an argument of the component’s constructor:
constructor (homeDataService: HomeDataService){}
You may think let’s inject this HttpService into the ProfileSummaryComponent and handle the request and its response inside this component. And honestly, you are right. You could do that.
However, the single purpose of the ProfileSummaryComponent is rendering profile data. If you add data provisioning to this purpose, it will make testing much more difficult! It requires adding mocks for the HttpService and test data to be used for responses, as well as lots of code for the current tests. Please stick to the single responsibility principle and create a separate object for data provisioning.
If we look at the app decomposition shown above, my component of choice would be the HomeComponent. Up until now, it only had the single responsibility of rendering the HomePageComponent, which in turn renders the ProfileSummaryComponent.
The HomeComponent itself is rendered by activating the ‘home’ route and is a great candidate for providing data to its child components like the HomePageComponent and the ProfileSummaryComponent.
Wiring the components to the data flow
The HomeDataService retrieves data by sending out a request to our WebAPI service and returns the received data. This is where RxJS (see also ref 2) comes into action: an http request is performed by a (self-closing) Observable stream. This is what the HomeDataService’s getData() function returns:
public getData(): Observable { // some code return this.httpService.get(this.apiSvcUrl, options).pipe((data: Observable) => this.parseProfileSummaryData(data)); }
The pipe function assigns a handler (parseProfileSummaryData) to the event so that a message is returned on the Observable stream.
In order to process this data, the HomeComponent needs to subscribe to the Observable stream:
this.homeDataService.getData().subscribe(resp => this.handleResponse(resp));
And in the handleResponse function, we assign data so it can be passed on to the HomePageComponent, which in turn passes it on to the ProfileSummaryComponent for rendering. This parent-child communication (HomeComponent -> HomePageComponent and HomePageComponent -> ProfileSummaryComponent) is executed by ‘@Input’-variables:
HomeComponent: <app-home-page [data]=”profiledata” [loading]=”loading”></app-home-page> HomePageComponent: <app-profile-summary *ngIf=”!loading” [data]=”data”></app-profile-summary>
In addition to this, I want to add a loading indicator. You may have noticed the ngIf directive in the HomePageComponent’s view and the input parameter binding of the loading parameter. The HomePageComponent presents the following loading indicator (I used a straightforward line of text for presentation) as long as the data is not available:
<h3 *ngIf=”loading”>loading...</h3>
As soon as the data becomes available, it renders the ProfileSummaryComponent.
The HomeComponent is notified by an Observable (based on a BehaviorSubject) that I added to the HomeDataService. Every time the BehaviorSubject sends a value down the stream, the HomeComponent tells its child HomePageComponent (through an Input parameter) that the loading indicator has a new value. The observable stream derived from the BehaviorSubject will not close until the app is closed altogether, so any components subscribed to this stream need to unsubscribe during the Destroy-event.
All this will become clearer when you take a look at my code sample. (See my GIT repo at the end of this article.)
The data provisioning is now functioning as shown in the following chart:
The app is growing; we need more modules!
As the app grows, the arrays of declarations (of components) and providers (services) in our app module get very lengthy. In order to keep the code easy to read, we can split up our app into multiple modules (feature modules). An obvious way to group related components and objects is to look at the routes in our main module: we have a route to ‘home’, a route to ‘network’ and a route to ‘jobs’. Each route can be changed to (lazy) load a module, so our routing table looks like this:
const routes: Routes = [ { path: “, pathMatch: ‘full’, redirectTo: ‘/network’ }, { path: ‘home’, loadChildren: () => HomeModule }, { path: ‘network’, loadChildren: () => NetworkModule }, { path: ‘jobs’, loadChildren: () => JobsModule }, { path: ‘**’, redirectTo: ‘/network’ } ];
And the declarations of the HomeComponent, HomePageComponent, ProfileSummaryComponent and HomeDataService move to the HomeModule:
@NgModule({ declarations: [ HomeComponent, HomePageComponent, ProfileSummaryComponent ], imports: [ CommonModule, HttpModule, HomeRoutingModule ], providers: [ HomeDataService ] }) export class HomeModule { }
Note the HomeRoutingModule that only defines a single route (the default route to instantiate the HomeComponent) and the HttpModule that is required for providing the HttpService.
Another advantage of this approach is that the HomeDataService is not instantiated until the route to ‘home’ is activated for the first time. The default route is to ‘network’ (as we defined in our routing table), so this lazy loading will speed up the application’s initial load.
The decomposition of our app now looks like this.
General-purpose functions
In addition to data provisioning, services are well suited for storing any kind of functions. As a component is not a singleton (by definition), we need to keep it as small and lightweight as possible.
Putting a function into a service enables reuse as well, so we satisfy the DRY principle too!
On top of all this, tests for a service are much easier to write and faster to execute than tests for a component.
If you reuse functions outside of the scope of the module, you should probably move the service containing these functions into its own feature module. Importing this module should be done at the highest point of the module hierarchy to ensure availability throughout your app.
Injecting services into services
If you need a general-purpose function in your data provisioning service, you could inject the service containing this function into the data provisioning service. Be aware of the dependency you create, as these relations causes both services to be instantiated together!
State and services
When it comes to state, I would like to add a short recommendation.
As services are singletons (by design), it’s tempting to use them for storing data that needs to be widely available in your app, but beware not to store a state that belongs to a component!
Take, for example, a component that is used to select a global setting for your app. The selected value is data that is part of the state of the component. If the selected value needs to persist when the component is destroyed, that is a good reason for storing this value in a service.
Another example is the user information of the user that is currently logged in. The component responsible for handling the login request is usually only visible and existent during the login process. When the user has finished logging in, the component should store the data in a service (as a kind of cache) for any retrieval required. There are, of course, other options like the use of Redux for state management.
Finally…
There is much more to discuss about writing Angular apps in a structured way (and I could go on pretty much forever….), but I hope you like this article.
Enjoy the tips and use them if you like. Feel free to respond to my suggestions, opinions and code samples!
References
- Martijn Kloosterman: “How to talk to your children and listen to what they have to say part 1” about parent – child communication by Input parameters and EventEmitters
- Martijn Kloosterman: “How to talk to your children and listen to what they have to say part 2” about RxJS, Subjects, Observables and Subscriptions
- My previous blog about Angular App Architecture
- The Git repo with the example app: https://github.com/RenzoVeldkamp/AngularAppArchitecture.git