The services layer is more or less what we call 'business logic layer' on the server side. It is the layer where the business logic is placed. The main challenges are:
-
Define application state and an API for the components layer to use it
-
Handle application state transitions
-
Perform backend interaction (XHR, WebSocket, etc.)
-
Handle business logic in a maintainable way
-
Configuration management
All parts of the services layer are described in this chapter. An example which puts the concepts together can be found at the end Interaction of Smart Components through the services layer.
There are two APIs for the components layer to interact with the services layer:
-
A store can be subscribed to for receiving state updates over time
-
A use case service can be called to trigger an action
To illustrate the fact the follwing figure shows an abstract overview.
A store is a class which defines and handles application state with its transitions over time.
Interaction with a store is always synchronous.
A basic implementation using rxjs
can look like this.
💡
|
A more profound implementation taken from a real-life project can be found here (Abstract Class Store). |
@Injectable()
export class ProductSearchStore {
private stateSource = new BehaviorSubject<ProductSearchState>(defaultProductSearchState);
state$ = this.stateSource.asObservable();
setLoading(isLoading: boolean) {
const currentState = this.stateSource.getValue();
this.stateSource.next({
isLoading: isLoading,
products: currentState.products,
searchCriteria: currentState.searchCriteria
});
}
}
In the example ProductSearchStore
handles state of type ProductSearchState
.
The public API is the property state$
which is an observable of type ProductSearchState
.
The state can be changed with method calls.
So every desired change to the state needs to be modeled with an method.
In reactive terminology this would be an Action.
The store does not use any services.
Subscribing to the state$
observable leads to the subscribers receiving every new state.
This is basically the Observer Pattern:
The store consumer registeres itself to the observable via state$.subscribe()
method call.
The first parameter of subscribe()
is a callback function to be called when the subject changes.
This way the consumer - the observer - is registered.
When next() is called with a new state inside the store, all callback functions are called with the new value.
So every observer is notified of the state change.
This equals the Observer Pattern push type.
A store is the API for Smart Components to receive state from the service layer.
State transitions are handled automatically with Smart Components registering to the state$
observable.
A use case service is a service which has methods to perform asynchronous state transitions.
In reactive terminology this would be an Action of Actions - a thunk (redux
) or an effect (@ngrx
).
A use case services method - an action - interacts with adapters, business services and stores. So use case services orchestrate whole use cases. For an example see use case service example.
An adapter is used to communicate with the backend. This could be a simple XHR request, a WebSocket connection, etc. An adapter is simple in the way that it does not add anything other than the pure network call. So there is no caching or logging performed here. The following listing shows an example.
For further information on backend interaction see Consuming REST Services
@Injectable()
export class ProducsAdapter {
private baseUrl = environment.baseUrl;
constructor(private http: HttpClient) { }
getAll(): Observable<Product[]> {
return this.http.get<Product[]>(this.baseUrl + '/products');
}
}
The interaction of smart components is a classic problem which has to be solved in every UI technology. It is basically how one dialog tells the other something has changed.
An example is adding an item to the shopping basket. With this action there need to be multiple state updates.
-
The small logo showing how many items are currently inside the basket needs to be updated from 0 to 1
-
The price needs to be recalculated
-
Shipping costs need to be checked
-
Discounts need to be updated
-
Ads need to be updated with related products
-
etc.
To handle this interaction in a scalable way we apply the following pattern.
The state of interest is encapsualted inside a store. All Smart Components interested in the state have to subscibe to the store’s API served by the public observable. Thus, with every update to the store the subscribed components receive the new value. The components basically react to state changes. Altering a store can be done directly if the desired change is synchronous. Most actions are of asynchronous nature so the UseCaseService
comes into play. Its actions are void
methods, which implement a use case, i.e., adding a new item to the basket. It calls asynchronous actions and can perform multiple store updates over time.
To put this pattern into perspective the UseCaseService
is a programmatic alternative to redux-thunk
or @ngrx/effects
. The main motivation here is to use the full power of TypeScript’s --strictNullChecks
and to let the learning curve not to become as steep as it would be when learning a new state management framework. This way actions are just void
method calls.
The example shows two Smart Components sharing the FlightSearchState
by using the FlightSearchStore
.
The use case shown is started by an event in the Smart Component FlightSearchComponent
. The action loadFlight()
is called. This could be submitting a search form.
The UseCaseService is FlightSearchService
, which handles the use case Load Flights.
export class FlightSearchService {
constructor(
private flightSearchAdapter: FlightSearchAdapter,
private store: FlightSearchStore
) { }
loadFlights(criteria: FlightSearchCriteria): void {
this.store.setLoadingFlights(true);
this.store.clearFlights();
this.flightSearchAdapter.getFlights(criteria.departureDate,
{
from: criteria.departureAirport,
to: criteria.destinationAirport
})
.finally(() => this.store.setLoadingFlights(false))
.subscribe((result: FlightTo[]) => this.store.setFlights(result, criteria));
}
}
First the loading flag is set to true
and the current flights are cleared. This leads the Smart Component showing a spinner indicating the loading action. Then the asynchronous XHR is triggert by calling the adapter. After completion the loading flag is set to false
causing the loading indication no longer to be shown. If the XHR was successful, the data would be put into the store. If the XHR was not successful, this would be the place to handle a custom error. All general network issues should be handled in a dedicated class, i.e., an interceptor. So for example the basic handling of 404 errors is not done here.