Client / Company

NooBee

Category Project

Backend

Industry

Education

Project Time

2023 - 2024

Daftar Isi

Overview

NooBeeID adalah sebuah platform edukasi untuk kamu yang ingin belajar programming. NooBeeID memberikan beberapa format menarik, seperti Artikel, Course, Bootcamp, Mini Course dan Tutorial di youtube-nya. Dengan menggunakan Discord sebagai platform diskusi, NooBee sudah di mempunyai lebih dari 600 pengguna.

Kenapa Perlu di Revamp ?

Ada beberapa point yang menjadi alasan kenapa website ini perlu di revamp, yaitu :

  1. Microservices. Pada versi pertama, website ini dibangun menggunakan microservice sehingga sangat sulit untuk di maintenance. Hal ini juga menyebabkan cost di sisi infra yang lumayan membengkak.
  2. Arsitektur sistem yang belum matang & Overengineering. Sebenarnya tidak ada masalah dengan arsitektur sistem yang versi pertama. Namun karena terlalu microservices, jadinya terbiasa untuk membuat mini apps terpisah yang menyebabkan sulitnya untuk menjaga data berupa transactional.
  3. Struktur folder yang belum modular. Walau sudah microservices, namun struktur directory nya belum modular. Alhasil, jika nantinya akan dilakukan pengembangan, maka developer terpaksa akan mengambil 1 per 1 dari setiap file.

Perbedaan Arsitektur

Arsitektur sistem pada versi pertama adalah Microservices, yang mana pada arsitektur tersebut akan memecah 1 aplikasi menjadi beberapa aplikasi kecil yang saling ter-integrasi satu sama lain. Pada NooBee versi pertama, ada 4 service utama pembentuk aplikasi tersebut, yaitu :

  • Auth Service => untuk nge handle seluruh tentang authentication
  • User Serivce => untuk nge hanldle seluruh hal yang berkaitan dengan data user
  • Course Service => untuk nge handle seluruh hal yang berkaitan dengan course, seperti pembuatan Bootcamp, Online Course, dan Article.
  • Enroll Service => untuk nge handle proses enroll course/bootcamp/article ke user tertentu.

Services tersebut dihubungkan dengan teknik Event Driven sehingga trigger setiap service nya adalah sebuah "Event". Di NooBee, awalnya menggunakan Kafka sebagai Message Bus nya namun karena terkendala budget (maklum masih bootstraping), jadinya di refactor ke Rabbit MQ.

Berikut adalah contoh arsitektur dari versi pertamanya : 

Arsitektur NooBeeID v1
Arsitektur NooBeeID v1

Ini adalah salah satu contoh developer yang kemakan teknik marketingnya "Microservices" ( dan developernya adalah Saya... wkkwkwkw ). Saat dibuat pertama kali pada tahun 2019 yang mana pada tahun tersebut arsitektur "Microservices" sangat sangat menjual di pasaran. Akhirnya saya coba implementasi arsitektur tersebut pada website noobee versi pertama.

Dan akhirnya, saya menyesal telah mengimplementasi microservices tersebut :). Dikarenakan sangat sulit untuk di maintenance jika developernya sangat sedikit (cuma 1 orang btw). Jadi, buat kamu yang ingin nge develop sebuah apps, jangan pernah berfikir "Microservices First" yaa.. Selalu buat dengan arsitektur modular monolith terlebih dahulu. 

Berikut adalah Arsitektur yang saya gunakan pada versi 2.

Arsitektur NooBeeID v2
Arsitektur NooBeeID v2

Pada arsitektur v2, saya menggunakan Modular Monolith, yang mana setiap service itu akan di bundle menjadi sebuah module / domain. Behaviour nya mirip dengan microservices, namun dia ada di dalam 1 apps yang sama. Untuk memudahkan penggunaan Modular Monolith, saya menerapkan Domain Driven Design (DDD). Karena pada DDD, kita sudah melakukan pemisahan tanggung jawab antar domain.

