Architecting Angular Apps for Scalability

Doguhan Uluca

Architecting Angular Apps for Scalability

Technical Fellow @

What do we develop?

What is a Line-of-Business App?

set of critical computer applications perceived as vital to running an enterprise

Source: Wikipedia

Personal development

Enterprise development

How should we develop?

Router-First Architecture

the 80-20 solution

What is Router-First Architecture?

  • enforce high-level thinking
  • ensure consensus on features, before you start coding
  • plan on your codebase/team to grow
  • introduce little engineering overhead

A way to

  1. Develop a roadmap and scope
  2. Design with lazy loading in mind
  3. Implement a walking-skeleton navigation experience
  4. Achieve a stateless, data-driven design
  5. Enforce a decoupled component architecture
  6. Differentiate between user controls and components
  7. Maximize code reuse with ES6/TypeScript

How to implement Router-First?

Sample Project Code

  1. Develop a roadmap and scope
  2. Design with lazy loading in mind
  3. Implement a walking-skeleton navigation experience
  4. Achieve a stateless, data-driven design
  5. Enforce a decoupled component architecture
  6. Differentiate between user controls and components
  7. Maximize code reuse with ES6/TypeScript

How to implement Router-First?

Lemon

Mart

The Lemon Business

Identify Stakeholders

Site Map

Site Map

Site Map

  • Capture the vision concretely
    • i.e. Site maps, mockups, user stories
  • Document every artifact you create
    • i.e. GitHub Wiki, Confluence
  • Define the roadmap before getting on the road
    • i.e. GitHub Projects, Jira
  • Get high-level architecture right
  • Bring tools in only when necessary
    • Paper and pencil works wonders
    • Everyone can/should draw: the Squid Framework, Visual Thinking

1. Develop a Roadmap and Scope

What you avoid implementing is more important than what you implement

Source: A wise developer

  1. Develop a roadmap and scope
  2. Design with lazy loading in mind
  3. Implement a walking-skeleton navigation experience
  4. Achieve a stateless, data-driven design
  5. Enforce a decoupled component architecture
  6. Differentiate between user controls and components
  7. Maximize code reuse with ES6/TypeScript

How to implement Router-First?

First-Paint Matters, A Lot

  • 53% of mobile users* abandon if load times > 3 secs
  • Content is consumed mostly on mobile*
  • 70% in the US
  • 90% in China
  • Hybrid client/server-side rendering is hard and expensive

*Source: Angular Team, Google Analytics, 2018

Angular

app.ts

rootRouter

services

pipes

modules

/a: default

/master

/detail

/b/...

/c

childRouter

/d

/e

/f

Define Feature Modules

  • manager
  • inventory
  • pos
  • unauthorized
  • none

User Roles

Modules

  • ManagerModule
  • InventoryModule
  • PosModule
  • AppModule
  • UserModule

Authenticate to Authorize

implement a Login experience

achieved iteratively and incrementally

Lazy Loading Feature Modules

duluca/lemon-mart/src/app/app-routing.module.ts 
{
  path: 'user',
  loadChildren: () => 
    import('./user/user.module')
    .then(m => m.UserModule),
},
{
  path: 'manager',
  loadChildren: () => 
    import('./manager/manager.module')
    .then(m => m.ManagerModule),
  canLoad: [AuthGuard],
},

Lazy Loading Tips

  1. You may eager-load resources in the background
  2. Optimize your chunk sizes
  3. Watch for sizes of individual assets
  4. Enable compression on your web server
  5. Component level lazy loading will be possible in the future

2. Design with Lazy-Loading in Mind

  • First-paint matters a lot
  • Lazy loading is low hanging fruit
  • Requires user roles to be defined early on
  • Very difficult implement after the fact
  1. Develop a roadmap and scope
  2. Design with lazy loading in mind
  3. Implement a walking-skeleton navigation experience
  4. Achieve a stateless, data-driven design
  5. Enforce a decoupled component architecture
  6. Differentiate between user controls and components
  7. Maximize code reuse with ES6/TypeScript

How to implement Router-First?

Implement Major Structural Elements

  • AppModule
  • UserModule
  • ManagerModule
  • InventoryModule
  • PosModule
  • Achieve a concrete representation of the scope
  • Set the stage for multiple teams to work in tandem
  • Gather feedback from users
  • Workout fundamental workflow and integration issues quickly
  • Angular Material components makes delivering high quality UX a breeze

