BeeTool.Dev - Essential Tools for developers

Stories.Tool Tabs VaultPersonal Documents
BeeTool.Dev

Essential tools, knowledge, and playgrounds for developers.

Explore

  • Stories
  • Personal Documents
  • Features
  • Beetool for Mac
  • Algorithms Playground

Tool Tabs

  • JSON Compare
  • SQL Formatter
  • Text Diff
  • All tools →

Learn

  • Sorting algorithms
  • Searching algorithms
  • Algorithm Race Mode
© 2024 – 2026 BeeTool.Dev
AboutContactPrivacy policyTerms
All stories
GolangDesign PrincipleDesign Pattern

Loose Coupling trong Go

DUKE407 viewsJuly 20, 2026
Loose Coupling trong Go

Có một yêu cầu nghe rất đơn giản:

“Đổi SendGrid sang AWS SES giúp anh nhé.”

Thoạt nghe, đây có vẻ chỉ là chuyện thay một thư viện gửi email. Nhưng khi mở code ra, bạn phát hiện SendGridMailer đang được khởi tạo trực tiếp trong service đăng ký, service quên mật khẩu, service đặt hàng và cả worker gửi báo cáo.

Bạn sửa hết những chỗ đó, chạy test rồi bắt đầu thấy không yên tâm: một vài test đang sử dụng client thật. Test checkout có thể gọi payment gateway thật, gửi email thật hoặc phụ thuộc vào hàng loạt biến môi trường.

Đây là một biểu hiện quen thuộc của tight coupling: các thành phần trong hệ thống phụ thuộc quá chặt vào implementation cụ thể.

Trong bài này, chúng ta sẽ lần lượt gỡ các dependency đó ra bằng:

  • Interface
  • Dependency Inversion Principle
  • Dependency Injection
  • Manual wiring
  • IoC Container với Uber Fx

Mục tiêu cuối cùng không phải là biến mọi struct thành interface, mà là làm sao để business logic không phải thay đổi mỗi khi infrastructure thay đổi.

Phạm vi của coupling rất rộng. Một hệ thống có thể bị coupling bởi database, message broker, protocol, schema hoặc cách chia service. Bài này chỉ tập trung vào dependency giữa các component bên trong một ứng dụng Go.

Tight coupling trông như thế nào?

Giả sử chúng ta có một service xử lý checkout. Nó cần charge tiền và gửi email xác nhận:

package order

import (
"context"
"fmt"
"os"
)

type OrderService struct {
payment *StripePayment
mailer *SendGridMailer
}

func NewOrderService() *OrderService {
return &OrderService{
payment: NewStripePayment(os.Getenv("STRIPE_KEY")),
mailer: NewSendGridMailer(os.Getenv("SENDGRID_KEY")),
}
}

func (s *OrderService) Checkout(
ctx context.Context,
orderID string,
amount int64,
email string,
) error {
if err := s.payment.Charge(ctx, amount); err != nil {
return err
}

body := fmt.Sprintf("Đơn hàng %s đã được thanh toán", orderID)
return s.mailer.Send(ctx, email, body)
}

Nhìn qua thì đoạn code này không có gì quá đáng ngờ. Service có đủ dependency và có thể hoạt động ngay sau khi được khởi tạo.

Vấn đề nằm ở chỗ OrderService đang tự quyết định:

  • Dùng Stripe làm payment gateway.
  • Dùng SendGrid để gửi email.
  • Lấy API key từ đâu.
  • Khởi tạo các client như thế nào.

Trong khi đó, trách nhiệm chính của nó chỉ nên là xử lý nghiệp vụ checkout.

Việc tự khởi tạo dependency khiến StripePayment và SendGridMailer trở thành hard dependency của OrderService.

Hậu quả đầu tiên: thay infrastructure phải sửa business logic

Muốn đổi Stripe sang Paypal, bạn phải mở OrderService và thay constructor.

Muốn đổi SendGrid sang SES, bạn cũng phải sửa OrderService.

Business logic đặt hàng đang bị kéo vào những quyết định thuộc tầng infrastructure.

Hậu quả thứ hai: unit test khó kiểm soát

