Nguyen Le Phong

Nền tảng Kiến trúc Phần mềmPhần 1/6

Ports & Adapters (Kiến trúc Lục giác), Giải thích Đơn giản

Một hướng dẫn thân thiện, đầy ví dụ về Kiến trúc Lục giác: port và adapter thực sự là gì, quy tắc duy nhất khiến nó vận hành, và áp dụng bao nhiêu là vừa — dù bạn là founder solo hay một team enterprise.

Bạn từng gặp cảnh này: một tính năng đáng lẽ làm trong một buổi chiều lại ngốn ba ngày, chỉ vì cái “sửa nhỏ” ở chỗ gửi email hoá ra dính vào mười hai file. Code thanh toán biết về database, code database biết về request web, và đâu đó ở giữa — gần như khuất tầm mắt — mới là những quy tắc nghiệp vụ thật sự khiến sản phẩm của bạn là của bạn.

Ports & Adapters (bạn cũng sẽ nghe gọi là Hexagonal Architecture — Kiến trúc Lục giác, do Alistair Cockburn đặt tên năm 2005) là cách sắp xếp code để chuyện trên thôi xảy ra. Nó không phải framework, bạn không cần cài, và nó dùng được trong mọi ngôn ngữ. Thực ra chỉ là một ý tưởng khoác cái tên hơi kêu. Hãy bóc tách thật chậm, có hình minh hoạ và ví dụ thực tế, để cuối bài bạn biết chính xác khi nào nên dùng — và khi nào không.

Vấn đề: code lớn lên một cách tệ hại

Đa số ứng dụng khởi đầu giống nhau và dễ thương như nhau — một controller gọi một service, service nói chuyện với database và vài vendor, ai cũng vui. Rắc rối nằm ở chuyện xảy ra theo thời gian. Đây là một đoạn checkout chỉ mới vài tháng tuổi mà đã nhức đầu:

// checkout.ts — mọi thứ rối vào nhau
class OrderService {
  async checkout(cart: Cart) {
    const db = new PgClient(process.env.DB_URL)        // một database cụ thể
    const stripe = new Stripe(process.env.STRIPE_KEY)   // một vendor cụ thể
    const sg = new SendGrid(process.env.SENDGRID_KEY)   // một vendor khác

    // quy tắc nghiệp vụ thật sự bị chôn giữa các lời gọi vendor…
    const total = cart.items.reduce((s, i) => s + i.price, 0)
    await stripe.charges.create({ amount: total, source: cart.token })
    await db.query('INSERT INTO orders …')
    await sg.send({ to: cart.email, template: 'order-ok' })
  }
}

Chẳng có dòng nào “sai” cả. Nhưng logic nghiệp vụ (một đơn hàng là gì, khi nào hợp lệ, “đã xác nhận” nghĩa là gì) đang quấn chặt với ba vendor và một driver database. Bạn không thể test quy tắc mà thiếu database thật. Bạn không thể rời SendGrid mà không phẫu thuật. Và một đồng đội mới đọc đoạn này sẽ học về Stripe trước khi học về đơn hàng của bạn.

Câu hỏi cốt lõi

Ports & Adapters trả lời đúng một câu hỏi: làm sao tách phần làm ra tiền (quy tắc nghiệp vụ) khỏi những phần chắc chắn sẽ phải thay (database, vendor, framework, UI)?

Ý tưởng cốt lõi, gói gọn trong một bức tranh

Đặt quy tắc nghiệp vụ — phần domain — vào trung tâm. Cấm nó import bất kỳ framework, driver database hay SDK vendor nào. Rồi để thế giới bên ngoài chỉ nói chuyện với nó qua các port (interface), còn các adapter lo phần việc lấm lem với thực tế ở phía bên kia.

Domain nằm ở trung tâm. Các adapter cắm vào nó qua các port. BÊN ĐIỀU KHIỂN · gọi vào ta BÊN BỊ ĐIỀU KHIỂN · ta gọi ra Domain quy tắc nghiệp vụ, không vendor Web / Mobile UI REST / GraphQL Test / CLI / Cron Cơ sở dữ liệu Email / SMS Cổng thanh toán ● = một port (interface do domain sở hữu)
Hình lục giác chỉ là cách vẽ: domain nằm ở giữa và không bao giờ import một framework hay vendor nào. Mọi thứ bên ngoài muốn chạm tới nó đều phải qua một port. Adapter bên trái điều khiển ứng dụng; adapter bên phải bị ứng dụng điều khiển.