3. Walking-Skeleton Navigation UX

  1. Develop a roadmap and scope
  2. Design with lazy loading in mind
  3. Implement a walking-skeleton navigation experience
  4. Achieve a stateless, data-driven design
  5. Enforce a decoupled component architecture
  6. Differentiate between user controls and components
  7. Maximize code reuse with ES6/TypeScript

How to implement Router-First?

RxJS/BehaviorSubject as Data Anchors

duluca/lemon-mart/src/app/user/user/user.service.ts 

readonly currentUser$ = new BehaviorSubject<IUser>(
   this.getItem('user') || 
   new User()
)

Consuming BehaviorSubject

duluca/lemon-mart/src/app/user/view-user/view-user.component.ts

<div *ngIf="currentUser$ | async as currentUser">
  <div>{{currentUser.firstName}}</div>
  ...
</div>

Updating BehaviorSubject

duluca/lemon-mart/src/app/user/user/user.service.ts

updateResponse.pipe(
  tap(
    res => {
      this.currentUser$.next(res)
      this.removeItem('draft-user')
    },
    err => observableThrowError(err)
  )
)

4. Be Stateless & Data-Driven

  • Define observable "data anchors"
  • Don't store state in components
  • Data across components will be kept in sync
  • Leverage RxJS features
  • Write functional reactive code
    • Avoid subscribing, use the async pipe instead
    • If you subscribe, use SubSink to manage subscriptions
    • If you subscribe in the middle of a data stream, you're not implementing reactively
  1. Develop a roadmap and scope
  2. Design with lazy loading in mind
  3. Implement a walking-skeleton navigation experience
  4. Achieve a stateless, data-driven design
  5. Enforce a decoupled component architecture
  6. Differentiate between user controls and components
  7. Maximize code reuse with ES6/TypeScript

How to implement Router-First?

  • @Input and @Output bindings
    • Scaling with Form Parts Reuse
  • Router orchestration
    • Reusable components

Ways to decouple

Scaling with Form Parts Reuse

FormBuilder

duluca/lemon-mart/user/profile/profile.component.ts  

name: this.formBuilder.group({
    first: [(user && user.name.first) || '', RequiredTextValidation],
    middle: [(user && user.name.middle) || '', OneCharValidation],
    last: [(user && user.name.last) || '', RequiredTextValidation],
 }),

FormBuilder

duluca/lemon-mart/user/profile/profile.component.html  

  <div fxLayout="row" fxLayout.lt-sm="column"
    [formGroup]="userForm.get('name')" fxLayoutGap="10px">
    <mat-form-field appearance="outline" fxFlex="40%">
      <mat-label>First Name</mat-label>
      <input matInput aria-label="First Name" formControlName="first" />
      <mat-error *ngIf="userForm.get('name').get('first').hasError('required')">
        First Name is required
      </mat-error>
      <mat-error *ngIf="userForm.get('name').get('first').hasError('minLength')">
        Must be at least 2 characters
      </mat-error>
      <mat-error *ngIf="userForm.get('name').get('first').hasError('maxLength')">
        Can't exceed 50 characters
      </mat-error>
    </mat-form-field>
    <mat-form-field appearance="outline" fxFlex="20%">
      <mat-label>MI</mat-label>
      <input matInput aria-label="Middle Initial" formControlName="middle" />
      <mat-error *ngIf="userForm.get('name').get('middle').invalid">
        Only inital
      </mat-error>
    </mat-form-field>
    <mat-form-field appearance="outline" fxFlex="40%">
      <mat-label>Last Name</mat-label>
      <input matInput aria-label="Last Name" formControlName="last" />
      <mat-error *ngIf="userForm.get('name').get('last').hasError('required')">
        Last Name is required
      </mat-error>
      <mat-error *ngIf="userForm.get('name').get('last').hasError('minLength')">
        Must be at least 2 characters
      </mat-error>
      <mat-error *ngIf="userForm.get('name').get('last').hasError('maxLength')">
        Can't exceed 50 characters
      </mat-error>
    </mat-form-field>
  </div>

Form Parts

duluca/lemon-mart/user/name-input/name-input.component.ts  

export class NameInputComponent implements OnInit {
  @Input() initialData: IName
  @Input() disable: boolean
  @Output() formReady = new EventEmitter<FormGroup>()

  formGroup: FormGroup