OrderService tự tạo Stripe và SendGrid nên test không có cách thuận tiện để đưa fake implementation vào.

Bạn có thể thêm flag, monkey patch biến global hoặc dựng một đống environment variable dành riêng cho test. Nhưng càng làm như vậy, test càng khó đọc và càng dễ phụ thuộc vào môi trường bên ngoài.

Package-level singleton cũng không giải quyết được vấn đề

Một cách khác thường gặp là đưa dependency ra biến global:

package mailer

import "os"

var DefaultMailer = NewSendGridMailer(os.Getenv("SENDGRID_KEY"))

Sau đó sử dụng ở nơi khác:

package order

import "myapp/mailer"

func (s *OrderService) Checkout(...) error {
return mailer.DefaultMailer.Send(...)
}

Lúc này OrderService không còn trực tiếp gọi NewSendGridMailer, nhưng dependency vẫn bị cố định trong global state.

Test muốn thay mailer phải sửa mailer.DefaultMailer, sau đó nhớ khôi phục lại giá trị cũ. Nếu nhiều test chạy song song, chúng có thể ảnh hưởng lẫn nhau và tạo ra những lỗi rất khó tái hiện.

Nói cách khác, việc chuyển dependency từ constructor sang biến global chỉ đổi vị trí của coupling, chứ chưa loại bỏ nó.

Bước chuyển tiếp: Inversion of Control với Factory Pattern

IoC (Inversion of Control) là một design principle với ý tưởng: class không nên tự điều khiển việc tạo ra dependency của mình — hãy đảo quyền điều khiển đó ra bên ngoài.

Hình dung thế này: đầu bếp nhà hàng không tự trồng rau, tự nuôi bò. Nguyên liệu được bộ phận thu mua giao tới tận bếp, đầu bếp chỉ tập trung nấu. Nếu bắt đầu bếp kiêm luôn trồng trọt chăn nuôi thì mỗi lần đổi nhà cung cấp thịt là phải... đào tạo lại đầu bếp. Nghe vô lý đúng không? Vậy mà OrderService tự new StripePayment(...) chính là ông đầu bếp tự nuôi bò đấy.

Một cải tiến đơn giản là tách phần construction ra khỏi service:

package payment

import "os"

func NewDefaultPayment() *StripePayment {
return NewStripePayment(os.Getenv("STRIPE_KEY"))
}

OrderService sử dụng factory này:

type OrderService struct {
payment *payment.StripePayment
}

func NewOrderService() *OrderService {
return &OrderService{
payment: payment.NewDefaultPayment(),
}
}

Cách này giúp gom logic đọc config và khởi tạo client về một nơi. Nếu cách dựng Stripe thay đổi, chúng ta chỉ cần sửa factory.

Đây là một bước tiến, nhưng chưa phải loose coupling.

OrderService vẫn phụ thuộc vào:

  • Concrete type *payment.StripePayment.
  • Package payment.
  • Hàm payment.NewDefaultPayment.

Đổi Stripe sang Paypal vẫn có thể khiến OrderService phải thay đổi. Factory giúp tập trung hóa quá trình khởi tạo, nhưng chưa giải quyết sự phụ thuộc vào implementation cụ thể. Để làm được điều đó, chúng ta cần Dependency Inversion Principle.

Nào bây giờ chúng ta cùng nhau refactor code trên để đạt được loose coupling nhé:

Đầu tiên: Sử dụng Dependency Inversion Principle

Dependency Inversion Principle, thường được gọi tắt là DIP, là chữ D trong SOLID.

Nguyên tắc này có hai ý chính:

  • Module cấp cao không nên phụ thuộc trực tiếp vào module cấp thấp. Cả hai nên phụ thuộc vào abstraction.
  • Abstraction không nên phụ thuộc vào implementation. Implementation phải tuân theo abstraction.

Trong ví dụ hiện tại:

  • OrderService là module cấp cao vì nó chứa business logic.
  • StripePayment và SendGridMailer là các implementation thuộc infrastructure.

OrderService không thực sự cần biết Stripe hay SendGrid là gì. Nó chỉ cần hai khả năng:

  • Charge được một khoản tiền.
  • Gửi được một email.