Pada aplikasi yang sekarang, saya membaginya menjadi beberapa domain :

  • Modul Auth & User. Karena ini 1 paket, dan keduanya saling ketergantungan satu sama lainnya.
  • Modul Post. Modul ini berfungsi untuk nge-manage seluruh tentang Postingan, seperti Article, Course, Syllabus, dan lain sebagainya.
  • Modul Enroll. Modul ini berfungsi khusus untuk melakukan enrolling data, seperti user nge enroll course/article.
  • Modul Payment. Modul ini berfungsi untuk yang berhubungan dengan payment, seperti topup, withdraw, dan lain sebagainya.

Setiap domain / modul tidak saling depend. Artinya, akan ada beberapa redundansi function yang dilakukan. Kenapa hal ini dilakukan ? Karena saya sudah bosan dengan konsep "DRY" dan pinginnya sih yg "WET" :)... Wkwkwkwk

Bukan ya, karena saya ingin agar setiap domain itu less coupling terhadap domain lainnya. Semakin banyak domain tersebut depend ke domain yang lainnya, maka aplikasi kita akan semakin sulit untuk di maintenance. 

Seperti pada gambar dibawah, setiap domain tidak berinteraksi pada domain lainnya. 

Design Directory & Layering NooBee v2
Design Directory & Layering NooBee v2

Lalu, bagaimana jika domain A membutuhkan data pada domain B? ya caranya tinggal tulis ulang kodenya pada domain A :)..

Perbandingan Coupling vs Decoupling Dependency

Misalkan, ada 2 domain yaitu User dan Transaction. Pada skenario pertama, kita akan lakukan Domain Transaction depend ke Domain User. Berikut contoh kode nya

1package user
2
3// domain user
4type User struct {
5  Id       int
6  Name     string
7  Balance  decimal.Decimal
8}
9
10func (u User) Validate() (err error){
11  // handle validation of user
12  return
13}
14
15// interface repository
16type readRepository interface {
17  GetAllUser()
18}
19
20type writeRepository interface {
21  CreateUser(ctx context.Context, req User) (err error)
22}
23
24// service user
25func Register(ctx context.Context, user User, repo writeRepository) (err error) {
26  if err = user.Validate(); err != nil {
27    log.Println("[Register, UserValidation] error :",err.Error())
28    return
29  }
30
31  if err = repo.CreateUser(ctx, user); err != nil {
32    log.Println("[Register, CreateUser] error :",err.Error())
33    return
34  }
35
36  return
37}
1package transaction
2
3// domain transaction
4type Transaction struct {
5  From        int // user id
6  To          int // user id
7  Amount      decimal.Decimal
8  CreatedAt   time.Time
9}
10
11func (t Transaction) Validate() (err error) {
12  if t.From == 0 {
13    return ErrorFromUserIsEmpty
14  }
15
16  if t.To == 0 {
17    return ErrorToUserIsEmpty
18  }
19
20  if t.Amount == decimal.NewFromInt(0) {
21    return ErrorAmountIsRequired
22  }
23
24  return nil
25}
26
27// repository
28type writeRepository interface {
29  UpdateSaldo(ctx context.Context, tx *sqlx.Tx, req user.User) error
30  BeginTx(ctx context.Context) (tx *sqlx.Tx)
31  Commit(tx *sqlx.Tx) error
32  Rollback(tx *sqlx.Tx) error
33}
34
35type readRepository interface {
36  GetUserById(ctx context.Context, userId int) (res user.User, err error)
37}
38
39type sendMoneyRepository interface {
40  writeRepository
41  readRepository
42}
43
44// service
45func SendMoney(ctx context.Context, req Transaction, repo sendMoneyRepository) (err error){
46  if err = req.Validate(); err != nil {
47    log.Println("[SendMoney, TransactionValidation] error : ",err.Error())
48    return
49  }
50
51  fromUser, _ := repo.GetUserById(ctx, req.From)
52  toUser, _ := repo.GetUserById(ctx, req.To)
53  
54  // business rules for process change saldo
55  // need to makesure in domain user, already implement this method
56  if err = fromUser.Send(&toUser, req.amount); err != nil {
57    log.Println("[SendMoney, SendMoneyToUser] error : ",err.Error())
58    return
59  }
60  
61  tx := repo.BeginTx(ctx)
62  
63  defer repo.Rollback(tx)
64  
65  // update saldo from user
66  if err = repo.UpdateSaldo(ctx, tx, fromUser); err != nil {
67    log.Println("[SendMoney, UpdateSaldoFromUser] error : ",err.Error())
68    return
69  }
70  
71  // update saldo to user
72  if err = repo.UpdateSaldo(ctx, tx, toUser); err != nil {
73    log.Println("[SendMoney, UpdateSaldoToUser] error : ",err.Error())
74    return
75  }
76  
77  if err = repo.Commit(tx); err != nil {
78    log.Println("[SendMoney, CommitTransaction] error : ",err.Error())
79    return
80  }
81
82  return
83}
84