  constructor(private formBuilder: FormBuilder) {}

  ngOnInit() {
    this.formGroup = this.buildForm(this.initialData)
    this.formReady.emit(this.formGroup)
    if (this.disable) {
      this.formGroup.disable()
    }
  }

  ...
}

Form Parts

duluca/lemon-mart/user/name-input/name-input.component.ts  

export class NameInputComponent implements OnInit {
  ...

  buildForm(initialData?: IName): FormGroup {
    return this.formBuilder.group({
      first: [name ? name.first : '', RequiredTextValidation],
      middle: [name ? name.middle : '', OptionalTextValidation],
      last: [name ? name.last : '', RequiredTextValidation],
    })
  }
}

Form Parts

duluca/lemon-mart/user/name-input/name-input.component.html

template: `
    <form [formGroup]="formGroup">
      <div fxLayout="row" fxLayout.lt-sm="column" fxLayoutGap="10px">
        <mat-form-field appearance="outline" fxFlex="40%">
          <mat-label>First Name</mat-label>
          <input matInput aria-label="First Name" formControlName="first" required />
          <mat-error *ngIf="formGroup.get('first').hasError('required')">
            First Name is required
          </mat-error>
        </mat-form-field>
        <mat-form-field appearance="outline" fxFlex="20%">
          <mat-label>MI</mat-label>
          <input matInput aria-label="Middle" formControlName="middle" />
        </mat-form-field>
        <mat-form-field appearance="outline" fxFlex="40%">
          <mat-label>Last Name</mat-label>
          <input matInput aria-label="Last Name" formControlName="last" required />
          <mat-error *ngIf="formGroup.get('last').hasError('required')">
            Last Name is required
          </mat-error>
        </mat-form-field>
      </div>
    </form>
  `,

Form Parts

duluca/lemon-mart/user/profile/profile.component.ts  

<form [formGroup]="userForm">
  <ng-template matStepLabel>Contact Information</ng-template>
  <div class="stepContent">
    <app-name-input [initialData]="name$ | async"
      (formReady)="userForm.setControl('name', $event)">
    </app-name-input>
    ...
  </div>
</form>

Identify Reused Components

Master/Detail

Bound or Routed

Bound Context

Bound Context

duluca/lemon-mart/src/app/user/profile/profile.component.html
<mat-step [stepControl]="formGroup">
  <form [formGroup]="formGroup" (ngSubmit)="save(formGroup)">
    <ng-template matStepLabel>Review</ng-template>
    <div class="stepContent">
      Review and update your user profile.
      <app-view-user [user]="formGroup.value"></app-view-user>
    </div>
    …
  </form>
</mat-step>

Master/Detail Context

Master/Detail Context

duluca/lemon-mart/src/app/manager/manager-routing.module.ts
{
  path: 'users',
  component: UserManagementComponent,
  children: [
    { path: '', component: UserTableComponent, outlet: 'master' },
    {
      path: 'user',
      component: ViewUserComponent,
      outlet: 'detail',
      resolve: {
        user: UserResolve,
      },
    },
  ],
  canActivate: [AuthGuard],
  canActivateChild: [AuthGuard],
  data: {
    expectedRole: Role.Manager,
  },
}

Router Orchtrestion

Router Orchestration

duluca/lemon-mart/src/app/manager/user—table/user-table.component.ts
<a mat-button mat-icon-button 
  [routerLink]="[
    '/manager/users',
    { outlets: { detail: ['user', { userId: row.id }] } }
  ]" 
  skipLocationChange>
  <mat-icon>visibility</mat-icon>
</a>

Cost of Reusability

duluca/lemon-mart/src/app/user/view-user/view-user.component.ts
import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { BehaviorSubject } from 'rxjs'

import { IUser, User } from '../user/user'

@Component({
  selector: 'app-view-user',
  template: `
    <div *ngIf="currentUser$ | async as currentUser">
      <mat-card>
        <mat-card-header>
          <div mat-card-avatar><mat-icon>account_circle</mat-icon></div>
          <mat-card-title>{{ currentUser.fullName }}</mat-card-title>
          <mat-card-subtitle>{{ currentUser.role }}</mat-card-subtitle>
        </mat-card-header>
        <mat-card-content>
          <p><span class="mat-input bold">E-mail</span></p>
          <p>{{ currentUser.email }}</p>
          <p><span class="mat-input bold">Date of Birth</span></p>
          <p>{{ currentUser.dateOfBirth | date: 'mediumDate' }}</p>
        </mat-card-content>
        <mat-card-actions *ngIf="editMode">
          <button mat-button mat-raised-button>Edit</button>
        </mat-card-actions>
      </mat-card>
    </div>
  `,
  styles: [
    `
      .bold {
        font-weight: bold;
      }
    `,
  ],
})
export class ViewUserComponent implements OnInit, OnChanges {
  @Input() user: IUser
  readonly currentUser$ = new BehaviorSubject(new User())