Những khả năng này có thể được mô tả bằng các interface nhỏ:

package order

import "context"

type PaymentGateway interface {
Charge(ctx context.Context, amount int64) error
}

type Mailer interface {
Send(ctx context.Context, to string, body string) error
}

Implementation Stripe:

package payment

import "context"

type StripePayment struct {
apiKey string
}

func NewStripePayment(apiKey string) *StripePayment {
return &StripePayment{
apiKey: apiKey,
}
}

func (p *StripePayment) Charge(
ctx context.Context,
amount int64,
) error {
// Gọi Stripe API.
return nil
}

Implementation Paypal:

package payment

import "context"

type PaypalPayment struct {
partnerCode string
}

func NewPaypalPayment(partnerCode string) *PaypalPayment {
return &PaypalPayment{
partnerCode: partnerCode,
}
}

func (p *PaypalPayment) Charge(
ctx context.Context,
amount int64,
) error {
// Gọi Paypal API.
return nil
}

Go sử dụng implicit interface implementation. StripePayment và PaypalPayment không cần khai báo rằng chúng implements PaymentGateway.

Chỉ cần có đúng method:

Charge(context.Context, int64) error

thì chúng đã thỏa interface.

Bây giờ OrderService có thể phụ thuộc vào abstraction:

type OrderService struct {
payment PaymentGateway
mailer Mailer
}

Chiều phụ thuộc đã thay đổi:

Trước:
OrderService ───────> StripePayment
OrderService ───────> SendGridMailer
Sau:

OrderService ───────> PaymentGateway <────── StripePayment
^───────── PaypalPayment

OrderService ───────> Mailer <────────────── SendGridMailer
^───────────── SESMailer

OrderService không còn phụ thuộc trực tiếp vào Stripe hoặc SendGrid. Các implementation cụ thể phải tuân theo contract mà OrderService cần.

Tuy nhiên, chỉ đổi field sang interface vẫn chưa đủ.

Nếu constructor tiếp tục tự gọi factory:

func NewOrderService() *OrderService {
return &OrderService{
payment: payment.NewDefaultPayment(),
}
}

thì service vẫn chủ động đi tìm dependency của mình.

Factory đổi tên, đổi package hoặc đổi tham số thì OrderService vẫn bị ảnh hưởng.

Chúng ta cần lật nốt quyền kiểm soát này ra bên ngoài.

Bước 2: Dùng Dependency Injection: đừng tự đi lấy dependency

Ý tưởng của Dependency Injection, hay DI, khá đơn giản:

Một component không tự tạo hoặc tự tìm dependency. Dependency được truyền từ bên ngoài vào.

Trong Go, cách phổ biến nhất là constructor injection:

package order

import (
"context"
"fmt"
)

type PaymentGateway interface {
Charge(ctx context.Context, amount int64) error
}

type Mailer interface {
Send(ctx context.Context, to string, body string) error
}

type OrderService struct {
payment PaymentGateway
mailer Mailer
}

func NewOrderService(
payment PaymentGateway,
mailer Mailer,
) *OrderService {
return &OrderService{
payment: payment,
mailer: mailer,
}
}

func (s *OrderService) Checkout(
ctx context.Context,
orderID string,
amount int64,
email string,
) error {
if err := s.payment.Charge(ctx, amount); err != nil {
return err
}

body := fmt.Sprintf("Đơn hàng %s đã được thanh toán", orderID)
return s.mailer.Send(ctx, email, body)
}

Ở phiên bản này, OrderService không còn:

  • Import package Stripe.
  • Import package SendGrid.
  • Đọc environment variable.
  • Gọi factory.
  • Quyết định implementation nào được sử dụng.

Nó chỉ khai báo dependency mà nó cần thông qua constructor.

Việc lựa chọn Stripe, Paypal, SendGrid hay SES trở thành trách nhiệm của lớp ngoài cùng, thường được gọi là composition root hoặc wiring layer.

Unit test trở nên đơn giản hơn

Vì dependency được truyền từ bên ngoài vào, test có thể cung cấp fake implementation:

package order_test

