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 :
- 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.
- 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.
- 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 :
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.
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.
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}
84Bisa 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}
25Dengan 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}
100Pada 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 Language | Golang (go1.16.0) | Golang (go1.21.0) |
| Library Database | Driver Bawaan (database/sql) | SQLx |
| Routing Framework | Fiber | Fiber |
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
| Name | Technology | Notes |
|---|---|---|
| Webserver | Nginx | Untuk Proxy + API Gateway + Load Balancing |
| Containerization | Docker | |
| Orchestration | Docker Compose | |
| CI/CD | Gitlab CI | |
| Analytics User | Plausible | |
| Database | PostgreSQL | |
| Business Tracking | Metabase | |
| Backend Monitoring | Newrelic | |
| Payment Gateway | Xendit |
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
- Email : [email protected]
- Linkedin : reyhanjovie