  get editMode() {
    return !this.user
  }

  constructor(private route: ActivatedRoute) {}

  ngOnInit() {
    if (this.route.snapshot.data.user) {
      const snapshotUser = User.Build(this.route.snapshot.data.user)
      if (!snapshotUser.dateOfBirth) {
        snapshotUser.dateOfBirth = Date.now() // for data mocking purposes only
      }
      this.currentUser$.next(snapshotUser)
    }
  }

  ngOnChanges(changes: SimpleChanges): void {
    this.currentUser$.next(User.Build(changes.user.currentValue))
  }
}

5. Be Decoupled

  • Every component should be responsible for loading their own data
    • Allows for composition of components
  • Router enables URL driven composition/orchestration
    • Don't abuse the router
  • Ok to design for a parent component to contain multiple hard-coded components
    • i.e. Forms, static layouts (read: most screens)
  1. Develop a roadmap and scope
  2. Design with lazy loading in mind
  3. Implement a walking-skeleton navigation experience
  4. Achieve a stateless, data-driven design
  5. Enforce a decoupled component architecture
  6. Differentiate between user controls and components
  7. Maximize code reuse with ES6/TypeScript

How to implement Router-First?

  • i.e. Custom Date Input, Star Rater
  • Likely implements ControlValueAccessor

What's a User Control?

  • i.e. Form with date inputs
  • Form parts, i.e. NameInputComponent
  • Reusable components, i.e. ViewUserComponent

What is a component?

User Control vs Component

Usage

duluca/lemon-mart/user/profile/profile.component.ts  

<div fxLayout="row" fxLayout.lt-sm="column" class="margin-top"
  fxLayoutGap="10px">
  <mat-label class="mat-body-1">Select the Limoncu level:
    <app-lemon-rater formControlName="level">
    </app-lemon-rater>
  </mat-label>
</div>

<form [formGroup]="userForm">
  <ng-template matStepLabel>Contact Information</ng-template>
  <div class="stepContent">
    <app-name-input [initialData]="name$ | async"
      (formReady)="userForm.setControl('name', $event)">
    </app-name-input>
    ...
  </div>
</form>
  • Encapsulates complicated user interaction code 
  • Highly coupled, convoluted, complicated code
  • Using Angular features no one has ever heard of before
  • Can be shipped publicly
  • Be open sourced

Implementation Differences

User Controls

Components

  • Encapsulates domain-specific behavior
  • Code must be easy to read and understand
  • Sticks to Angular basics, so code is stable and easy to maintain
  • Can be shared company-wide

6. User Controls vs Components

  • Wire-framing makes it possible to identify reusable elements early on
  • Keep user interaction code separate from business logic
  • Increase composability
  • Save time and resources
  • If possible, open-source controls and become part of the community
  1. Develop a roadmap and scope
  2. Design with lazy loading in mind
  3. Implement a walking-skeleton navigation experience
  4. Achieve a stateless, data-driven design
  5. Enforce a decoupled component architecture
  6. Differentiate between user controls and components
  7. Maximize code reuse with ES6/TypeScript

How to implement Router-First?

  • Documents shape of data
  • Pass abstractions, not concretions
  • Separate internal data shape from external shape

Interfaces

  • Aim for a flat data hierarchy
    • Flatten complicated data structures in services
  • Arrays and simple shapes for common objects are okay
    • i.e. a name object or a common domain-specific object

Interface Tips

Interfaces

duluca/lemon-mart/src/app/user/user/user.ts
export interface IUser {
  id: string
  email: string
  name: IName
  …
  address: {
    line1: string
    line2: string
    city: string
    state: string
    zip: string
  }
  phones: IPhone[]
}

export interface IName {
  first: string
  middle?: string
  last: string
}
duluca/lemon-mart/src/app/user/user/user.ts

export interface IPhone {
  type: string
  number: string
  id: number
}