import (
"context"
"testing"

"myapp/order"
)

type fakePayment struct {
chargedAmount int64
}

func (f *fakePayment) Charge(
ctx context.Context,
amount int64,
) error {
f.chargedAmount = amount
return nil
}

type fakeMailer struct {
to string
body string
}

func (f *fakeMailer) Send(
ctx context.Context,
to string,
body string,
) error {
f.to = to
f.body = body
return nil
}

func TestCheckoutSendsConfirmationEmail(t *testing.T) {
payment := &fakePayment{}
mailer := &fakeMailer{}

service := order.NewOrderService(payment, mailer)

err := service.Checkout(
context.Background(),
"ORD-1",
500_000,
"[email protected]",
)
if err != nil {
t.Fatal(err)
}

if payment.chargedAmount != 500_000 {
t.Fatalf(
"charged amount = %d, want %d",
payment.chargedAmount,
500_000,
)
}

if mailer.to != "[email protected]" {
t.Fatalf(
"email recipient = %q, want %q",
mailer.to,
"[email protected]",
)
}
}

Test này:

  • Không cần Stripe API key.
  • Không cần kết nối Internet.
  • Không gửi email thật.
  • Không charge tiền thật.
  • Không sửa global state.
  • Không phụ thuộc vào thứ tự chạy của các test khác.

Đây là một trong những lợi ích thực tế nhất của DI: business logic có thể được test độc lập khỏi infrastructure.

DIP và DI giải quyết hai vấn đề khác nhau

Hai khái niệm này thường đi cùng nhau nên khá dễ bị nhầm.

DIP trả lời câu hỏi:

Code nên phụ thuộc vào thứ gì?

Câu trả lời là abstraction, chẳng hạn như PaymentGateway hoặc Mailer.

DI trả lời câu hỏi:

Dependency được đưa vào component như thế nào?

Câu trả lời là truyền từ bên ngoài vào, thường qua constructor.

Có DI nhưng không có DIP:

func NewOrderService(
payment *StripePayment,
mailer *SendGridMailer,
) *OrderService

Dependency đã được truyền từ ngoài vào, nhưng service vẫn phụ thuộc vào concrete type.

Có DIP nhưng chưa áp dụng DI đầy đủ:

type OrderService struct {
payment PaymentGateway
}

func NewOrderService() *OrderService {
return &OrderService{
payment: payment.NewDefaultPayment(),
}
}

Field đã sử dụng interface, nhưng service vẫn tự tìm implementation.

Khi kết hợp cả DIP và DI, business logic vừa phụ thuộc vào abstraction, vừa không tự kiểm soát việc tạo dependency.

Không cần container vẫn có thể dùng DI

Sau khi chuyển sang constructor injection, một câu hỏi xuất hiện:

Nếu service không tự tạo dependency, vậy ai sẽ tạo?

Với ứng dụng nhỏ, câu trả lời thường là main.go:

func main() {
cfg := LoadConfig()

paymentGateway := payment.NewStripePayment(cfg.StripeKey)
mailer := sendgrid.NewMailer(cfg.SendGridKey)

orderService := order.NewOrderService(
paymentGateway,
mailer,
)

server := httpapi.NewServer(orderService)

if err := server.ListenAndServe(); err != nil {
panic(err)
}
}

Đây được gọi là manual dependency injection hoặc manual wiring.

Không có framework đặc biệt nào ở đây. Chúng ta chỉ khởi tạo object từ ngoài vào trong:

Config
├── StripePayment
├── SendGridMailer
│
└── OrderService
│
└── HTTP Server

Cách này có nhiều ưu điểm:

  • Dễ đọc.
  • Dễ debug.
  • Không có magic.
  • Compiler kiểm tra type.
  • Không phụ thuộc vào DI framework.

Với ứng dụng nhỏ hoặc dependency graph chưa phức tạp, manual wiring thường là lựa chọn tốt nhất.

Vấn đề mới : Khi main.go bắt đầu trở thành một bó dây điện

Vấn đề xuất hiện khi hệ thống lớn dần:

func main() {
cfg := LoadConfig()

db := postgres.Open(cfg.PostgresDSN)
redisClient := redis.Open(cfg.RedisURL)

productRepo := product.NewPostgresRepository(db)
orderRepo := order.NewPostgresRepository(db)
addressRepo := address.NewPostgresRepository(db)

cache := cache.NewRedisCache(redisClient)

inventoryService := inventory.NewService(
productRepo,
cache,
)

shippingClient := ghn.NewClient(cfg.GHNToken)

shippingService := shipping.NewService(
shippingClient,
addressRepo,
)

paymentGateway := payment.NewStripePayment(
cfg.StripeKey,
)

mailer := sendgrid.NewMailer(
cfg.SendGridKey,
)

orderService := order.NewOrderService(
paymentGateway,
mailer,
inventoryService,
shippingService,
orderRepo,
)

server := httpapi.NewServer(
cfg,
orderService,
)

server.ListenAndServe()
}

Đoạn code này vẫn đúng về mặt thiết kế. Nhưng càng nhiều module, phần wiring càng dài.

Một vài vấn đề thường gặp:

  • Constructor có quá nhiều dependency.
  • Thứ tự khởi tạo khó theo dõi.
  • Resource cần đóng đúng lúc.
  • Vô tình tạo nhiều instance của cùng một dependency.
  • Một thay đổi nhỏ khiến phải sửa nhiều đoạn wiring.
  • main.go chứa hàng trăm dòng chỉ để nối object.

Đây là lúc một IoC Container như Uber Fx có thể hữu ích.

Bước 3: Cỗ máy IoC Container

Trước khi đi vào Fx, cần tách rõ ba khái niệm này.

Inversion of Control

IoC là một design principle, có 1 số người bảo là design pattern nhưng không phải nhé.

Thay vì để component tự quyết định cách tạo, tìm và quản lý dependency, quyền kiểm soát đó được chuyển ra bên ngoài.

Trong ví dụ của chúng ta, OrderService không còn tự tạo StripePayment. Một lớp bên ngoài sẽ quyết định cung cấp payment gateway nào.

Dependency Injection

DI là một cách phổ biến để hiện thực hóa IoC.

Dependency được đưa vào component thông qua:

  • Constructor.
  • Method parameter.
  • Field injection.

Trong Go, constructor injection thường rõ ràng và an toàn nhất.

IoC Container

IoC Container, còn gọi là DI Container, là công cụ tự động hóa quá trình wiring.

Bạn cung cấp cho container:

  • Danh sách constructor.
  • Mapping giữa interface và implementation.
  • Các hàm cần được chạy khi ứng dụng khởi động.
  • Lifecycle hook khi start và stop.

Container sẽ:

  • Dựng dependency graph.
  • Xác định thứ tự gọi constructor.
  • Truyền dependency vào đúng vị trí.
  • Báo lỗi nếu thiếu dependency.
  • Quản lý lifecycle của ứng dụng.

Trong hệ sinh thái Go, một lựa chọn phổ biến cho công việc này là Uber Fx.

Bộ ba này là câu hỏi phỏng vấn kinh điển, và ứng viên trượt nhiều nhất ở đúng một chỗ: cái nào là principle, cái nào là pattern. Phân biệt nhanh thế này:

  • Principle (nguyên lý) = triết lý, chỉ nói nên làm gì, không nói làm như thế nào. Giống như lời khuyên "đừng bỏ hết trứng vào một giỏ".
  • Pattern (mẫu thiết kế) = cách làm cụ thể, có hình hài trong code. Giống như "chia tiền vào 3 ngân hàng khác nhau" — một cách hiện thực hóa lời khuyên trên.

Áp vào bộ ba:


Là gì?

Trả lời một câu

IoC

Design principle

"Class đừng tự điều khiển việc tạo dependency của mình, hãy đảo quyền đó ra bên ngoài."

DIP

Principle (chữ D trong SOLID)

"Phụ thuộc vào abstraction (interface), đừng phụ thuộc vào class cụ thể."

DI

Design pattern

"Cách hiện thực hóa IoC: tiêm dependency từ ngoài vào qua constructor/method/property."

IoC Container

Công cụ (framework/library)

