มาลองเขียน Angular ด้วย Clean Architecture กันเถอะ!

tana
Gofive
Published in
4 min readJun 13, 2024

เมื่อไม่กี่เดือนก่อนผมได้มีโอกาสที่ได้อัปเกรดโปรเจ็ค Angular จากเดิมเวอร์ชั่น 15 ให้ไปเป็น 17 ซึ่งก็เป็น 2 major เวอร์ชั่นด้วยกัน จึงเป็นโอกาสอันดี ไหน ๆ ก็ไหน ๆ แล้วพี่เดย์ Decha Kenthaworn ก็เลยให้ผมลองเขียน Angular แบบ Clean Architecture ไปด้วยซะเลย

Clean Architecture คืออะไร?

Clean Architecture เป็น Architecture ที่ถูกพัฒนาขึ้นโดย Robert C. Martin โดยเกิดขึ้นจากการนำหลักการสำคัญต่าง ๆ ของ Architecture อยู่แล้วไม่ว่าจะเป็น Hexagonal Architecture, Onion Architecture or Screaming Architecture และอื่น ๆ มารวมกัน โดยจะได้หัวข้อต่าง ๆ ดังนี้

  • Independent of Frameworks: ตัว Architecture จะไม่ยึดติดกับ frameworks หรือ libraries
  • Testable: ตัว business logic ควรที่จะเทสได้ โดยที่ไม่จำเป็นต้องไปยุ่งกับ UI, database หรือ API
  • Independent of UI: เนื่องจากถ้าเราแยก business logic ออกมาแล้ว เมื่อมีการเปลี่ยนแปลงของ UI เกิดขึ้น มันควรที่จะไม่ไปกระทบกับ business logic จนพัง
  • Independent of Database: เมื่อเกิดการเปลี่ยนแปลงในส่วนของ database เช่นเรากำลังใช้ Oracle อยู่ แล้วเราต้องการที่จะเปลี่ยนไปใช้ SQL Server เราควรจะเปลี่ยนได้โดยไม่มีปัญหา
  • Independent of any external agency: ตัว business rules ของเราจะต้องขึ้นอยู่กับตัวเองเท่านั้น

The Dependency Rule

จากที่เห็นจากรูป Clean Architecture มีการแบ่งองค์ประกอบใหญ่ๆออกเป็น 4 ชั้น โดยชั้นที่อยู่ด้านในจะต้องไม่รู้ และไม่เรียกใช้งานชั้นด้านนอก และชั้นด้านนอกก็เรียกใช้ได้แค่ชั้นก่อนหน้าเท่านั้นเพื่อให้แต่ละชั้นทำงานในส่วนของตัวเองไปเลย หรือเรียกอีกอย่างว่า separation of concern นั่นเอง โดยทั้ง 4 ชั้นจะแบ่งออกเป็นดังนี้

  • Entities: จะเป็นการกำหนด core business objects ของ application โดยส่วนใหญ่แล้วจะเป็นการเก็บ plain objects เพื่อแสดงถึงโครงสร้างของข้อมูลว่าจะมีอะไรบ้างเช่น users, products, orders
  • Use Cases: จะเป็นการกำหนด business logic ของ application ไว้
  • Interface Adapters: จะเป็น layer ที่ทำการ convert ข้อมูลที่รับเข้ามาให้ไปใช้ต่อได้ง่าย
  • Frameworks and Drivers: จะเป็นส่วนที่เอาไว้จัดการพวก framework, third-party libraries ของ project

มาเริ่มกันเถอะ

Folder Structure

Core Layer

  • Core Entities: ตอนนี้ก็จะมี AnimalModel ที่เก็บข้อมูลที่ต้องใช้ ซึ่งตอนนี้ก็จะประกอบไปด้วยชื่อ และอายุ
export interface AnimalModel {
name: string;
age: number;
}
  • Mapper Interface: ตัว interface ตัวนี้ก็จะคอยทำการ mapping ของเรา โดยจะมีอยู่ 2 function นั่นก็คือ mapFrom และ mapTo
export abstract class Mapper<I, O> {
abstract mapFrom(param: I): O;
abstract mapTo(param: O): I;
}
  • Use Case Interface: ตัว use case จะมีอยู่ function เดียว ซึ่งก็คือ execute โดยจะทำการ return observable ออกมา
import { Observable } from 'rxjs';
export interface UseCase<S, T> {
execute(params: S): Observable<T>;
}
  • Repository Base: repository ที่ใช้ในที่นี้ไม่ได้เชื่อมต่อกับ database แต่เป็นการดึงข้อมูลจาก API และในการดึงข้อมูลส่วนใหญ่ก็จะเป็น CRUD operations ดังนั้นเราเลยใช้ repository เป็นตัวแทน โดย AnimalRepository ก็จะมี abstract ที่เอาไว้ดึงข้อมูลมา
import { Observable } from 'rxjs';
import { AnimalModel } from '../models/animal.model';

export abstract class AnimalRepository {
abstract getAnimals(): Observable<AnimalModel[]>;
abstract getAnimalByName(name: string): Observable<AnimalModel>;
}
  • Use Case: ในการสร้าง use case เราก็ต้องทำการ implements ตัว base interface ที่เราทำไว้ แล้วก็ไปเรียกใช้ repository ตาม dependency rule
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { UseCase } from '../../../core/base/use-case';
import { AnimalModel } from '../../models/animal.model';
import { AnimalRepository } from '../../repositories/animal.repositoy';

@Injectable({
providedIn: 'root'
})
export class GetAnimalByNameUseCase implements UseCase<string, AnimalModel> {

constructor(private animalRepository: AnimalRepository) { }

execute(param: string): Observable<AnimalModel> {
return this.animalRepository.getAnimalByName(param);
}
}