export class User implements IUser

Work with Abstractions

duluca/lemon-mart/src/app/user/user/user.service.ts

getUser(id): Observable<IUser> {
  return this.httpClient.get<IUser>(`${environment.baseUrl}/v1/user/${id}`)
}
duluca/local-weather-app/src/app/weather/weather.service.ts

private getCurrentWeatherHelper(uriParams: string): 
  Observable<ICurrentWeather> {
  return this.httpClient
    .get<ICurrentWeatherData>(
      `${environment.baseUrl}api.openweathermap.org/data/2.5/weather?` +
        `${uriParams}&appid=${environment.appId}`
    )
    .pipe(map(data => this.transformToICurrentWeather(data)))
}
  • No string literals in code
  • No string literals in code
  • No string literals in code

Use Enums

duluca/lemon-mart/src/app/auth/role.enum.ts

export enum Role {
  None = 'none',
  Clerk = 'clerk',
  Cashier = 'cashier',
  Manager = 'manager',
}

DRY

  • Don't Repeat Yourself
    • Refactor code into sharable functions
      • Import functions in other files
    • OOP
      • Abstract classes
      • Inheritance

Object-Oriented Design

  • Move behavior to classes
  • i.e. hydration, toJSON, calculated properties
  • Don’t abuse OOP
    • Avoid state inside classes, remain functional

Behavior in Classes

duluca/lemon-mart/src/app/user/user/user.ts
export class User implements IUser {
  static Build(user: IUser) {
    return new User(
      user.id,
      user.email,
      user.name,
      …
    )
  }

  get fullName() {
    return this.name ? 
      `${this.name.first} ${this.name.middle} ${this.name.last}` : ''
  }

  toJSON() {
    return JSON.stringify(...)
  }
}

Generics and Inheritance

duluca/lemon-mart/src/app/common/base-form.class.ts
export abstract class BaseFormComponent<TFormData> {
  @Input() initialData: TFormData
  @Input() disable: boolean
  @Output() formReady: EventEmitter<AbstractControl>
  formGroup: FormGroup

  private registeredForms: string[] = []

  constructor() {
    this.formReady = new EventEmitter<AbstractControl>(true)
  }

  abstract buildForm(initialData?: TFormData): FormGroup

  patchUpdatedData(data) {
    this.formGroup.patchValue(data, { onlySelf: false })
  }
  …
}

Generics and Inheritance

duluca/lemon-mart/src/app/common/base-form.class.ts
export class NameInputComponent extends BaseFormComponent<IName>
  implements OnInit, OnChanges {
  constructor(private formBuilder: FormBuilder) {
    super()
  }

  buildForm(initialData?: IName): FormGroup {
    const name = initialData
    return this.formBuilder.group({ … })
  }

  ngOnInit() {
    this.formGroup = this.buildForm(this.initialData)
    this.formReady.emit(this.formGroup)
  }
  …
}

7. Use ES6 & TypeScript Features

  • Refactor code, so you can export reusable functions
  • Pass around abstractions (i.e. interfaces)
  • Use enums instead of string literals
  • Use interfaces to document the shape of your data (internal or external)
  • Use classes to reuse context-specific behavior
  • Use abstract base classes to enforce implementation patterns, reuse context-specific behavior 
  • Leverage Angular Validators, Pipes, Route Resolvers, and Route Guards to reuse logic

Think Router-First

Keep it simple

Master the fundamentals

Be reactive, iterative, incremental

Angular Evergreen

VS Code Extension

@duluca

linkedin.com/in/duluca

github.com/duluca

Router-First Metrics

  • Feedback loop cycle reduced from 20 minutes to 1 minute 

  • ~25% reduction in package dependencies

  • Eliminated variations in versions of dependencies

  • ~25% reduction in software bugs reported 

  • ~25% increase in velocity 

Before

After

  • 10 projects (with libraries, multiple Angular apps)

  • 50 developers

  • With 33% of project time used, only 11% of functionality implemented

Doguhan Uluca

Architecting Angular Apps for Scalability

By Doguhan Uluca

Architecting Angular Apps for Scalability

The key ingredients to architecting for scalability are thinking router-first, keeping it simple, mastering the fundamentals and being reactive. A router-first approach to SPA design saving development teams significant waste in duplicative work, and re-architecting of the codebase; enabling better collaboration and achieving sub-second first meaningful paints in your application

  • 3,896