Toàn bộ hình hài chỉ có thế. Người ta vẽ hình lục giác thay vì hình tròn vì một lý do dễ thương: sáu cạnh nhắc rằng một ứng dụng có nhiều lối vào ra (web UI, API, test, cron job, database, hàng đợi…), chứ không chỉ “trên” và “dưới”. Con số sáu chẳng có ý nghĩa gì — đừng đếm số port của bạn.

Bộ từ vựng, nói bằng lời thật dễ hiểu

Chỉ ba từ làm hết việc. Đây là chúng, không thuật ngữ rối rắm:

  • Domain (lõi): quy tắc và khái niệm nghiệp vụ của bạn — Order, Money, “giỏ hàng không có món nào thì không được checkout”. Logic thuần. Đọc nó phải giống mô tả về việc kinh doanh của bạn, chứ không phải về stack công nghệ.
  • Port: một interface do domain sở hữu, mô tả thứ nó cần hoặc cung cấp — “tôi cần save(order)” hoặc “tôi cần charge() một khoản thanh toán”. Port là một lời hứa, viết bằng ngôn ngữ của domain, không một tên vendor nào ló mặt.
  • Adapter: đoạn code cụ thể đáp ứng một port bằng một công nghệ thật — một PostgresOrderRepo, một StripeGateway. Adapter là nơi Stripe, Postgres và React được phép tồn tại.

Và port có hai loại — đây là điểm tinh tế duy nhất đáng học:

Một request đi từ adapter điều khiển, xuyên qua domain, ra tới adapter bị điều khiển. CHÍNH · điều khiển PHỤ · bị điều khiển User / Test tác nhân Controller / CLI adapter điều khiển Use case quy tắc domain Repo / Gateway adapter bị điều khiển DB / Vendor port vào port ra
Cùng một domain, hai loại adapter. Adapter điều khiển (chính) đẩy request vào qua port vào; domain đẩy việc ra ngoài qua port ra tới adapter bị điều khiển (phụ). Mũi tên là hướng gọi — không phải hướng phụ thuộc.
  • Adapter điều khiển (chính) nằm bên trái và gọi vào ứng dụng của bạn: một controller web, một lệnh CLI, một bài test. Chúng dùng một port vào (interface use-case của bạn).
  • Adapter bị điều khiển (phụ) nằm bên phải và bị ứng dụng gọi: một database, một dịch vụ email, một API thanh toán. Chúng hiện thực một port ra mà domain đã định nghĩa.
Mẹo nhớ nhanh

Nếu thứ gì đó bắt đầu cuộc trò chuyện với app của bạn, nó là bên điều khiển. Nếu app của bạn mở lời trước, thứ kia là bên bị điều khiển. Người dùng bấm “Mua” là điều khiển; cổng thanh toán là bị điều khiển.

Một ví dụ cụ thể: trước và sau

Hãy gỡ rối đoạn checkout đó. Trước hết, viết ra thứ domain cần — các port — chỉ dùng từ ngữ trong nghiệp vụ của bạn:

// ports.ts — từ vựng của domain. Cấm mọi tên vendor.
interface PaymentGateway { charge(amount: Money, token: string): Promise<Receipt> }
interface OrderRepository { save(order: Order): Promise<void> }
interface Notifier        { orderConfirmed(order: Order): Promise<void> }

Giờ use case chỉ phụ thuộc vào những lời hứa đó, và không gì khác. Đọc to lên xem: nó nghe như một mô tả việc checkout, không phải một danh sách vendor.

// checkout-service.ts — chỉ phụ thuộc port, không bao giờ phụ thuộc vendor
class CheckoutService {
  constructor(
    private payments: PaymentGateway,
    private orders: OrderRepository,
    private notify: Notifier,
  ) {}

  async checkout(cart: Cart): Promise<Order> {
    const order = Order.fromCart(cart)         // quy tắc nghiệp vụ thuần
    await this.payments.charge(order.total, cart.token)
    await this.orders.save(order)
    await this.notify.orderConfirmed(order)
    return order
  }
}