"Cái máy tự động hóa DI: tự tạo object và tự tiêm theo cấu hình."

Lần sau, nếu ai hỏi ba khái niệm về IoC, DI, IoC Container đừng bảo chúng là 1 nhé !

Uber Fx hoạt động như thế nào?

Fx không yêu cầu domain của bạn kế thừa class đặc biệt hay sử dụng decorator.

Bạn vẫn viết constructor Go bình thường:

func NewService(
payment PaymentGateway,
mailer Mailer,
) *Service

Sau đó đăng ký constructor với fx.Provide.

Điểm quan trọng là domain không cần biết Fx tồn tại.

Domain không phụ thuộc vào Fx

Package order chỉ chứa contract và business logic:

package order

import (
"context"
"fmt"
)

type PaymentGateway interface {
Charge(ctx context.Context, amount int64) error
}

type Mailer interface {
Send(ctx context.Context, to string, body string) error
}

type Service struct {
payment PaymentGateway
mailer Mailer
}

func NewService(
payment PaymentGateway,
mailer Mailer,
) *Service {
return &Service{
payment: payment,
mailer: mailer,
}
}

func (s *Service) Checkout(
ctx context.Context,
orderID string,
amount int64,
email string,
) error {
if err := s.payment.Charge(ctx, amount); err != nil {
return err
}

body := fmt.Sprintf(
"Đơn hàng %s đã được thanh toán",
orderID,
)

return s.mailer.Send(ctx, email, body)
}

Package này không import:

go.uber.org/fx

Đây là một ranh giới nên được giữ. Fx thuộc composition layer, không nên tràn vào domain chỉ vì ứng dụng đang dùng DI Container.

Các implementation cụ thể

Stripe payment gateway:

package payment

import (
"context"

"myapp/config"
)

type StripePayment struct {
apiKey string
}

func NewStripePayment(
cfg config.Config,
) *StripePayment {
return &StripePayment{
apiKey: cfg.StripeKey,
}
}

func (p *StripePayment) Charge(
ctx context.Context,
amount int64,
) error {
// Gọi Stripe API.
return nil
}

SendGrid mailer:

package sendgrid

import (
"context"

"myapp/config"
)

type Mailer struct {
apiKey string
}

func NewMailer(cfg config.Config) *Mailer {
return &Mailer{
apiKey: cfg.SendGridKey,
}
}

func (m *Mailer) Send(
ctx context.Context,
to string,
body string,
) error {
// Gọi SendGrid API.
return nil
}

Config:

package config

import "os"

type Config struct {
StripeKey string
SendGridKey string
HTTPAddr string
}

func NewConfig() Config {
return Config{
StripeKey: os.Getenv("STRIPE_KEY"),
SendGridKey: os.Getenv("SENDGRID_KEY"),
HTTPAddr: ":8080",
}
}

Nối dependency bằng Fx

Trong main.go, chúng ta đăng ký các constructor:

package main

import (
"myapp/config"
"myapp/httpapi"
"myapp/order"
"myapp/payment"
"myapp/sendgrid"

"go.uber.org/fx"
)

func main() {
app := fx.New(
fx.Provide(
config.NewConfig,

fx.Annotate(
payment.NewStripePayment,
fx.As(new(order.PaymentGateway)),
),

fx.Annotate(
sendgrid.NewMailer,
fx.As(new(order.Mailer)),
),

order.NewService,
httpapi.NewServer,
),

fx.Invoke(func(*httpapi.Server) {}),
)

app.Run()
}

Fx sẽ nhìn vào các constructor và dựng dependency graph:

config.NewConfig
│
├── payment.NewStripePayment
│ │
│ └── order.PaymentGateway
│
├── sendgrid.NewMailer
│ │
│ └── order.Mailer
│
└── httpapi.NewServer

order.PaymentGateway + order.Mailer
│
└── order.NewService
│
└── httpapi.NewServer

Fx tự xác định constructor nào cần được gọi trước. Chúng ta không cần tự sắp xếp thứ tự trong main.go.

Tại sao cần fx.As?

Đoạn đáng chú ý nhất là:

fx.Annotate(
payment.NewStripePayment,
fx.As(new(order.PaymentGateway)),
)

