Ways to Make Sure Your Angular Code is Agile

Doguhan Uluca

Principal Fellow  at

@duluca

5

Agile

Lean

Kanban

Scrum

LeSS

SAFe

SoS

Nexus

Spotify

"Highest priority is ... early and continuous delivery
of valuable software"

"changing requirements, even late in
development"

 

"Deliver working software frequently"

 

"Working software is the primary measure of progress"

"... promote sustainable development"

"Continuous attention to technical excellence"

"Simplicity--the art of maximizing the amount
of work not done--is essential"

https://agilemanifesto.org/principles.html

Agile

Agility

, forward flow

consistent

of features

"It's not up to them

it's up to us [the developers]"

Ron Jeffries, Chet Hendrickson, deliver:Agile 2018.

It's up to you to deliver a quality product

Five Ways to Make Sure Your Angular Code is Agile

  1. Iterative & incremental
  2. Open to extension, closed to modification
  3. Pick a component library and stick to it
  4. Test smart
  5. Think twice, code less

Iterative & Incremental

1

Login experience

achieved iteratively and incrementally

Forms

read-only preview

Forms

simple layout

Forms

responsive layout

Forms

validations, fancy user controls

achieved iteratively and incrementally

Forms

simple inputs, static entry

Forms

auto complete fields

Forms

dynamic entry and validation

achieved iteratively and incrementally

Reactive

products$ = this.http.get<Product[]>(this.productsUrl)
    .pipe(
      tap(data => console.log('getProducts: ', JSON.stringify(data))),
      shareReplay(),
      catchError(this.handleError)
    )

Reactive

productsWithCategory$ = combineLatest(
    this.products$,
    this.productCategoryService.productCategories$
  ).pipe(
    map(([products, categories]) =>
      products.map(
        p =>
          ({
            ...p,
            category: categories.find(c => p.categoryId === c.id).name
          } as Product) // <-- note the type here!
      )
    ),
    shareReplay()
  )

stateless, composable, and reactive

enables

iterative and incremental development

 

Sources

Data Composition with RxJS | Deborah Kurata

Thinking Reactively: Most Difficult | Mike Pearson

Open to extension,

Closed to modification

2

  • Single Responsibility
    • DRY, one place to change
  • Open-Closed
    • open for extension, closed for modification
  • Liskov Substitution
    • Use objects derived from base/abstract classes without knowing about specific implementation
  • Interface segregation
    • Many specific interfaces over one general-purpose interface
  • Dependency inversion
    • Depend on abstractions, not concretions

Open-Closed

Principle

Open-Closed

Principle

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

export interface IAuthService {
  readonly authStatus$: BehaviorSubject<IAuthStatus>
  readonly currentUser$: BehaviorSubject<IUser>
  login(email: string, password: string): Observable<void>
  logout(clearToken?: boolean): void
  getToken(): string
}

Open-Closed Principle

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

export abstract class AuthService implements IAuthService {
  authStatus$: BehaviorSubject<IAuthStatus>
  currentUser$: BehaviorSubject<IUser>
  
  constructor() {}
  
  login(email: string, password: string): Observable<void> {
    throw new Error('Method not implemented.')
  }
    
  logout(clearToken?: boolean): void {
    throw new Error('Method not implemented.')
  }

  getToken(): string {
    throw new Error('Method not implemented.')
  }
}

Open-Closed Principle

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

export abstract class AuthService implements IAuthService {
  protected abstract authProvider(
    email: string,
    password: string
  ): Observable<IServerAuthResponse>
  
  protected abstract transformJwtToken(token: unknown): IAuthStatus
  protected abstract getCurrentUser(): Observable<User>
  ...
}

Open-Closed Principle

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

login(email: string, password: string): Observable<void> {
  this.clearToken()

  const loginResponse$ = this.authProvider(email, password).pipe(
    map((value) => {
      this.setToken(value.accessToken)
      const token = decode(value.accessToken)
      return this.transformJwtToken(token)
    }),
    tap((status) => this.authStatus$.next(status)),
    this.getAndUpdateUserIfAuthenticated
  )

  loginResponse$.subscribe({
    error: (err) => {
      this.logout()
      return throwError(err)
    },
  })

  return loginResponse$
}

