Quán trà đá lấy đá của ông Ba đầu chợ, quen đến mức cái thùng đá được đóng vừa khít khay đá nhà ông Ba, sổ sách ghi "tiền đá ông Ba", nhân viên được dặn "hết đá thì gọi ông Ba, số 09xx". Hôm ông Ba về quê ăn giỗ một tuần, cả quán bán trà nóng giữa tháng Sáu.
Muốn lấy đá nhà máy đầu phố thì không xong: khay của nhà máy không vừa thùng, sổ sách phải sửa, nhân viên không biết gọi ai. Ông Ba không có lỗi gì. Lỗi là cả cái quán được xây quanh ông Ba.
Lời dẫn
Đây là bài cuối của series SOLID, mỗi bài một chữ:
| Chữ | Tên đầy đủ | Nói kiểu quán trà đá |
|---|---|---|
| S | Single Responsibility Principle | Mỗi người nghe một sếp |
| O | Open/Closed Principle | Thêm món thì gài thêm thẻ, đừng sơn lại biển |
| L | Liskov Substitution Principle | Người thay ca phải bán đúng như người cũ |
| I | Interface Segregation Principle | Tuyển trông xe thì đừng đòi biết pha latte |
| D | Dependency Inversion Principle | Cần đá viên đạt chuẩn, đừng cần đúng ông Ba |
Bốn chữ trước nói chuyện chia code cho đúng, cắm thêm cho êm, thay thế cho chuẩn, hứa hẹn cho vừa. Chữ D trả lời câu hỏi còn lại: ai phụ thuộc vào ai. Đây là chữ quyết định cái lõi của chương trình có bị trói vào một ông bán đá nào đó hay không.
Bài này đọc riêng vẫn hiểu. Đọc cả series thì sẽ thấy D là chữ mà anh em đã làm từ bài S mà chưa biết tên.
Trong bài có bài refactor mẫu đi từng bước, và bài tập nhanh ở cuối để anh em tự kiểm tra.
1. D là gì?
Dependency Inversion Principle (DIP), nguyên tắc đảo ngược phụ thuộc. Bác Bob (Robert C. Martin) phát biểu thành hai ý, trong bài viết năm 1996 và sau đó là sách Agile Software Development (2002):
A. High level modules should not depend upon low level modules. Both should depend upon abstractions.
B. Abstractions should not depend upon details. Details should depend upon abstractions.
A. Module cấp cao không nên phụ thuộc vào module cấp thấp. Cả hai nên phụ thuộc vào abstraction.
B. Abstraction không nên phụ thuộc vào chi tiết. Chi tiết nên phụ thuộc vào abstraction.
— Robert C. Martin, The Dependency Inversion Principle, C++ Report, 1996
Nghe như văn bản hành chính. Dịch cabin:
- Module cấp cao là phần quan trọng, phần nghiệp vụ: "mỗi đêm sao lưu database", "mỗi sáng gửi bản tin bài mới". Ở quán: "bán trà đá cho khách".
- Module cấp thấp là chi tiết vặt: mysqldump, Google Cloud Storage, Brevo, Stripe. Ở quán: ông Ba.
- Abstraction là cái mô tả nhu cầu, không nói tên ai: "một thứ biết xuất database ra file", "một chỗ cất file". Ở quán: "đá viên sạch, giao trước 6 giờ sáng, vừa khay chuẩn".
Câu ngắn nhất: phần quan trọng không nên biết tên của mấy thứ vặt.
Đảo ngược cái gì?
Bình thường, chiều phụ thuộc đi từ trên xuống:
Bán trà đá ──phụ thuộc──▶ Ông Ba
Quán thiết kế theo ông Ba. Ông Ba đổi khay, quán đổi theo. Ông Ba nghỉ, quán nghỉ.
Đảo ngược là:
Bán trà đá ──phụ thuộc──▶ "Đá viên đạt chuẩn" ◀──đáp ứng── Ông Ba
◀──đáp ứng── Nhà máy đá
Quán đặt ra tiêu chuẩn. Ai bán đá thì phải theo tiêu chuẩn của quán. Mũi tên từ ông Ba giờ chỉ ngược lên. Đó là chữ "đảo ngược". Điểm mấu chốt: tiêu chuẩn do quán đặt, không phải do ông Ba đặt.
Hiểu nhầm phổ biến
"Dependency Inversion với Dependency Injection là một." Hai cái tên na ná, hay bị lẫn. Dependency Injection (DI) là một kỹ thuật: thay vì tự new bên trong, class nhận thứ nó cần từ bên ngoài, thường qua constructor. Dependency Inversion (DIP) là một nguyên tắc về chiều phụ thuộc. DI là cách phổ biến nhất để đạt DIP, nhưng DI xong chưa chắc đã DIP: nếu cái được truyền vào vẫn mang nguyên hình dạng của ông Ba (gọi method tên goi_ong_ba, nhận khay cỡ nhà ông Ba), thì quán vẫn bị trói, chỉ là trói bằng dây dài hơn.
"Phải dùng DI container, framework, file cấu hình XML." Không cần. Trong Ruby, truyền qua tham số initialize là đủ. Anh em đã làm từ bài S rồi:
MonthlyBillReport.new(
reader: GcpBillingCsvReader.new("billing-2026-09.csv"),
formatter: MarkdownBillFormatter.new,
sender: EmailBillSender.new(to: "hello@staycrafting.dev")
)
MonthlyBillReport không biết GCP, không biết Brevo. Nó chỉ biết cần một reader, một formatter, một sender. Đó là D.
"Cái gì cũng phải bọc abstraction." Không. Mục 5 sẽ nói kỹ.
Kết luận phần này: cái lõi đặt ra yêu cầu, chi tiết chạy theo yêu cầu, không phải ngược lại.
2. Chuyện đời thường: ổ cắm, sạc, và ông Ba
Ví dụ ổ cắm điện ở bài O cũng là ví dụ của D, chỉ là nhìn từ góc khác. Cái nồi chiên không phụ thuộc vào nhà máy điện nào. Nhà máy thủy điện, nhiệt điện, điện mặt trời đều phải cung cấp đúng chuẩn 220V, 50Hz. Cái nồi chỉ cần biết chuẩn, không cần biết điện từ nhà máy nào. Nhà máy nào muốn bán điện thì phải chạy theo chuẩn.
Ngược lại, thời điện thoại mỗi hãng một kiểu chân sạc là thời điện thoại phụ thuộc vào cục sạc của đúng hãng đó. Mất sạc là mất liên lạc. Đến khi cả ngành thống nhất USB-C, điện thoại chỉ cần "một cục sạc USB-C", hãng nào cũng được.
Quán trà đá của mình sau vụ ông Ba về quê cũng rút kinh nghiệm:
| Trước | Sau |
|---|---|
| Thùng đá đóng vừa khay ông Ba | Thùng đá theo khay cỡ chuẩn, nhà ai giao cũng vừa |
| Sổ ghi "tiền đá ông Ba" | Sổ ghi "tiền đá", bên cạnh ghi nhà cung cấp |
| "Hết đá gọi ông Ba" | "Hết đá gọi số trên bảng", bảng ghi hai số |
Ông Ba vẫn là nhà cung cấp chính, vẫn giao đá mỗi sáng. Chỉ là quán không còn chết theo ông Ba.
Mẹo nhỏ: Đọc file nghiệp vụ quan trọng nhất của dự án. Nếu thấy tên một hãng, một SDK, một dịch vụ cụ thể (
Stripe,Aws::,Google::Cloud,Brevo,Net::HTTP) nằm giữa logic nghiệp vụ, chỗ đó đang ghi "tiền đá ông Ba".
3. Vi phạm D thì đã sao?
- Không test được nếu không có thứ thật. Muốn test logic "đặt tên file backup theo ngày", phải có MySQL thật và tài khoản cloud thật. Kết quả thường gặp: không ai viết test.
- Đổi nhà cung cấp là mổ lõi. Muốn chuyển từ Google Cloud Storage sang Cloudflare R2, từ Brevo sang dịch vụ khác, phải sửa thẳng vào code nghiệp vụ. Mà code nghiệp vụ là phần đắt nhất để làm hỏng.
- Không chạy được ở máy local. Chạy thử ở máy mình là đẩy file thật lên cloud thật, gửi mail thật cho người thật. Xin chúc mừng, anh em vừa gửi bản tin test "asdfgh" cho toàn bộ người đăng ký.
- Chi tiết rò vào lõi. Lỗi của SDK, tên tham số của API bên ngoài, giới hạn của nhà cung cấp lan dần vào code nghiệp vụ. Đến lúc nào đó, không còn phân biệt được đâu là luật của mình, đâu là luật của ông Ba.
Dấu hiệu nhận biết
- Class nghiệp vụ tự
newcác client bên ngoài ngay trong method:Google::Cloud::Storage.new,Stripe::Charge.create,Net::HTTP.post. - Class nghiệp vụ gọi
system(...), đọcENV[...], đọc file cấu hình trực tiếp. - Class nghiệp vụ gọi
Time.now,Date.todayở nhiều chỗ, và test phải "đóng băng thời gian" mới chạy được. - Test phải mock thẳng class của thư viện bên ngoài (
allow(Stripe::Charge).to receive...).
4. Bài refactor: job backup database
Code bằng Ruby như các bài trước. Anh em ngôn ngữ khác cứ đọc như mã giả.
Bối cảnh: ai theo series Blog trà đá thì biết blog này có một job backup database mỗi đêm, chạy mysqldump rồi đẩy file lên một bucket Google Cloud Storage. Dưới đây là một phiên bản minh họa viết kiểu "dính chặt", không phải code thật của blog, nhưng anh em sẽ thấy nó quen.
4.1. Code ban đầu: cả quán xây quanh ông Ba
require "google/cloud/storage"
class DatabaseBackup
def run
file = "/tmp/backup-#{Date.today}.sql"
db = Rails.configuration.database_configuration["production"]
system("mysqldump -h #{db['host']} -u #{db['username']} " \
"-p#{db['password']} #{db['database']} > #{file}") or raise "mysqldump lỗi"
bucket = Google::Cloud::Storage.new.bucket("staycrafting-backup")
bucket.create_file(file, File.basename(file))
ensure
File.delete(file) if file && File.exist?(file)
end
end
Mười mấy dòng, chạy ngon mỗi đêm. Nhưng nhìn kỹ, DatabaseBackup biết quá nhiều thứ:
| Nó biết | Đó là chi tiết của |
|---|---|
Lệnh mysqldump và cú pháp tham số |
MySQL |
| Database tên gì, nằm trong cấu hình Rails nào | Rails, môi trường production |
| Google Cloud Storage, tên bucket | |
File tạm để ở /tmp |
Hệ điều hành |
Hôm nay là ngày nào (Date.today) |
Đồng hồ hệ thống |
Còn phần thật sự là của nó, phần nghiệp vụ, thì chỉ có bốn việc: đặt tên file theo ngày, xuất database ra file đó, cất file đi, dọn file tạm. Bốn việc đó bị chôn giữa đống chi tiết.
Hậu quả:
- Test: muốn kiểm tra file có được đặt tên đúng ngày không, phải có MySQL và quyền ghi vào bucket thật.
- Chạy local: chạy thử là đẩy file thật lên bucket production.
- Đổi nhà cung cấp: chuyển sang Cloudflare R2 hay sang PostgreSQL là mổ thẳng vào
run. - Bảo mật: mật khẩu nằm ngay trên dòng lệnh, ai liệt kê tiến trình trên máy cũng đọc được. Chi tiết vặt mà để lọt vào lõi thì lỗi vặt cũng lọt theo.
4.2. Bước 1: cái lõi cần gì?
Quên MySQL, quên Google đi. Hỏi: để sao lưu, DatabaseBackup cần những gì?
- Một thứ biết xuất database ra một file cho trước.
- Một chỗ biết cất một file với một cái tên.
- Biết hôm nay là ngày nào (để đặt tên).
Viết thành "tiêu chuẩn" bằng ngôn ngữ của nghiệp vụ, không dính tên nhà cung cấp:
| Vai trò | Lời hứa |
|---|---|
dumper |
dump_to(path): xuất toàn bộ database ra file ở path, lỗi thì ném lỗi |
store |
save(path, name:): cất file ở path với tên name |
clock |
today: trả về ngày hôm nay |
Để ý tên method: dump_to, save. Không phải run_mysqldump, không phải create_file_in_bucket. Tiêu chuẩn do quán đặt, bằng tiếng của quán.
4.3. Bước 2: cái lõi chỉ làm việc với tiêu chuẩn
require "tmpdir"
class DatabaseBackup
def initialize(dumper:, store:, clock: Date)
@dumper = dumper
@store = store
@clock = clock
end
def run
name = "backup-#{@clock.today}.sql"
Dir.mktmpdir do |dir|
path = File.join(dir, name)
@dumper.dump_to(path)
@store.save(path, name: name)
end
name
end
end
Đọc lên như đọc quy trình: đặt tên, xuất ra, cất đi. Dir.mktmpdir lo luôn chuyện dọn file tạm. Không có chữ MySQL, không có chữ Google nào. clock: Date là mặc định cho đời thật, test thì truyền đồng hồ giả.
4.4. Bước 3: chi tiết chạy theo tiêu chuẩn
Mỗi nhà cung cấp một class nhỏ, đáp ứng tiêu chuẩn ở bước 1:
class MysqlDumper
def initialize(config)
@config = config
end
def dump_to(path)
ok = system(
{ "MYSQL_PWD" => @config["password"] },
"mysqldump", "-h", @config["host"], "-u", @config["username"], @config["database"],
out: path
)
raise "mysqldump lỗi" unless ok
end
end
class GcsBackupStore
def initialize(bucket_name)
@bucket = Google::Cloud::Storage.new.bucket(bucket_name)
end
def save(path, name:)
@bucket.create_file(path, name)
end
end
class LocalFolderStore
def initialize(dir)
@dir = dir
FileUtils.mkdir_p(dir)
end
def save(path, name:)
FileUtils.cp(path, File.join(@dir, name))
end
end
Tiện tay sửa luôn chuyện mật khẩu: truyền qua biến môi trường MYSQL_PWD thay vì lên dòng lệnh, và gọi system với từng tham số riêng để không phải lo chuyện ký tự đặc biệt trong mật khẩu. Chi tiết của MySQL thì sửa ở chỗ của MySQL, cái lõi không cần biết.
4.5. Bước 4: một chỗ duy nhất biết tên ông Ba
Phải có một chỗ biết hết các nhà cung cấp thật để lắp ráp. Chỗ đó nên nằm ở rìa ngoài cùng, ví dụ rake task:
# lib/tasks/backup.rake
task backup: :environment do
db = Rails.configuration.database_configuration[Rails.env]
store =
if Rails.env.production?
GcsBackupStore.new(ENV.fetch("BACKUP_BUCKET"))
else
LocalFolderStore.new("tmp/backups")
end
DatabaseBackup.new(dumper: MysqlDumper.new(db), store: store).run
end
Đây là cái bảng "hết đá gọi số nào" của quán. Đổi nhà cung cấp là sửa đúng chỗ này. Chạy ở máy local thì file nằm trong tmp/backups, không bay lên cloud.
Sơ đồ phụ thuộc giờ thành:
rake task (chỗ lắp ráp)
│ biết tất cả, chỉ để lắp
▼
DatabaseBackup ──cần──▶ dumper / store / clock (tiêu chuẩn)
▲ ▲
MysqlDumper GcsBackupStore, LocalFolderStore
Lõi trỏ vào tiêu chuẩn. Chi tiết trỏ ngược lên tiêu chuẩn. Đảo ngược xong.
4.6. Test: không cần MySQL, không cần cloud
require "minitest/autorun"
class DatabaseBackupTest < Minitest::Test
class FakeDumper
def dump_to(path)
File.write(path, "-- fake dump")
end
end
class MemoryStore
attr_reader :saved
def initialize
@saved = {}
end
def save(path, name:)
@saved[name] = File.read(path)
end
end
FixedClock = Struct.new(:today)
def test_dat_ten_theo_ngay_va_cat_dung_noi_dung
store = MemoryStore.new
backup = DatabaseBackup.new(
dumper: FakeDumper.new,
store: store,
clock: FixedClock.new(Date.new(2026, 10, 4))
)
assert_equal "backup-2026-10-04.sql", backup.run
assert_equal({ "backup-2026-10-04.sql" => "-- fake dump" }, store.saved)
end
end
Test chạy trong nháy mắt, trên máy nào cũng chạy, không cần mạng. MysqlDumper và GcsBackupStore thì test riêng (hoặc kiểm tra bằng một lần chạy thật trên môi trường staging), vì chúng mỏng và chỉ làm đúng một việc nói chuyện với bên ngoài.
4.7. So sánh trước và sau
| Trước | Sau | |
|---|---|---|
DatabaseBackup biết |
MySQL, Google, Rails config, /tmp, đồng hồ |
Chỉ biết tiêu chuẩn: dumper, store, clock |
| Test logic sao lưu | Cần MySQL và cloud thật | Object giả, chạy trong vài mili giây |
| Chạy ở máy local | Đẩy file lên bucket production | Lưu vào tmp/backups |
| Chuyển sang R2 hoặc PostgreSQL | Mổ DatabaseBackup |
Viết class mới, sửa chỗ lắp ráp |
| Mật khẩu | Nằm trên dòng lệnh | Truyền qua biến môi trường |
| Số class | 1 | 1 lõi + mỗi nhà cung cấp 1 class mỏng |
5. Đừng đảo ngược quá tay
Học xong D, rất dễ rơi vào cảnh bọc mọi thứ bằng abstraction: StringUtilsInterface, DateProvider, ArrayFactory. Kìm lại.
Không cần đảo ngược với thứ ổn định. Thư viện chuẩn của Ruby (Date, BigDecimal, JSON, File cho việc đơn giản) mấy chục năm không đổi. Phụ thuộc vào nó như phụ thuộc vào chuyện nước sôi ở 100 độ. Không ai bọc nước sôi bằng interface.
Khoan, vậy sao mục 4 lại tách clock ra khỏi Date? Vì cái ổn định là class Date: cách cộng ngày, cách so sánh, cách in ra chuỗi. Còn "hôm nay là ngày nào" thì mỗi lần chạy một khác, và test không điều khiển được. PriceCalculator dùng BigDecimal thì cứ dùng thẳng. DatabaseBackup hỏi "hôm nay" thì nên hỏi qua clock.
Đảo ngược ở biên giới. Chỗ đáng đặt tiêu chuẩn là chỗ chương trình chạm ra thế giới bên ngoài:
- Thứ chậm: mạng, database ngoài, file lớn.
- Thứ tốn tiền hoặc có tác dụng phụ thật: gửi mail, trừ tiền, đẩy file lên cloud.
- Thứ hay đổi nhà cung cấp: email, lưu trữ, thanh toán, SMS.
- Thứ khó kiểm soát trong test: thời gian, số ngẫu nhiên.
Đừng chống lại framework. Trong Rails, controller gọi thẳng model ActiveRecord là chuyện bình thường. Bọc toàn bộ ActiveRecord sau một lớp "repository" cho app nhỏ là tự tạo thêm việc, mà lợi ích chẳng thấy đâu. Rails đã tự đảo ngược phụ thuộc ở những chỗ quan trọng: Active Storage như bài O có nhắc, rồi Active Job, Action Mailer cũng vậy, đều cho cắm adapter qua config. Code của anh em gọi post.cover.attach(...), không gọi Google.
Tiêu chuẩn chỉ đáng đặt khi đã có ông Ba thứ hai, hoặc sắp có, hoặc test đang đau. Quán chỉ cần đặt tiêu chuẩn khay đá, không cần đặt tiêu chuẩn cho cái thìa khuấy.
Nói riêng với anh em Rails
- Service object nhận dependency qua
initialize, kèm giá trị mặc định là kiểu D vừa phải nhất cho Rails:def initialize(sender: BrevoSender.new, clock: Time)(BrevoSenderở đây là class mỏng tự viết, giả định). Đời thật không phải truyền gì, test thì truyền đồ giả. Cái giá phải nói thẳng: service vẫn biết tên ông Ba, ở đúng một dòng mặc định. Đổi lại, phần thân của nó chỉ gọi@sender, và chỗ gọi service không phải lắp ráp gì. Muốn lõi sạch hẳn thì bỏ giá trị mặc định, lắp ráp ở rìa ngoài như mục 4.5. - Thời gian: Rails có
travel_tođể đóng băng thời gian trong test, dùng được. Nhưng nếu một class nghiệp vụ phụ thuộc nặng vào thời gian (lịch, hạn, khuyến mãi theo giờ như bài O), truyềnclockvào sẽ rõ ràng hơn. - Gem SDK bên ngoài (Stripe, AWS, Google, Brevo): luôn bọc trong một class mỏng của mình, đặt tên theo nhu cầu của mình. Lúc gem lên phiên bản mới đổi tên method, anh em chỉ sửa đúng một file.
6. Bài tập nhanh
Đến lượt anh em. Một bài khởi động nhẹ, không cần mở editor, chỉ cần một ly trà đá: đọc từng tình huống và đoán xem chỗ nào vi phạm, chỗ nào không đáng sửa. Bài tập sửa code dài hơn sẽ gom vào một bài tổng ôn riêng của series.
Cố tự làm trước rồi hãy xem lời giải. Xem lời giải trước khi làm thì giống chép đáp án đề thi thử: cảm giác rất giỏi, cho đến hôm thi thật.
Trường hợp nào vi phạm D? Giải thích bằng câu hỏi "phần quan trọng có đang biết tên một chi tiết vặt không, và chi tiết đó có đáng để tách không?".
OrderService#checkouttính tổng tiền, áp khuyến mãi, rồi gọi thẳngStripe::Charge.create(amount: total, ...)ngay trong method.PriceCalculatordùngBigDecimalđể tính tiền cho khỏi sai số.SignupService.new(mailer: BrevoMailer.new). Bên trong:@mailer.brevo_template_id = 12rồi@mailer.send_brevo_template(to: email).PostsController#indexgọiPost.published.order(published_at: :desc). App blog cá nhân, một database, không có kế hoạch đổi.CouponValidator#valid?kiểm tra hạn dùng bằngDate.todayrải rác ở bốn chỗ. Test hiện phải dùngtravel_toở mọi ca, và hay quên.
Túm cái váy lại
Chữ D
| Ghi nhớ | |
|---|---|
| Định nghĩa | Cấp cao không phụ thuộc cấp thấp, cả hai phụ thuộc abstraction |
| Dịch cabin | Phần quan trọng không nên biết tên của mấy thứ vặt |
| Câu hỏi thần chú | "Ông Ba về quê thì quán có phải đóng cửa không?" |
| Đảo ngược | Tiêu chuẩn do cái lõi đặt, chi tiết chạy theo tiêu chuẩn |
| Dấu hiệu | Tên SDK, system, ENV, Date.today nằm giữa logic nghiệp vụ |
| Cách làm | Hỏi lõi cần gì, đặt tiêu chuẩn bằng tiếng của lõi, mỗi nhà cung cấp một class mỏng, lắp ráp ở rìa ngoài |
| Hiểu nhầm | DI là DIP. DI là kỹ thuật, DIP là chiều phụ thuộc. Inject rồi vẫn có thể bị trói |
| Đừng | Bọc mọi thứ. Chỉ đảo ngược ở biên giới: chậm, tốn tiền, hay đổi, khó test |
Cả series, gói trong một bảng
| Chữ | Câu hỏi thần chú | Quán trà đá |
|---|---|---|
| S | Ai sẽ bắt mình sửa class này? | Bà chủ kiêm hết, ốm một hôm là cả ngõ tê liệt |
| O | Thêm một loại mới thì phải sửa mấy file cũ? | Thêm trà chanh thì gài thẻ, đừng sơn lại biển |
| L | Người gọi có phải hỏi "đứa nào đây?" không? | Người trông quán hộ phải bán trà đá khi khách gọi trà đá |
| I | Đoạn code này thật sự gọi mấy method? | Tuyển trông xe thì đừng đòi biết pha latte |
| D | Ông Ba về quê thì quán có phải đóng cửa không? | Cần đá viên đạt chuẩn, đừng cần đúng ông Ba |
Năm chữ, nhưng chung một mục đích: sửa code mà không sợ. S chia đúng chỗ. O cho cắm thêm mà không đụng cũ. L đảm bảo cắm vào thì chạy đúng. I giữ cho mỗi chỗ cắm vừa vặn. D đảm bảo cái lõi không bị trói vào bất kỳ ai.
Và như mọi bài đều nhắc: SOLID là để code dễ sửa hơn, không phải để code trông kiến trúc hơn. Áp khi đau, áp ở chỗ đau. Code chạy ngon, không ai phải sửa, thì cứ để nó yên mà uống trà.
Sau series này, anh em đã sở hữu:
- Năm câu hỏi thần chú, mỗi câu soi một kiểu bệnh.
- Năm bài refactor mẫu, từ báo cáo hóa đơn đến job backup.
- Và hy vọng là một cái nhìn khác về quán trà đá đầu ngõ: nó vận hành trơn tru hơn rất nhiều hệ thống phần mềm mình từng thấy.
Chúc anh em code dễ sửa, deploy không giật mình, nhà cung cấp có về quê cũng không sao, và trà luôn đủ đá.
À mà, nếu anh em đang nghĩ "dự án em gọi thẳng SDK khắp nơi mà vẫn chạy ngon", thì mình tin. Quán cũng lấy đá ông Ba mười năm nay rồi. Chỉ là chưa đến tháng Sáu ông Ba về quê thôi.