Bisa dilihat, pada domain Transaction depend ke domain User. Jika kita lihat pada domain User, method Send belum diimplementasikan sehingga akan terjadinya error. Nah jika begitu, kita harus mengupdate domain user menjadi :

1package user
2
3// domain user
4type User struct {
5  Id       int
6  Name     string
7  Balance  decimal.Decimal
8}
9
10func (u User) Validate() (err error){
11  // handle validation of user
12  return
13}
14
15func (u *User) Send(to *User, amount decimal.Decimal) (err error) {
16  if u.Balance < amount {
17    return ErrorInsufficientBalance
18  }
19
20  u.Balance = u.Balance.Sub(amount)
21  to.balance = to.Balance.Add(amount)
22
23  return nil
24}
25

Dengan melakukan update seperti itu, maka kita meng-otak-atik domain lain. Jika hal ini terus terjadi, maka domain domain tersebut akan sangat sulit untuk di maintenance.

***

Nah sekarang kita akan coba mengubah kode pada domain Transaction, agar tidak depend ke domain User.

1package transaction
2
3// domain transaction
4type Transaction struct {
5  From        int // user id
6  To          int // user id
7  Amount      decimal.Decimal
8  CreatedAt   time.Time
9}
10
11type User struct {
12  Id       int
13  Balance  decimal.Decimal
14}
15
16func (u *User) Send(to *User, amount decimal.Decimal) (err error) {
17  if u.Balance < amount {
18    return ErrorInsufficientBalance
19  }
20
21  u.Balance = u.Balance.Sub(amount)
22  to.balance = to.Balance.Add(amount)
23
24  return nil
25}
26
27
28func (t Transaction) Validate() (err error) {
29  if t.From == 0 {
30    return ErrorFromUserIsEmpty
31  }
32
33  if t.To == 0 {
34    return ErrorToUserIsEmpty
35  }
36
37  if t.Amount == decimal.NewFromInt(0) {
38    return ErrorAmountIsRequired
39  }
40
41  return nil
42}
43
44// repository
45type writeRepository interface {
46  UpdateSaldo(ctx context.Context, tx *sqlx.Tx, req User) error
47  BeginTx(ctx context.Context) (tx *sqlx.Tx)
48  Commit(tx *sqlx.Tx) error
49  Rollback(tx *sqlx.Tx) error
50}
51
52type readRepository interface {
53  GetUserById(ctx context.Context, userId int) (res User, err error)
54}
55
56type sendMoneyRepository interface {
57  writeRepository
58  readRepository
59}
60
61// service
62func SendMoney(ctx context.Context, req Transaction, repo sendMoneyRepository) (err error){
63  if err = req.Validate(); err != nil {
64    log.Println("[SendMoney, TransactionValidation] error : ",err.Error())
65    return
66  }
67
68  fromUser, _ := repo.GetUserById(ctx, req.From)
69  toUser, _ := repo.GetUserById(ctx, req.To)
70  
71  // business rules for process change saldo
72  if err = fromUser.Send(&toUser, req.amount); err != nil {
73    log.Println("[SendMoney, SendMoneyToUser] error : ",err.Error())
74    return
75  }
76  
77  tx := repo.BeginTx(ctx)
78  
79  defer repo.Rollback(tx)
80  
81  // update saldo from user
82  if err = repo.UpdateSaldo(ctx, tx, fromUser); err != nil {
83    log.Println("[SendMoney, UpdateSaldoFromUser] error : ",err.Error())
84    return
85  }
86  
87  // update saldo to user
88  if err = repo.UpdateSaldo(ctx, tx, toUser); err != nil {
89    log.Println("[SendMoney, UpdateSaldoToUser] error : ",err.Error())
90    return
91  }
92  
93  if err = repo.Commit(tx); err != nil {
94    log.Println("[SendMoney, CommitTransaction] error : ",err.Error())
95    return
96  }
97
98  return
99}
100