Liskov Subsitution

duluca/lemon-mart/src/app/auth/auth.inmemory.service.ts

export class InMemoryAuthService extends AuthService {
  // LemonMart Server User Id: 5da01751da27cc462d265913
  private defaultUser = User.Build({...})

  constructor() {
    super()
    console.warn(
      "You're using the InMemoryAuthService. Do not use this service in production."
    )
  }

  protected authProvider(email: string, password: string): Observable<IServerAuthResponse> {
    const authStatus = { isAuthenticated: true, ... } as IAuthStatus

    const authResponse = {
      accessToken: sign(authStatus, 'secret', {
        expiresIn: '1h',
        algorithm: 'none',
      }),
    } as IServerAuthResponse

    return of(authResponse)
  }

  protected transformJwtToken(token: IAuthStatus): IAuthStatus {
    return token
  }

  protected getCurrentUser(): Observable<User> {
    return of(this.defaultUser)
  }
}

Open-Closed Principle

duluca/lemon-mart/src/app/auth/auth.firebase.service.ts

@Injectable()
export class FirebaseAuthService extends AuthService {
  constructor(private afAuth: AngularFireAuth) {
    super()
  }

  protected authProvider(email: string, password: string): Observable<IServerAuthResponse> {
    return this.afAuth.signInWithEmailAndPassword(email, password)
  }

  protected transformJwtToken(token: IJwtToken): IAuthStatus {
    return { isAuthenticated: token.email ? true : false, userId: token.sub, userRole: Role.None }
  }

  protected getCurrentUser(): Observable<User> {
    return this.afAuth.user.pipe(map(this.transformFirebaseUser))
  }

  logout() {
    if (this.afAuth) {
      this.afAuth.signOut()
    }
    this.clearToken()
    this.authStatus$.next(defaultAuthStatus)
  }
}

Open-Closed Principle

duluca/lemon-mart/src/app/auth/auth.custom.service.ts

export class CustomAuthService extends AuthService {
  constructor(private httpClient: HttpClient) {
    super()
  }

  protected authProvider(email: string, password: string): Observable<IServerAuthResponse> {
    return this.httpClient.post<IServerAuthResponse>(
      `${environment.baseUrl}/v1/auth/login`, { email, password }
    )
  }

  protected transformJwtToken(token: IJwtToken): IAuthStatus {
    return {
      isAuthenticated: token.email ? true : false,
      userId: token.sub,
      userRole: $enum(Role).asValueOrDefault(token.role, Role.None),
      userEmail: token.email,
      userPicture: token.picture,
    } as IAuthStatus
  }

  protected getCurrentUser(): Observable<User> {
    return this.httpClient
      .get<IUser>(`${environment.baseUrl}/v1/auth/me`)
      .pipe(map(User.Build, catchError(transformError)))
  }
}

Open-Closed Principle

duluca/lemon-mart/src/app/auth/auth.factory.ts

function authFactory(afAuth: AngularFireAuth, httpClient: HttpClient) {
  switch (environment.authMode) {
    case AuthMode.InMemory:
      return new InMemoryAuthService()
    case AuthMode.CustomServer:
      return new CustomAuthService(httpClient)
    case AuthMode.Firebase:
      return new FirebaseAuthService(afAuth)
  }
}
duluca/lemon-mart/src/app/app.module.ts

providers: [
  {
    provide: AuthService,
    useFactory: authFactory,
    deps: [AngularFireAuth, HttpClient],
  }
]

Dependency Inversion

Leverage SOLID principals

to write code

open to extension, closed to modification

 

Pick a component library

and

Stick to it

3

Step 1

Pick a component library

Angular Component

JavaScript Class
Angular
Binding
Angular Template

Uses

TypeScript

Uses

HTML, CSS

Bootstrap

