DEV Community

paulmojicatech
paulmojicatech

Posted on • Edited on

Separate Business Logic from UI Presenational Logic Using NX

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>

Enter fullscreen mode Exit fullscreen mode

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 use async 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();
  }
}

Enter fullscreen mode Exit fullscreen mode

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 {}

Enter fullscreen mode Exit fullscreen mode

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 and CurrentGroceryItemsEffects). 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 }));
  }
}

Enter fullscreen mode Exit fullscreen mode

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 the markItemAsUsed and decrementItem 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 }>()
);

Enter fullscreen mode Exit fullscreen mode

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 };
  })
);

Enter fullscreen mode Exit fullscreen mode

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 }
  );
}

Enter fullscreen mode Exit fullscreen mode

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';

Enter fullscreen mode Exit fullscreen mode

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)