Pada kode diatas, tidak ada dependensi ke domain user secara langsung. Kita melakukan duplikasi dan hanya memanggil attribute/method yang kita butuhkan saja pada domain user. Sehingga, pada domain User tidak perlu ada perubahan yang tidak dibutuhkan.

Tradeoff DRY vs WET

Hal ini tentu akan bikin kaum kaum penganut DRY sejati kesal.. wkwkwkwk tapi dengan begini, setiap domain bisa manage dependency nya sendiri. Akan ada beberapa tradeoff yang terjadi, yaitu :

DRY (Dont Repeat Your Self)

Kelebihan : 1 kode sumber, bisa di pakai dimana saja. Kita tidak perlu membuat banyak kode yang sama. Sehingga ini bisa mempercepat pengerjaan kita

Kekurangan : Terlalu kering/dry, akan menyebabkan sulit untuk di maintenance. Misalnya kita punya 1 kode yang di pakai di 5 atau 6 tempat yang sama. Ternyata, kita ada nge-update behaviour kode tersebut. Sehingga 6 code block yang memanggil kode tadi akan kena impact-nya juga, baik itu expected maupun unexpected

***

WET (Write Everything Twice)

Kelebihan : Jika kode tersebut hanya di pakai di 1 atau 2 tempat, maka tidak perlu menggabungkan kode tersebut menjadi 1. Cukup tulis ulang di tempat tempat yang membutuhkannya. Sehingga jika kita mengubah code di tempat A, maka tempat B tidak akan kena impactnya.

Kekurangan : Bisa jadi redundant code yang terlalu banyak. Dan jika ada update, kita harus tau betul yang mana aja kode yang perlu di ubah.

Techstack

Secara garis besar, proses revampnya hanya pada Design Architecture & Code Architecture saja. Untuk techstack, masih sama yaitu menggunakan :

Tech Stack v1 v2
Programming LanguageGolang (go1.16.0)Golang (go1.21.0)
Library DatabaseDriver Bawaan (database/sql)SQLx
Routing FrameworkFiberFiber

Infrastructure

Untuk infrastructure nya sendiri, ada 2 server yang digunakan yaitu server development dan server production. Berikut adalah full arsitektur design system dari NooBeeID v2

Design System Architecture NooBeeID v2
Design System Architecture NooBeeID v2
Name Technology Notes
WebserverNginxUntuk Proxy + API Gateway + Load Balancing
ContainerizationDocker
OrchestrationDocker Compose
CI/CDGitlab CI
Analytics UserPlausible
DatabasePostgreSQL
Business TrackingMetabase
Backend MonitoringNewrelic
Payment GatewayXendit
***

Masih banyak hal hal lain yang saya kerjakan disini, namun point point diatas adalah yang paling menarik yang bisa saya share ke kalian. Mungkin nantinya bakal bertambah seiring berjalannya waktu. Jika ada yang ingin ditanyakan, feel free to DM yaa