Separate Business Logic from UI Presenational Logic Using NX
Description
In this article, we will be discussing the practice of separating an application's business logic from the UI presentational logic. We achieve this by using NX, utilizing the design of creating an app, which is where the presentational components will be, and libs, which is where the business logic will be.
Why though?
I have been in many codebases that have 1000+ lines of code for a given component. The problem with this is that we are more than likely not separating WHAT the component does vs. HOW it does it.
But wait a minute, why should we separate those things? Below are some reasons why I think it is important:
- It makes testing easier. When all business logic occurs in an injected service, it is easier to test the presenation (WHAT) the UI shows by mocking when the service returns. For example, if you have some form validation logic that disables the submit button based on cross form validation, you can have a method on the service that returns a boolean (or better yet an observable / subject of type boolean) that you can mock to test the state of the button. You can also expose a method that handles inputs on the form where in your unit test you can just test that the input changes call the service to perform the validation. In the unit tests for the service, you can test that method to validate that validation is correct.
- It allows for more declarative / reactive programming. Your component simply displays data and is aware of UI interaction. Your service/s are doing the data orchrestation to pass your component and also the processing of the UI interactions.
- It allows code resusability. Consider the situation where your team is tasked with creating a web application. Six months later, the business says that there is a need to create a mobile, either via native web view hybrid or simply making it more responsive, if you built out your component to be presentational only, then you really only need to peel the potato in a different way. The receipe remains the same, meaning you will not have to make many changes to the logic of how the component works.
The Approach
We will create separate libs in our NX monorepo that will export our services needed by our component as well as any interfaces, types, and enums needed. We will also export our state store so that we can initialize our state store in the application.
Last thing to note about this is that the app is an Ionic app. This is not pertinent to this article.
Current List Module
The component
component.html
<pmt-mobile-toolbar class="header" title="Current Items">
</pmt-mobile-toolbar>
<ion-content *ngIf="viewModel$ | async as viewModel">
<ion-list *ngIf="viewModel.currentItems?.length; else noItemText">
<ion-item-sliding *ngFor="let item of viewModel.currentItems;">
<ion-item-options side="start">
<ion-item-option color="danger">
<ion-icon name="trash-sharp"></ion-icon>
</ion-item-option>
</ion-item-options>
<ion-item-options side="end">
<ion-item-option (click)="currentListStateSvc.markItemAsUsed(item)">
<ion-icon name="checkmark-sharp"></ion-icon>
</ion-item-option>
<ion-item-option (click)="currentListStateSvc.decrementItem(item)" *ngIf="item.qty > 1"><ion-icon name="remove-sharp"></ion-icon></ion-item-option>
</ion-item-options>
<ion-item lines="full">
<div class="grocery-item-container">
<span class="item-name">{{item.name}}</span>
<div class="item-details">
<div class="details-container">
<span class="label">Date Purchased:</span>
<span>{{item.datePurchased}}</span>
</div>
<div class="details-container">
<span class="label">Qty Left:</span>
<span class="qty">{{item.qty}}</span>
</div>
</div>
</div>
</ion-item>
</ion-item-sliding>
</ion-list>
<ng-template #noItemText>
<main class="no-item-section">
<div>
{{viewModel.noItemsText}}
</div>
</main>
</ng-template>
</ion-content>
Things to Note:
- We are using a
pmt-mobile-toolbar
component. This is another library in our monorepo that is a wrapper around Ionic's toolbar component. - We using a variable called
viewModel$
. This is an observable that contains all the data needed for this component. We useasync
pipe here as a best practice for Angular applications. - We bind to some elements' click handler where we call the service directly.
component.ts
import { Component, OnInit } from '@angular/core';
import {
CurrentListStateService,
CurrentListViewModel,
} from '@pmt/grocery-list-organizer-business-logic-current-grocery-items';
import { Observable } from 'rxjs';
@Component({
selector: 'pmt-current-list',
templateUrl: './current-list.component.html',
styleUrls: ['./current-list.component.scss'],
providers: [CurrentListStateService],
})
export class CurrentListComponent implements OnInit {
viewModel$!: Observable<CurrentListViewModel>;
constructor(public currentListStateSvc: CurrentListStateService) {}
ngOnInit(): void {
this.viewModel$ = this.currentListStateSvc.getViewModel();
}
}
Things to Note:
- We import items from the
@pmt/grocery-list-organizer-business-logic-current-grocery-items
. This is the library we created in the monorepo. This library is a one for one map to the module that contains this specific component. Also, the items we import are both the service and the view model. - We inject our state service directly into our component. We will see later that in the service, we don't use
providedIn: root
when using the@Injectable
annotation. This means that this service will be both created and destroyed when this component is created and destroyed. - This is a very lean component that really only takes the data from the service.
app.module.ts
import { NgModule } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { RouteReuseStrategy } from '@angular/router';
import { IonicModule, IonicRouteStrategy } from '@ionic/angular';
import { IonicStorageModule } from '@ionic/storage-angular';
import { StoreModule } from '@ngrx/store';
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import {
GlobalEffects,
globalReducer,
} from '@pmt/grocery-list-organizer-shared-business-logic';
import { EffectsModule } from '@ngrx/effects';
import {
CurrentGroceryItemsEffects,
currentGroceryItemsReducer,
} from '@pmt/grocery-list-organizer-business-logic-current-grocery-items';
@NgModule({
declarations: [AppComponent],
entryComponents: [],
imports: [
BrowserAnimationsModule,
IonicModule.forRoot(),
IonicStorageModule.forRoot(),
StoreModule.forRoot({
app: globalReducer,
'current-list': currentGroceryItemsReducer,
}),
EffectsModule.forRoot([GlobalEffects, CurrentGroceryItemsEffects]),
StoreDevtoolsModule.instrument({}),
AppRoutingModule,
ReactiveFormsModule,
],
providers: [{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy }],
bootstrap: [AppComponent],
})
export class AppModule {}
Things to Note:
- This is the app module file. Since the default screen is the current list view, we import our state exports here (
currentGroceryItemsReducer
andCurrentGroceryItemsEffects
). For other lazy loaded modules, we can import the state exports in that module specifically.
@pmt/grocery-list-organizer-business-logic-current-items
current-list-state service
import { Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { CurrentGroceryItem } from '@pmt/grocery-list-organizer-shared-business-logic';
import { map, Observable } from 'rxjs';
import { getCurrentItems } from '..';
import {
decrementItemQty,
markItemAsUsed,
} from '../actions/current-grocery-items.actions';
import {
CurrentListState,
CurrentListViewModel,
} from '../models/current-list.interface';
@Injectable()
export class CurrentListStateService {
constructor(private _store: Store<CurrentListState>) {}
getViewModel(): Observable<CurrentListViewModel> {
const viewModel$ = this._store.select(getCurrentItems).pipe(
map((currentItems) => {
const itemsToReturn: CurrentGroceryItem[] = currentItems ?? [];
const viewModel: CurrentListViewModel = {
currentItems: itemsToReturn,
noItemsText: 'You currently have no items.',
};
return viewModel;
})
);
return viewModel$;
}
markItemAsUsed(usedItem: CurrentGroceryItem): void {
this._store.dispatch(markItemAsUsed({ usedItem }));
}
decrementItem(itemToDecrement: CurrentGroceryItem): void {
this._store.dispatch(decrementItemQty({ itemToDecrement }));
}
}
Things to Note
- We do not use
providedIn: root
in the@Injectable
annotation here, as we discussed earlier. - We inject the store directly into this service.
- This is a straight forward service where
getViewModel
orchestrates the data to pass to the component, and themarkItemAsUsed
anddecrementItem
handle th UI interactions but just dispatching actions to the store.
actions.ts
import { createAction, props } from '@ngrx/store';
import { CurrentGroceryItem } from '@pmt/grocery-list-organizer-shared-business-logic';
export enum CurrentItemActionType {
LOAD_CURRENT_ITEMS = '[Current] Load Current Items',
LOAD_CURRENT_ITEMS_SUCCESS = '[Current] Load Current Items Success',
ADD_ITEM_TO_CURRENT_LIST = '[Current] Add Item to Current List',
MARK_ITEM_AS_USED = '[Current] Mark Item As Used',
DECREMENT_ITEM_QTY = '[Current] Decrement Item Qty',
}
export const loadCurrentItems = createAction(
CurrentItemActionType.LOAD_CURRENT_ITEMS
);
export const loadCurrentItemsSuccess = createAction(
CurrentItemActionType.LOAD_CURRENT_ITEMS_SUCCESS,
props<{ currentItems: CurrentGroceryItem[] }>()
);
export const addItemToCurrentList = createAction(
CurrentItemActionType.ADD_ITEM_TO_CURRENT_LIST,
props<{ itemToAdd: CurrentGroceryItem }>()
);
export const markItemAsUsed = createAction(
CurrentItemActionType.MARK_ITEM_AS_USED,
props<{ usedItem: CurrentGroceryItem }>()
);
export const decrementItemQty = createAction(
CurrentItemActionType.DECREMENT_ITEM_QTY,
props<{ itemToDecrement: CurrentGroceryItem }>()
);
reducer.ts
import { createReducer, on } from '@ngrx/store';
import {
addItemToCurrentList,
decrementItemQty,
loadCurrentItemsSuccess,
markItemAsUsed,
} from '../actions/current-grocery-items.actions';
import { CurrentListState } from '../models/current-list.interface';
const initialState: CurrentListState = {
currentItems: undefined,
};
export const currentGroceryItemsReducer = createReducer(
initialState,
on(loadCurrentItemsSuccess, (state, { currentItems }) => ({
...state,
currentItems,
})),
on(addItemToCurrentList, (state, { itemToAdd }) => {
const updatedItems = [...(state.currentItems ?? []), itemToAdd];
return { ...state, currentItems: updatedItems };
}),
on(markItemAsUsed, (state, { usedItem }) => {
const currentItems = state.currentItems?.filter(
(item) => item.id !== usedItem.id
);
return { ...state, currentItems };
}),
on(decrementItemQty, (state, { itemToDecrement }) => {
const updatedItems = state.currentItems?.map((item) => {
if (item.id === itemToDecrement.id) {
const updatedItem = { ...item, qty: itemToDecrement.qty - 1 };
return updatedItem;
}
return item;
});
return { ...state, currentItems: updatedItems };
})
);
effects.ts
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { initializeApp } from '@pmt/grocery-list-organizer-shared-business-logic';
import { tap } from 'rxjs';
import {
addItemToCurrentList,
decrementItemQty,
markItemAsUsed,
} from '../actions/current-grocery-items.actions';
import { CurrentGroceryItemsUtilService } from '../services/current-grocery-items-util.service';
@Injectable()
export class CurrentGroceryItemsEffects {
constructor(
private _actions$: Actions,
private _currentItemsUtilSvc: CurrentGroceryItemsUtilService
) {}
initAppLoadItems$ = createEffect(
() =>
this._actions$.pipe(
ofType(initializeApp),
tap(() => this._currentItemsUtilSvc.loadItemsFromStorage())
),
{ dispatch: false }
);
addItemToCurrentListUpdateStorage$ = createEffect(
() =>
this._actions$.pipe(
ofType(addItemToCurrentList),
tap((action) => {
this._currentItemsUtilSvc.addItemToCurrentListOnStorage(
action.itemToAdd
);
})
),
{ dispatch: false }
);
markItemAsUsed$ = createEffect(
() =>
this._actions$.pipe(
ofType(markItemAsUsed),
tap((action) => {
this._currentItemsUtilSvc.updateStorageAfterItemMarkedAsUsed(
action.usedItem
);
})
),
{ dispatch: false }
);
decrementItemUpdateStorage$ = createEffect(
() =>
this._actions$.pipe(
ofType(decrementItemQty),
tap((action) => {
this._currentItemsUtilSvc.updateStoargeAfterDecrementItem(
action.itemToDecrement
);
})
),
{ dispatch: false }
);
}
Things to Note:
- This actions and reducer file are straight forward and have nothing noteworthy to point out.
- On the effects file, we inject a util service that will NOT be exported as part of the library. We want to only allow access to that service from within this library.
- We are managing UI state through events that we listen for in our effects which will be a separate article.
index.ts
export * from './lib/actions/current-grocery-items.actions';
export * from './lib/reducer/current-grocery-items.reducer';
export * from './lib/effects/current-grocery-items.effects';
export * from './lib/index';
export { CurrentListStateService } from './lib/services/current-list-state.service';
export * from './lib/models/current-list.interface';
Things to Note:
- This is our contract for the library. You can see that while we export our models, state service, and store arifacts, we do not export our util service. That service is internal for this library.
Conclusion
I hope you enjoyed this article on my approach of using NX to separate out UI pieces from the business logic in our applications. Hopefully, you all can give it a try and let me know how it works for you. You can reach me via Twitter @paulmojicatech
. Happy Coding!
Top comments (0)