Step 2

Stick to it

Don't Google your way through it

HELL

Rolling your own?

  1. Should you?

  2. Know what you're doing

  3. Leverage Angular CDK

  4. Establish a design system

  5. Test and stress components individually

  6. Robust automated pipeline

  7. Don't do it

Pick a component library

and

Stick to it

Test Smart

4

Test Driven Development

  • Forces deeper thought about design
  • Ensures testable code is written
  • Avoids over-engineering

"To write unit-testable code, be sure to adhere to the Single Responsibility and
Open/Closed principles of the SOLID principles."

Doguhan Uluca, Angular for Enterprise-Ready Web Apps, 2020.

FIRST principle

  • Fast
  • Isolated
  • Repeatable
  • Self-verifying
  • Timely

Keep it organized & focused

  • Class Under Test
  • File Under Test
  • Function Test

Anatomy of a unit test

  • Arrange – setup
  • Act – run the thing you want to test
  • Assert – verify the results
duluca/local-weather-app/weather/weather.service.spec.ts

describe('getCurrentWeather', () => {
    it('should return value given zip code', () => {
      // Arrange
      const httpMock = TestBed.inject(HttpTestingController)
      const uriParams = 'lat=38.887103&lon=-77.093197'
      postalCodeServiceMock.resolvePostalCode.and.returnValue(
        of({
          postalCode: '22201',
          lat: 38.887103,
          lng: -77.093197,
          countryCode: 'US',
          placeName: 'Arlington',
        } as IPostalCode)
      )

      // Act
      weatherService.getCurrentWeather('22201').subscribe()

      // Assert
      const request = httpMock.expectOne(
        `${environment.baseUrl}api.openweathermap.org/data/2.5/weather?` +
          `${uriParams}&appid=01ff1417eeb4a81b09ac68b15958d453`,
        'call to api'
      )

      expect(request.request.method).toBe('GET')
    })
  })

Arrange, Act, Assert

Isolating Dependencies

weatherApi

weatherComponent

api.openweathermap.org/data/2.5/weather

weatherServiceMock

getCurrentWeather
return(32)
api.openweathermap.org/data/2.5/weather
hasBeenCalled(1)

weatherService

getCurrentWeather

Test Harness

duluca/local-weather-app/weather/weather.service.spec.ts

describe('getCurrentWeather', () => {
    it('should return value given zip code', () => {
      // Arrange
      const httpMock = TestBed.inject(HttpTestingController)
      const uriParams = 'lat=38.887103&lon=-77.093197'
      postalCodeServiceMock.resolvePostalCode.and.returnValue(
        of({
          postalCode: '22201',
          lat: 38.887103,
          lng: -77.093197,
          countryCode: 'US',
          placeName: 'Arlington',
        } as IPostalCode)
      )

      // Act
      weatherService.getCurrentWeather('22201').subscribe()

      // Assert
      const request = httpMock.expectOne(
        `${environment.baseUrl}api.openweathermap.org/data/2.5/weather?` +
          `${uriParams}&appid=01ff1417eeb4a81b09ac68b15958d453`,
        'call to api'
      )

      expect(request.request.method).toBe('GET')
    })
  })

Mocks and Spies

Mike Cohn's Testing Pyramid

Don't overuse unit tests

  1. Leverage e2e testing (i.e. Cypress)
  2. Mock REST endpoints
  3. Merge test coverage results

Merge Test Coverage Results

38%

50%

88%

?

1
2
3
4
5
6
7
8
1
2
3
4
5
6
7
8

Unit Tests

1
2
3
4
5
6
7
8
1
2
3
4
5
6
7
8

e2e Tests

Merge Test Coverage Results

1
2
3
4
5
6
7
8
1
2
3
4
5
6
7
8

75%

Test smart

Merge Test Coverage Results

Jon Simon 

Think Twice
Code Less

 

5

DRY

Don't repeat yourself!

Don't repeat yourself!

Don't repeat yourself!

KISS

keep it simple, stupid!

AC/DC