Cuối cùng, vendor thật sống trong các adapter hiện thực port. Đổi một cái là thay đổi cục bộ — và trong test, bạn thay chúng bằng fake để chạy hàng nghìn ca trong khoảng thời gian trước đây chỉ đủ cho một lời gọi mạng:

// adapters/*.ts — vendor sống ở đây, nấp sau port mà nó hiện thực
class StripeGateway      implements PaymentGateway { /* SDK Stripe */ }
class PostgresOrderRepo  implements OrderRepository { /* SQL ở đây */ }
class SendGridNotifier   implements Notifier        { /* SendGrid */ }

// Trong test, thay vendor thật bằng fake in-memory — không mạng, tính bằng mili-giây:
const service = new CheckoutService(new FakePayments(), new InMemoryOrders(), new NullNotifier())

Để ý thứ đã dịch chuyển: Stripe, Postgres và SendGrid giờ là chi tiết ở rìa. Thứ làm ra tiền cho bạn ngồi ở giữa, dễ đọc, dễ test, và chẳng hề biết mấy vendor kia tồn tại.

Quy tắc duy nhất giữ tất cả lại với nhau

Nếu chỉ nhớ một điều, hãy nhớ điều này: phụ thuộc trong mã nguồn luôn hướng vào trong. Vòng ngoài (adapter, framework, I/O) được phép biết về vòng trong. Domain ở trung tâm được phép không biết gì về bên ngoài.

Phụ thuộc luôn hướng vào trong, về phía domain. ADAPTER · framework · CSDL · HTTP · I/O ỨNG DỤNG · use case + port Domain quy tắc thuần Lớp ngoài biết về lớp trong — không bao giờ ngược lại.
Quy tắc vàng: phụ thuộc trong mã nguồn chỉ hướng vào trong. Domain không biết database tồn tại; chính adapter database mới biết về domain. Lộn ngược một lớp bên ngoài, domain vẫn nguyên vẹn.

Đó là lý do bạn có thể thay cả framework web, hay di trú từ MySQL sang DynamoDB, mà domain không hề hay biết. Mũi tên phụ thuộc không bao giờ chỉ ra khỏi lõi, nên thay đổi ở rìa không thể lan vào trong. (Nếu bạn từng nghe Nguyên lý Đảo ngược Phụ thuộc — chữ “D” trong SOLID — thì đây chính là nó trong thực tế: domain định nghĩa interface, adapter phải tuân theo.)

Điều khiển và bị điều khiển, hiểu cho thực dụng

Vì sao chuyện chính/phụ lại quan trọng hằng ngày? Vì nó cho bạn biết ai sở hữu interface.

  • Với một port bị điều khiển (database, email), domain sở hữu interface còn adapter phải uốn theo cho vừa. Chính điều đó cho phép bạn đổi vendor thoải mái.
  • Với một port điều khiển (các use case của bạn), ứng dụng sở hữu interface còn thế giới bên ngoài (HTTP, CLI, một bộ test) gọi vào nó. Chính điều đó cho phép cùng một logic hôm nay phục vụ REST API, ngày mai phục vụ một endpoint gRPC hay một job định kỳ mà không đổi một quy tắc nào.

Một phép thử hữu ích: một kênh phân phối hoàn toàn mới (ví dụ một bot Slack) đáng lẽ chỉ là một adapter điều khiển mới. Một vendor hoàn toàn mới (ví dụ đổi từ Stripe sang Adyen) đáng lẽ chỉ là một adapter bị điều khiển mới. Nếu một trong hai buộc bạn phải mở domain ra sửa, tức là đã có gì đó rò rỉ.

Trông như thế nào ở các quy mô công ty khác nhau

Đây là chỗ nhiều lời khuyên đi chệch — chúng coi kiến trúc là “một cỡ vừa cho tất cả”. Lượng Ports & Adapters đúng phụ thuộc rất nhiều vào việc công ty bạn đang ở đâu. Đây là phiên bản thành thật:

Quy mô công tyNỗi đau nó gỡ bỏÁp dụng bao nhiêu
Solo / startup giai đoạn đầu Bạn chọn vội SQLite và một cổng thanh toán, giờ sợ bị khoá chặt vào chúng. Tối đa một hai port — thường là database và thanh toán. Phần còn lại để gọi trực tiếp. Tốc độ quan trọng hơn sự “thuần khiết”.
Nhỏ / đang lớn (≈ Series A) Test chạm database thật và sandbox vendor, nên chậm, dễ flaky, lập trình viên bỏ không chạy nữa. Bọc port quanh mọi I/O. Fake giúp unit test chạy trong mili-giây, người mới hiểu domain trong một ngày.
Tầm trung / scale-up Nhiều team dẫm chân nhau; lệnh “bỏ SendGrid để tiết kiệm” biến thành cả tháng viết lại. Port trở thành hợp đồng giữa các team. Đổi vendor chỉ là một adapter mới, không phải một cuộc di trú.
Tập đoàn lớn / enterprise Hệ thống cũ, vendor theo vùng, kiểm toán, và quy định mua sắm đòi “không khoá vào một nhà cung cấp”. Nhiều adapter cho một port (cũ + mới), di trú kiểu strangler-fig, và độc lập vendor là yêu cầu bắt buộc bằng văn bản.

Ba câu chuyện thực tế ngắn

Cửa hàng thương mại điện tử nhỏ (10 người). Họ bọc email sau một port Notifier chỉ có một phương thức “để phòng hờ”. Hai năm sau chi phí email tăng vọt; chuyển từ SendGrid sang Amazon SES chỉ là một adapter mới và đổi một dòng đấu nối, làm xong trong một buổi chiều. Logic đơn hàng — viên ngọc của họ — không hề bị đụng tới.

Fintech đang scale-up (≈120 kỹ sư). Thanh toán phải đi qua một nhà cung cấp nội địa ở nước này và một nhà cung cấp quốc tế ở nước kia, chọn cái nào quyết định lúc chạy theo từng giao dịch. Vì PaymentGateway là một port, “dùng nhà cung cấp nào” trở thành một quyết định định tuyến nấp sau interface. Kiểm toán viên tuân thủ cũng thích: ranh giới là một đường nối rõ ràng, test được.

Ngân hàng lớn (1.000+ kỹ sư). Một hệ mainframe 20 năm tuổi không thể thay trong một đêm. Họ định nghĩa một port AccountLedger và viết hai adapter — một nói chuyện với mainframe cũ, một với hệ core-banking mới — rồi chuyển dần lưu lượng (kiểu “cây bóp cổ” strangler fig). Các team xây tính năng mới dựa trên port trong khi cuộc di trú diễn ra bên dưới, vô hình.

Ports & Adapters và kiến trúc phân lớp cổ điển

Đa số team đến từ kiến trúc phân lớp truyền thống (UI ở trên, service ở giữa, database ở dưới). Đây là cách hai bên so kè:

Câu hỏiLayered cổ điển (UI → service → DB)Ports & Adapters
Ai phụ thuộc ai?Từ trên xuống: mỗi lớp phụ thuộc lớp dưới, kết thúc ở database.Mọi thứ phụ thuộc vào trong, kết thúc ở domain. Database chỉ là một plugin.
Test được quy tắc mà không cần DB?Thường là không — service chạm thẳng DB.Được — tiêm một repository giả; không DB, không mạng.
Đổi vendor?Phải đụng vào lớp service và lan ra ngoài.Viết một adapter mới; domain không hề đổi.
Quy tắc nghiệp vụ nằm đâu?Thường rải rác khắp service và DB.Tập trung trong domain, viết bằng ngôn ngữ nghiệp vụ.
Chi phíÍt rườm rà, nhưng theo thời gian khoá chặt bạn vào stack.Vài interface phụ lúc đầu; lời dần khi thay đổi tăng tốc.

Khác biệt lớn nhất là hướng của mũi tên. Trong code phân lớp, rốt cuộc mọi thứ phụ thuộc vào database. Trong Ports & Adapters, database phụ thuộc vào bạn. Sự đảo ngược đó chính là toàn bộ tinh thần.

Khi nào nên dùng (và khi nào không)

Kiến trúc tốt là chuyện cân công sức với mức độ quan trọng. Hãy với tới Ports & Adapters khi:

  • Ứng dụng có quy tắc nghiệp vụ thật đáng bảo vệ (không chỉ là CRUD trên một bảng).
  • Bạn dự kiến sẽ đổi hoặc thêm tích hợp — vendor, database, kênh — trong vòng đời của nó.
  • Tốc độ và sự tin cậy của test quan trọng, và bạn muốn unit test nhanh, tất định.
  • Nhiều team hoặc vòng đời dài khiến ranh giới rõ ràng tự trả lại công sức bỏ ra.