payment.NewStripePayment trả về:

*payment.StripePayment

Trong khi order.NewService cần:

order.PaymentGateway

Mặc dù *StripePayment implement PaymentGateway, Fx không tự động đoán rằng concrete type này cần được cung cấp dưới interface nào.

fx.As nói rõ:

Hãy đăng ký kết quả của constructor này dưới type order.PaymentGateway.

Tương tự với mailer:

fx.Annotate(
sendgrid.NewMailer,
fx.As(new(order.Mailer)),
)

Một cách khác là tạo constructor trả thẳng về interface:

func NewPaymentGateway(
cfg config.Config,
) order.PaymentGateway {
return payment.NewStripePayment(cfg)
}

Sau đó:

fx.Provide(NewPaymentGateway)

Cả hai cách đều hợp lệ.

Constructor trả về interface thường đơn giản hơn. fx.Annotate và fx.As lại giúp phần wiring thể hiện rõ implementation nào đang được bind vào abstraction nào, đặc biệt khi dự án có nhiều module.

So sánh nhanh với TypeScript và NestJS

Trong TypeScript, interface bị xóa khi compile sang JavaScript. Vì vậy, NestJS không thể dùng interface trực tiếp làm runtime token.

Thông thường chúng ta phải tạo token:

export const PAYMENT_GATEWAY = Symbol("PAYMENT_GATEWAY");

Sau đó bind implementation vào token đó.

Trong Go, interface vẫn là một type mà Fx có thể sử dụng trong dependency graph. Chúng ta không cần tự tạo Symbol hoặc string token cho trường hợp thông thường.

Tuy nhiên, nếu constructor trả về concrete type, Fx vẫn cần được chỉ dẫn rằng type đó phải được expose dưới interface nào. Đây là vai trò của fx.As.

fx.Invoke dùng để làm gì?

Fx chỉ tạo những dependency thực sự cần thiết trong graph.

Nếu chúng ta chỉ đăng ký:

fx.Provide(httpapi.NewServer)

nhưng không có component nào cần *httpapi.Server, constructor có thể không được gọi.

Đoạn sau tạo một root dependency:

fx.Invoke(func(*httpapi.Server) {})

Fx phải cung cấp *httpapi.Server cho hàm invoke. Để làm được điều đó, nó sẽ dựng toàn bộ dependency graph phía sau server.

Bạn cũng có thể dùng một hàm rõ nghĩa hơn:

func StartApplication(server *httpapi.Server) {
// Server đã được dựng.
}
fx.Invoke(StartApplication)

Quản lý lifecycle bằng Fx

DI không chỉ là tạo object.

Nhiều dependency còn có lifecycle:

  • HTTP server cần start và shutdown.
  • Database connection cần close.
  • Kafka consumer cần bắt đầu consume và dừng graceful.
  • Worker cần hoàn thành công việc đang xử lý trước khi thoát.
  • Metrics server cần đóng listener.

Fx cung cấp fx.Lifecycle để quản lý các giai đoạn này.

Ví dụ HTTP server:

package httpapi

import (
"context"
"errors"
"net/http"

"myapp/config"
"myapp/order"

"go.uber.org/fx"
)

type Server struct {
server *http.Server
}

func NewServer(
lc fx.Lifecycle,
cfg config.Config,
orders *order.Service,
) *Server {
mux := http.NewServeMux()

mux.HandleFunc(
"POST /orders/checkout",
func(w http.ResponseWriter, r *http.Request) {
err := orders.Checkout(
r.Context(),
"ORD-1",
500_000,
"[email protected]",
)
if err != nil {
http.Error(
w,
err.Error(),
http.StatusBadGateway,
)
return
}

w.WriteHeader(http.StatusNoContent)
},
)

httpServer := &http.Server{
Addr: cfg.HTTPAddr,
Handler: mux,
}

lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
go func() {
err := httpServer.ListenAndServe()
if err != nil &&
!errors.Is(err, http.ErrServerClosed) {
// Production code nên log hoặc đưa lỗi
// về một error channel để xử lý.
}
}()

return nil
},