Avoid complexity, depend (on your own) code

Single Responsibility Principle

export class LemonRaterComponent {
  internalValue!: number

  setRating(lemon: any) {
    let text = ''

    if (lemon.value) {
      text = this.ratings.find((i) => i.value === value)?.text || ''
    }
      
    if (!this.disabled) {
      this.internalValue = lemon.value
      this.displayTextRef.nativeElement.textContent = text
      this.onChanged(lemon.value)
      this.onTouched()
    }
  }

  setDisplayText() {
    let text = ''

    if (this.internalValue) {
      this.displayTextRef.nativeElement.textContent = 
        this.ratings.find((i) => i.value === value)?.text || ''
    }
  }
}

Single Responsibility Principle

export class LemonRaterComponent {
  private internalValue!: number
  
  get value() {
    return this.internalValue
  }

  setRating(lemon: any) {
    if (!this.disabled) {
      this.internalValue = lemon.value
      this.setDisplayText()
      this.onChanged(lemon.value)
      this.onTouched()
    }
  }

  setDisplayText() {
    this.setSelectedText(this.internalValue)
  }

  private setSelectedText(value: number) {
    this.displayTextRef.nativeElement.textContent = 
      this.getSelectedText(value)
  }

  private getSelectedText(value: number) {
    let text = ''

    if (value) {
      text = this.ratings.find((i) => i.value === value)?.text || ''
    }

    return text
  }
}

DRY, KISS, AC/DC

  • Export reusable functions
  • Avoid libraries
  • Avoid overusing dependency injection
new FormControl('', 
  [Validators.required, Validators.minLength(2)]
)

DRY, KISS, AC/DC

duluca/lemon-mart/common/validations.ts

export const OptionalTextValidation = 
  [Validators.minLength(2), Validators.maxLength(50)]
export const RequiredTextValidation = 
  OptionalTextValidation.concat([Validators.required])
export const EmailValidation = [Validators.required, Validators.email]
export const PasswordValidation = ...
export const BirthDateValidation = [
  Validators.required,
  Validators.min(new Date().getFullYear() - 100),
  Validators.max(new Date().getFullYear()),
]
export const USAZipCodeValidation = [
  Validators.required,
  Validators.pattern(/^\d{5}(?:[-\s]\d{4})?$/),
]
export const USAPhoneNumberValidation = ...

DRY, KISS, AC/DC

duluca/lemon-mart-server/server/services/authService.ts

export function createJwt(user: IUser): Promise<string> {
 ...
 sanitizeToken(token)
}

export function authenticate(options?: {
  requiredRole?: Role
  permitIfSelf?: {
    idGetter: (req: Request) => string
    requiredRoleCanOverride: boolean
  }
}) {
  ...
}

function sanitizeToken(authorization: string | undefined) {
 ...
}

DRY, KISS, AC/DC

duluca/lemon-mart-server/server/routes/userRouter.ts

router.get(
  '/',
  authenticate({ requiredRole: Role.Manager }),
  async (req: Request, res: Response) => {
    const query: Partial<IQueryParameters> = {
      filter: req.query.filter,
      limit: req.query.limit,
      skip: req.query.skip,
      sortKeyOrList: req.query.sortKey,
      projectionKeyOrList: ['email', 'role', '_id', 'name'],
    }

    const users = await UserCollection.findWithPagination<User>(query)
    res.send(users)
  }
)

DRY, KISS, AC/DC

  • No string literals in code
  • No string literals in code
  • No string literals in code

Use Enums

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

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

Self-documenting code

  • Code is written once, read many times
  • Once written, documentation is never updated nor read
  • Verbose
  • Living, useful documentation
  • No string literals
  • No string literals
  • No string literals

Write

readable, DRY, and simple code

Write

Less code

Five Ways to Make Sure Your Angular Code is Agile

  1. Iterative & incremental
  2. Open to extension, closed to modification
  3. Pick a component library and stick to it
  4. Test smart
  5. Think twice, code less

We're hiring, including fully remote positions!