Doguhan Uluca
Technical Fellow at
@duluca
Features
Time
Quality
Roy Osherove
Ron Jeffries, Chet Hendrickson, deliver:Agile 2018.
Past
Present
Future
ES3
ES5
ES2015 (aka ES6)
2015
ES3 & ES5
ES2015 - ES2016
ES2017
2017
ES3 & ES 5
ES2019
2019
ES2021
2021
ESPast
ESCurrent
ESNext
Current
ES2015 - ES2017
ES2015 - ES2020
ES3 & ES 5
Sample Project
High Cohesion
lemon-mart-server
├───bin
├───web-app (default Angular setup)
├───server
│ ├───src
│ │ ├───models
│ │ ├───public
│ │ ├───services
│ │ ├───v1
│ │ │ └───routes
│ │ └───v2
│ │ └───routes
│ └───tests
│ package.json
│ README.md
Low Coupling
export class LemonRaterComponent
implements ControlValueAccessor, AfterViewInit {
disabled = false
internalValue!: number
...
}
Low Coupling
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)
}
}
Don't repeat yourself!
Don't repeat yourself!
Don't repeat yourself!
keep it simple, stupid!
Avoid complexity, depend (on your own) code
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
duluca/lemon-mart-server/server/routes/userRouter.ts
/**
* @swagger
* components:
* parameters:
* filterParam:
* in: query
* name: filter
* required: false
* schema:
* type: string
* description: Search text to filter the result set by
* skipParam:
* in: query
* name: skip
* required: false
* schema:
* type: integer
* minimum: 0
* description: The number of items to skip before collecting the result set.
* limitParam:
* in: query
* name: limit
* required: false
* schema:
* type: integer
* minimum: 1
* maximum: 50
* default: 10
* description: The numbers of items to return.
* sortKeyParam:
* in: query
* name: sortKey
* required: false
* schema:
* type: string
* description: Name of a column to sort ascending.
* Prepend column name with a dash to sort descending.
*/
/**
* @swagger
* /v2/users:
* get:
* description: |
* Searches, sorts, paginates and returns a summary of `User` objects.
* parameters:
* - $ref: '#/components/parameters/filterParam'
* - $ref: '#/components/parameters/skipParam'
* - $ref: '#/components/parameters/limitParam'
* - $ref: '#/components/parameters/sortKeyParam'
* responses:
* "200": # Response
* description: OK
* content:
* application/json:
* schema:
* type: object
* properties:
* total:
* type: integer
* data:
* type: array
* items:
* type: object
* properties:
* _id:
* type: string
* email:
* type: string
* fullName:
* type: string
* name:
* $ref: "#/components/schemas/Name"
* role:
* $ref: "#/components/schemas/Role"
* description: Summary of `User` object.
*/
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)
}
)
duluca/lemon-mart-server/server/routes/userRouter.ts
/**
* @swagger
* components:
* parameters:
* filterParam:
* in: query
* name: filter
* required: false
* schema:
* type: string
* description: Search text to filter the result set by
* skipParam:
* in: query
* name: skip
* required: false
* schema:
* type: integer
* minimum: 0
* description: The number of items to skip before collecting the result set.
* limitParam:
* in: query
* name: limit
* required: false
* schema:
* type: integer
* minimum: 1
* maximum: 50
* default: 10
* description: The numbers of items to return.
* sortKeyParam:
* in: query
* name: sortKey
* required: false
* schema:
* type: string
* description: Name of a column to sort ascending.
* Prepend column name with a dash to sort descending.
*/
/**
* @swagger
* /v2/users:
* get:
* description: |
* Searches, sorts, paginates and returns a summary of `User` objects.
* parameters:
* - $ref: '#/components/parameters/filterParam'
* - $ref: '#/components/parameters/skipParam'
* - $ref: '#/components/parameters/limitParam'
* - $ref: '#/components/parameters/sortKeyParam'
* responses:
* "200": # Response
* description: OK
* content:
* application/json:
* schema:
* type: object
* properties:
* total:
* type: integer
* data:
* type: array
* items:
* type: object
* properties:
* _id:
* type: string
* email:
* type: string
* fullName:
* type: string
* name:
* $ref: "#/components/schemas/Name"
* role:
* $ref: "#/components/schemas/Role"
* description: Summary of `User` object.
*/
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)
}
)
duluca/document-ts/collectionFactory.ts
private cursor<T>(
cursor: AggregationCursor<T> | undefined,
params: Partial<QueryParameters> & object,
query: {} | undefined
): AggregationCursor<T> | Cursor<K> {
if (cursor) {
if (params && params.filter) {
cursor = cursor.match(
this.query(params.filter, this.props)
)
}
return cursor
} else {
let projection: object[] = []
if (params && params.projectionKeyOrList) {
projection = this.convertToObject(params.projectionKeyOrList, 0)
}
return this.cursor(query, Object.assign({}, ...projection))
}
}
Readable Code
duluca/document-ts/collectionFactory.ts
private buildCursor<TReturnType>(
aggregationCursor: AggregationCursor<TReturnType> | undefined,
queryParams: Partial<IQueryParameters> & object,
builtQuery: {} | undefined
): AggregationCursor<TReturnType> | Cursor<TDocument> {
if (aggregationCursor) {
if (queryParams && queryParams.filter) {
aggregationCursor = aggregationCursor.match(
this.buildTokenizedQueryObject(queryParams.filter, this.searchableProperties)
)
}
return aggregationCursor
} else {
let projection: object[] = []
if (queryParams && queryParams.projectionKeyOrList) {
projection = this.convertKeyOrListToObject(queryParams.projectionKeyOrList, 0)
}
return this.getCursor(builtQuery, Object.assign({}, ...projection))
}
}
Readable Code
duluca/lemon-mart/auth/role.enum.ts
export enum Role {
None = 'none',
Clerk = 'clerk',
Cashier = 'cashier',
Manager = 'manager',
}
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
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
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
}
}
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
export 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
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)))
}
Interface Segregation
Doguhan Uluca, Angular for Enterprise-Ready Web Apps, 2020.
duluca/lemon-mart/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
}
export interface IPhone {
type: string
number: string
id: number
}
export class User implements IUser
duluca/lemon-mart-server/models/user.ts
export interface IUser extends IDocument {
email: string
name: IName
picture: string
role: Role
userStatus: boolean
dateOfBirth: Date
level: number
address: {
line1: string
line2?: string
city: string
state: string
zip: string
}
phones?: IPhone[]
}
import * as bcrypt from 'bcryptjs'
import { CollectionFactory, Document, IDocument }
from 'document-ts'
import { AggregationCursor, ObjectID }
from 'mongodb'
import { v4 as uuid } from 'uuid'
duluca/lemon-mart-server/models/user.ts
private setPassword(newPassword: string): Promise<string> {
return new Promise<string>((resolve, reject) => {
bcrypt.genSalt(10, (err, salt) => {
if (err) {
return reject(err)
}
bcrypt.hash(newPassword, salt, (hashError, hash) => {
if (hashError) {
return reject(hashError)
}
resolve(hash)
})
})
})
}
comparePassword(password: string): Promise<boolean> {
const user = this
return new Promise((resolve, reject) => {
bcrypt.compare(password, user.password, (err, isMatch) => {
if (err) {
return reject(err)
}
resolve(isMatch)
})
})
}
public get fullName(): string {
if (this.name.middle) {
return `${this.name.first} ${this.name.middle} ${this.name.last}`
}
return `${this.name.first} ${this.name.last}`
}
What you avoid implementing is more important than what you implement
Source: A wise developer
implement a Login experience
achieved iteratively and incrementally
products$ = this.http.get<Product[]>(this.productsUrl)
.pipe(
tap(data => console.log('getProducts: ', JSON.stringify(data))),
shareReplay(),
catchError(this.handleError)
)
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()
)
We're hiring, including fully remote positions!
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(...)
}
}
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 })
}
…
}
SOLID in action
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)
}
…
}
SOLID in action
Code
Test
Deploy
Publish
repeat
Branch
Merge
.circleci/config.yml
.circleci/config.yml
FROM
jobs
build
deploy
.circleci/config.yml
FROM
build
env
steps
Docker
checkout
run
store build artifacts
store test results
Infrastructure-as-Code closes the Configuration Gap
FROM
SETUP
COPY
CMD
Security
Dependencies
bit.ly/npmScriptsForDocker
Local build
CI-x build
.circleci/config.yml
FROM
jobs
build
deploy
.circleci/config.yml
FROM
deploy
restore
steps
Build artifact
Sign in to container repo
Publish to repo
Trigger deployment
bit.ly/npmScriptsForAWS
npm run aws:fargate-release
heroku container:push web -a lemon-mart
gcloud beta run deploy --image lemon-mart
Google Cloud Run
describe('Converters', () => {
describe('convertCtoF', () => {
it('should convert 0c to 32f', () => {
...
})
})
})
Sample Jasmine Test
Converters convertCtoF should convert 0c to 32f
describe('Converters', () => {
beforeAll(() => { ... })
beforeEach(() => { ... })
describe('convertCtoF', () => {
it('should convert 0c to 32f', () => {
...
})
})
describe('convertFtoC', () => {
it('should convert 32f to 0c', () => {
...
})
})
afterEach(() => { ... })
afterAll(() => { ... })
})
Fixtures
Fixtures are scoped within describe blocks
describe('convertCtoF', () => {
it('should convert 0c to 32f', () => {
// Act
// Arrange
// Assert
fail('message')
expect(expected).toBe(actual)
.toEqual(actual)
.toBeDefined()
.toBeFalsy()
.toThrow(exception)
.nothing()
})
})
Matchers
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')
})
})
Test Doubles
fakes
weatherApi -> wweatherService -> weatherComponent
app.ts
rootRouter
services
pipes
modules
/a: default
/master
/detail
/b/...
/c
childRouter
/d
/e
/f
weatherApi
weatherService
weatherComponent
weatherServiceFake
weatherComponent
weatherApiFake?
weatherService
50 services, 50 fakes?
getCurrentWeather
api.openweathermap.org/data/2.5/weather