Data Layer

ใน layer นี้เราจะมีตัว repository ที่เรา implement, ตัว entity ที่ repository ต้องใช้ในการรับข้อมูลมาจาก API และตัว mapper ที่ map ข้อมูลจาก data layer ไป core layer

AnimalImplementationRepository

import { Injectable } from '@angular/core';
import { Observable, from, map, mergeMap, toArray } from 'rxjs';
import { AnimalImplementationRepositoryMapper } from './animal.mapper';
import { AnimalEntity } from './animal.entity';
import { AnimalModel } from '../../../core/models/animal.model';
import { AnimalRepository } from '../../../core/repositories/animal.repositoy';
import { HttpClient } from '@angular/common/http';

@Injectable({
providedIn: 'root',
})
export class AnimalImplementationRepository extends AnimalRepository {
private mapper = new AnimalImplementationRepositoryMapper();

constructor(private http: HttpClient) {
super();
}

getAnimals(): Observable<AnimalModel[]> {
return this.http
.get<AnimalEntity[]>(
'https://6653b5ca1c6af63f4675625d.mockapi.io/api/v1/animals'
)
.pipe(
mergeMap((animals: AnimalEntity[]) => from(animals)),
map(this.mapper.mapFrom),
toArray()
);
}
getAnimalByName(name: string): Observable<AnimalModel> {
return this.http
.get<AnimalEntity>(
`https://6653b5ca1c6af63f4675625d.mockapi.io/api/v1/animals/${name}`
)
.pipe(map(this.mapper.mapFrom));
}
}

Entity

export interface AnimalEntity {
id: string;
name: string;
age: number;
}

Mapper

import { Mapper } from "../../../core/base/mapper";
import { AnimalModel } from "../../../core/models/animal.model";
import { AnimalEntity } from "./animal.entity";

export class AnimalImplementationRepositoryMapper extends Mapper <AnimalEntity, AnimalModel> {
mapFrom(param: AnimalEntity): AnimalModel {
return {
name: param.name,
age: param.age,
};
}

mapTo(param: AnimalModel): AnimalEntity {
return {
id: "0",
name: param.name,
age: param.age,
};
}
}

Presentation Layer

สิ่งที่เราต้องทำที่เหลือก็แค่นำข้อมูลที่เราได้มาแสดงผลบนหน้าเว็บโดยตัวอย่างที่ผมทำก็จะมีดังนี้

import { Component, OnDestroy, OnInit } from '@angular/core';
import { AnimalModel } from '../../core/models/animal.model';
import { GetAllAnimalsUseCase } from '../../core/usecases/animal/get-animals.usecase';
import { ReplaySubject, takeUntil } from 'rxjs';

@Component({
selector: 'app-animal-list',
standalone: true,
imports: [],
providers: [GetAllAnimalsUseCase],
templateUrl: './animal-list.component.html',
styleUrl: './animal-list.component.scss'
})
export class AnimalListComponent implements OnInit, OnDestroy {
constructor(private getAllAnimal: GetAllAnimalsUseCase) { }

animals: AnimalModel[] = [];
private destroyed$: ReplaySubject<boolean> = new ReplaySubject(1);

ngOnInit() {
this.getAllAnimal.execute().pipe(takeUntil(this.destroyed$)).subscribe((data) => {
this.animals = data;
});
}

ngOnDestroy() {
this.destroyed$.next(true);
this.destroyed$.complete();
}
}
<div>
@for (animal of animals; track $index) {
<div>
<span>{{ animal.name }}, </span>
<span>{{ animal.age }}</span>
</div>
}
</div>

Conclusion

ในบทความฉบับนี้ ผมได้อธิบายถึงการนำหลัก Clean Architecture มาประยุกต์ใช้ในการพัฒนาแอปพลิเคชันแบบ Angular ซึ่งการเขียนโปรเจ็ค Angular ด้วย Clean Architecture ไม่เพียงแต่ทำให้โค้ดของเรา clean และเป็นระบบมากขึ้นเท่านั้น แต่ยังช่วยเพิ่มความสามารถในการทดสอบและความยืดหยุ่นของ application อีกด้วย โดยแบ่งแยกส่วนต่างๆ ของโปรเจ็คอย่างชัดเจนด้วย layer ต่าง ๆ ซึ่งแต่ละส่วนก็มีหน้าที่ของตัวเอง ทำให้เมื่อมีการเปลี่ยนแปลงใดๆ เช่นการเปลี่ยนแปลง UI หรือฐานข้อมูล เราสามารถทำได้โดยไม่กระทบกับส่วนอื่น ๆ ของระบบ

นอกจากนี้ การนำ Clean Architecture มาใช้ยังช่วยให้เราสามารถเพิ่มหรือลดฟีเจอร์ต่างๆ ได้ง่ายขึ้น เนื่องจากแต่ละ layer ของโค้ดมีความเป็นอิสระต่อกัน โดยในรูปด้านล่างจะแสดงถึงผลลัพธ์ที่ได้จากการใช้ Clean Architecture พร้อมกับ refactor โค้ดชุดเก่าซึ่งผลจาก Sonar Cloud จะเห็นว่าได้เกรด A ทั้ง 4 ด้านโดยเฉพาะ Code Bugs, Code Smells รวมไปถึงจำนวนโค้ดที่ลดลงเพื่อให้การ Maintain ระบบได้ดีมากยิ่งขึ้น

References

Clean Coder Blog

Approach to Clean Architecture in Angular Applications — Theory | by Coffee & Cloud ☕️☁️ | Medium

Source Code

GofiveCorp/angular-clean-architecture (github.com)

--

--