Doguhan Uluca PRO
Author of the best-selling Angular for Enterprise-Ready Web Apps. Google Developers Expert in Angular. Agile, JavaScript and Cloud expert, Go player.
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
By Doguhan Uluca
It can be daunting to pick the right stack to deliver your idea to the cloud. Without realizing, you can introduce one too many "sandbag of complexity" between you and something you can release. It is possible to do full-stack development with a consistent set of tools and best practices using the same language, JavaScript. No more to context switching between front-end and back-end languages, libraries, and tools. However, it is important to not to dig yourself into a hole. My easy to learn and use stack 'Minimal MEAN' will get you started and deployed on the cloud over a lazy weekend, without requiring a MongoDB install, while leveraging the latest tools like async/await with Typescript, Angular and Node.
Author of the best-selling Angular for Enterprise-Ready Web Apps. Google Developers Expert in Angular. Agile, JavaScript and Cloud expert, Go player.