Hãy hoài nghi — hoặc chỉ áp dụng rất nhẹ — khi:

  • Đó là một app CRUD mỏng hay một prototype dùng xong bỏ; lớp gián tiếp chỉ tổ tốn công.
  • “Domain” về cơ bản chính là schema database của bạn; chẳng có gì để bảo vệ.
  • Bạn là dev solo cần ship nhanh và chi phí viết lại sau này còn thấp hơn chi phí rườm rà ngay bây giờ.
Đừng bắt chước mù quáng

Bọc một endpoint CRUD vô thưởng vô phạt trong ba interface không làm nó “sạch” — chỉ làm nó khó đọc hơn. Kiến trúc là cái giá bạn trả để mua khả năng thay đổi. Nếu chẳng có gì sắp đổi, bạn đang trả tiền cho hư không.

Những lỗi thường gặp (và cách sửa dễ dàng)

  • Port bị rò rỉ. Nếu PaymentGateway trả về một object Stripe.Charge, vendor đã rò qua port. Sửa: port chỉ nói bằng kiểu của riêng bạn (Receipt, Money).
  • Domain thiếu máu. Nếu mọi logic thật nằm trong adapter còn domain chỉ là túi dữ liệu, bạn đã vẽ ranh giới sai chỗ. Sửa: đẩy quy tắc vào lõi.
  • Một port khổng lồ. Một IRepository với 40 phương thức không phải ranh giới, nó là ngăn kéo tạp nham. Sửa: port nhỏ, đặt tên theo mục đích (OrderRepository, InventoryRepository).
  • Nhầm DTO với model domain. Hình dạng bạn gửi qua HTTP không phải entity domain. Sửa: để adapter dịch qua lại giữa định dạng truyền và domain.
  • Interface cho mọi thứ. Không phải class nào cũng cần port — chỉ những thứ băng qua ranh giới (I/O, vendor, kênh phân phối). Sửa: thêm port khi thấy đau, đừng thêm phòng xa.

Bắt đầu ngay thứ Hai như thế nào

Bạn không viết lại gì cả. Bạn chỉ tạo một đường nối ở chỗ đau nhất, rồi dừng tại đó:

  • 1. Chọn phụ thuộc gây đau nhất. Thường là database hoặc một vendor hay trục trặc.
  • 2. Viết cái interface bạn ước mình có. Bằng ngôn ngữ nghiệp vụ: save(order), không phải execSql(...).
  • 3. Dời code vendor ra sau một adapter hiện thực nó. Chưa cần đổi gì khác.
  • 4. Tiêm adapter vào thay vì khởi tạo inline, và viết một fake cho test.
  • 5. Tận hưởng sự nhẹ nhõm, rồi lặp lại — nhưng chỉ ở nơi mà sự thay đổi liên tục hay nỗi đau test đủ để biện minh cho một port nữa.

Trong vòng một tuần bạn sẽ có test nhanh quanh chỗ tích hợp khó chịu nhất và một domain bắt đầu đọc giống nghiệp vụ của bạn. Đó là toàn bộ phần thưởng, lấy được từng chút một.

Những điều cốt lõi cần nhớ

  • Một ý tưởng: quy tắc nghiệp vụ ở trung tâm, công nghệ ở rìa, nói chuyện với nhau chỉ qua interface.
  • Port = một interface do domain sở hữu. Adapter = phần hiện thực thật (Stripe, Postgres, React).
  • Phụ thuộc hướng vào trong. Domain không biết gì về thế giới bên ngoài.
  • Adapter điều khiển gọi vào app của bạn; adapter bị điều khiển bị app gọi.
  • Liều lượng tuỳ mức độ quan trọng: vài port cho startup, ranh giới độc lập vendor cho enterprise, và gần như không cần gì cho một app CRUD dùng xong bỏ.
  • Áp dụng từng bước — mỗi lần một đường nối đau nhất. Bạn không bao giờ cần một cuộc viết lại lớn.

Bài tiếp theo trong loạt này sẽ xây trên nền tảng đó và xem Ports & Adapters liên hệ thế nào với Clean ArchitectureOnion Architecture — ba cái tên cùng chỉ về một ngôi sao phương Bắc — và chúng khác nhau thật sự ở đâu trong thực tế.