OnStop: func(ctx context.Context) error {
return httpServer.Shutdown(ctx)
},
})

return &Server{
server: httpServer,
}
}

Khi ứng dụng start, Fx chạy OnStart.

Khi ứng dụng nhận tín hiệu shutdown, Fx chạy OnStop và truyền vào một context.Context có timeout. HTTP server có cơ hội dừng nhận request mới và chờ request đang xử lý hoàn thành.

Đây là nơi Fx mang lại nhiều giá trị hơn việc chỉ tự động truyền constructor parameter.

Tách wiring theo module

Khi dự án lớn lên, không nên nhét toàn bộ fx.Provide vào một file.

Chúng ta có thể nhóm dependency theo module:

package ordermodule

import (
"myapp/order"
"myapp/payment"
"myapp/sendgrid"

"go.uber.org/fx"
)

var Module = fx.Module(
"order",

fx.Provide(
fx.Annotate(
payment.NewStripePayment,
fx.As(new(order.PaymentGateway)),
),

fx.Annotate(
sendgrid.NewMailer,
fx.As(new(order.Mailer)),
),

order.NewService,
),
)

Config module:

package config

import "go.uber.org/fx"

var Module = fx.Module(
"config",
fx.Provide(NewConfig),
)

HTTP module:

package httpapi

import "go.uber.org/fx"

var Module = fx.Module(
"http",
fx.Provide(NewServer),
fx.Invoke(func(*Server) {}),
)

main.go lúc này chỉ còn vai trò composition root:

func main() {
fx.New(
config.Module,
ordermodule.Module,
httpapi.Module,
).Run()
}

Business logic vẫn không biết Fx là gì. Chỉ phần ngoài cùng của ứng dụng chịu trách nhiệm nối các module với nhau.

Đổi Stripe sang Paypal

Quay lại yêu cầu ban đầu: đổi Stripe sang Paypal.

Giả sử PaypalPayment implement interface:

type PaymentGateway interface {
Charge(ctx context.Context, amount int64) error
}

Chúng ta chỉ cần thay binding trong module:

fx.Annotate(
payment.NewPaypalPayment,
fx.As(new(order.PaymentGateway)),
)

Những phần sau không cần thay đổi:

  • order.Service
  • HTTP handler
  • Unit test của order
  • Mailer
  • Inventory service
  • Shipping service
  • Repository
  • Contract PaymentGateway

Thay đổi được giới hạn tại composition root, đúng nơi chịu trách nhiệm lựa chọn implementation.

Đây mới là lợi ích thật sự của loose coupling. Không phải vì số dòng code ít hơn, mà vì phạm vi ảnh hưởng của thay đổi được kiểm soát.

Tổng kết

Hành trình từ tight coupling đến loose coupling có thể tóm tắt như sau.

Ban đầu, service tự tạo concrete dependency:

type OrderService struct {
payment *StripePayment
}

Sau đó, business logic phụ thuộc vào abstraction:

type OrderService struct {
payment PaymentGateway
}

Dependency được truyền từ bên ngoài vào:

func NewOrderService(
payment PaymentGateway,
) *OrderService

Với ứng dụng nhỏ, dependency có thể được nối thủ công trong main.go.

Khi dependency graph trở nên phức tạp, Uber Fx có thể tự động hóa việc wiring và quản lý lifecycle:

fx.Annotate(
payment.NewStripePayment,
fx.As(new(order.PaymentGateway)),
)

Điểm quan trọng nhất không nằm ở Fx, interface hay constructor.

Loose coupling là việc đặt quyết định thay đổi ở đúng vị trí.

Business logic checkout không nên biết hệ thống đang dùng Stripe hay Paypal. Nó chỉ cần biết rằng có một payment gateway thực hiện được thao tác charge tiền.

Vì vậy, lần tới khi có yêu cầu:

“Đổi SendGrid sang SES nhé.”

Nơi cần sửa không nên là 14 service khác nhau.

Nó nên là composition root hoặc module wiring — nơi hệ thống lựa chọn implementation ngay từ đầu.

#Backend#Go Advance
DUKE
Written byDUKEView all stories by DUKE

Bình luận