Đây là danh mục pattern để chuyển code ra khỏi một hệ thống đang chạy. Mọi pattern trong chương đều phục vụ cùng một mục tiêu từ phần 2: chuyển chức năng theo từng bước nhỏ, ship được song song với việc làm tính năng và đảo ngược được nhanh. Newman cẩn thận cho mỗi pattern một mục "khi nào nên dùng", và phần lớn sự cân nhắc nằm ở những mục đó.

Là một frontend developer, mình thấy phần UI composition liên quan nhiều hơn mình tưởng. Micro frontends chỉ được nói ngắn, nhưng ý lớn hơn, rằng một cuộc migration bỏ qua UI sẽ bỏ phí rất nhiều giá trị, vẫn đứng vững rất tốt.

Ứng với chương 3 trong sách.

Ý chính

Trước các pattern: copy, đừng cắt

Newman bắt đầu bằng vài quyết định khung. Bạn có sửa được monolith không? Nếu đó là phần mềm của vendor, hoặc code coi như không truy cập được, chỉ những pattern làm việc ở rìa mới dùng được. Nếu sửa được, hãy copy code sang service mới thay vì cắt nó ra, để bản cũ vẫn còn làm điểm rollback và làm đối chiếu trong parallel run. Chỉ gỡ nó khỏi monolith khi migration đã tự chứng minh được.

Ông cũng khuyên refactor monolith theo đường domain trước, dùng seam (điểm nối để thay đổi hành vi mà không sửa code tại chỗ) theo nghĩa trong cuốn Working Effectively with Legacy Code của Michael Feathers. Biến các seam đó thành module là bạn có modular monolith, và ông ghi nhận với một số team, riêng bước đó đã giải quyết phần lớn vấn đề. Nếu viết lại thay vì chuyển code, hãy giữ mỗi lần viết lại thật nhỏ: vài ngày hoặc vài tuần, không phải vài tháng.

Strangler fig: chặn ở rìa

Strangler fig application (ứng dụng "cây đa bóp nghẹt"), cái tên do Martin Fowler đặt, là pattern mặc định đầu tiên của Newman. Service mới lớn dần quanh hệ thống cũ, nhận lấy từng phần chức năng một, và hệ thống cũ teo dần. Với một monolith dùng HTTP, cơ chế gồm ba bước:

  1. Đặt một reverse proxy (proxy ngược) trước monolith, cho mọi request đi qua nguyên vẹn, và đo độ trễ của bước nhảy thêm.
  2. Xây service mới và deploy lên production trước khi nó nhận traffic, kể cả khi ban đầu nó chỉ trả về 501.
  3. Chuyển route của chức năng đó sang service mới, và giữ cho việc chuyển đổi dễ đảo ngược.

Bước hai tách deployment khỏi release, một ý tưởng xuất hiện xuyên suốt chương. Service mới có thể sống trên production, được monitor và test tại chỗ, trong khi người dùng vẫn đi vào monolith. Cấu hình proxy cho một route đã migrate có thể trông như sau:

upstream legacy_app {
  server legacy.internal:8080;
}

upstream certificates_service {
  server certificates.internal:3000;
}

server {
  listen 80;

  location /api/certificates/ {
    proxy_pass http://certificates_service;
  }

  location / {
    proxy_pass http://legacy_app;
  }
}

Rollback chỉ là revert một khối location. Pattern này không dùng được khi chức năng bạn muốn chuyển không đi tới được từ một lời gọi vào, ví dụ notification được kích hoạt từ sâu bên trong nhiều luồng, và khi protocol khó định tuyến. Newman cũng cảnh báo việc nhét logic chuyển đổi protocol vào proxy dùng chung: đường ống dùng chung mà "thông minh" sẽ thành nguồn contention mới. Nếu cần chuyển từ protocol này sang protocol khác, hãy để chính service expose cả hai.

Chương cũng cho thấy pattern này dùng được ngoài HTTP: Homegate chặn các lượt upload FTP rồi đẩy sang một REST service mới, còn monolith chạy bằng message có thể được "strangle" bằng content-based router, hoặc để monolith và service mới cùng tiêu thụ có chọn lọc từ một queue. Cách tiêu thụ chọn lọc tránh được queue thừa nhưng cần thay đổi phối hợp ở cả hai phía, nên chỉ hợp với số ít consumer và luật đơn giản.

Một luật mình muốn in ra dán lên tường: tránh thay đổi hành vi của chức năng trong lúc đang migrate nó. Nếu service mới có bug fix hay tính năng mới, rollback về bản cũ sẽ lấy mất những thay đổi đó khỏi người dùng, nên mỗi bước migrate càng nhỏ thì áp lực "nhét thêm" thay đổi càng ít.

UI composition: migrate thứ người dùng nhìn thấy

Giao diện người dùng là một chỗ khác để ghép cũ với mới. Newman mô tả ba dạng, dựa trên The Guardian, REA Group, Orbitz và Spotify:

Dạng Cách hoạt động Phù hợp khi
Page composition Định tuyến cả trang hoặc cả mục của site sang hệ thống mới Một team sở hữu trọn một mảng dọc, như mục du lịch của The Guardian hay các kênh của REA
Widget composition Ghép một mảnh từ hệ thống mới vào trang cũ Bạn muốn một lần release đầu ít rủi ro; một widget lỗi chỉ làm hỏng một phần trang
Micro frontends Các phần của một single-page app được build độc lập và ghép trong trình duyệt Một SPA lớn do nhiều team sở hữu

Với app mobile native, ông lưu ý bản thân app là một monolith được ship qua vòng duyệt của store, vì thế những công ty như Spotify điều khiển bố cục component từ phía server.

Branch by abstraction: khi code nằm sâu bên trong

Khi không thể chặn chức năng ở rìa, bạn cần sửa monolith mà không dùng branch sống lâu. Branch by abstraction làm việc đó qua năm bước: tạo một abstraction bao quanh chức năng, chuyển các nơi gọi sang dùng nó, xây bản triển khai mới gọi sang service mới, chuyển đổi (lý tưởng là bằng feature toggle), rồi dọn bản triển khai cũ cùng toggle. Cả hai bản triển khai sống trên nhánh chính, và mỗi lúc chỉ một bản hoạt động.

export type CertificateRequest = {
  enrollmentId: string;
  learnerName: string;
  courseTitle: string;
};

export interface CertificateIssuer {
  issue(request: CertificateRequest): Promise<{ certificateId: string }>;
}

export class RemoteCertificateIssuer implements CertificateIssuer {
  constructor(private readonly baseUrl: string) {}

  async issue(request: CertificateRequest): Promise<{ certificateId: string }> {
    const response = await fetch(`${this.baseUrl}/certificates`, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify(request),
    });
    if (!response.ok) {
      throw new Error(`Certificate service responded ${response.status}`);
    }
    return (await response.json()) as { certificateId: string };
  }
}

export function chooseIssuer(
  useRemoteIssuer: boolean,
  legacy: CertificateIssuer,
  remote: CertificateIssuer,
): CertificateIssuer {
  return useRemoteIssuer ? remote : legacy;
}

Newman nhắc tới một biến thể, verify branch by abstraction, quay về bản cũ khi bản mới lỗi. Nó thêm độ phức tạp và đòi hỏi cả hai bản triển khai thấy cùng một dữ liệu nhất quán. Ông cũng nhắc hãy xoá toggle khi việc chuyển đổi đã cố định; flag bị bỏ quên là chi phí bảo trì có thật.

Parallel run: gọi cả hai, tin một

Strangler fig và branch by abstraction cho phép cũ và mới cùng tồn tại, nhưng mỗi request bạn vẫn chọn một bên. Parallel run gọi cả hai và so sánh kết quả, trong khi chỉ một bên là nguồn sự thật. Câu chuyện định giá credit derivative của Newman là ví dụ sống động: hai hệ thống cùng định giá giao dịch suốt một tháng, báo cáo đối soát lộ ra các khác biệt, và nhiều khác biệt hoá ra là bug của hệ thống cũ. Việc so sánh nên bao gồm cả hành vi phi chức năng: latency, tỉ lệ lỗi và time-out. Với các side effect như gửi email, một spy ghi lại điều lẽ ra đã xảy ra thay vì làm hai lần.

type Outcome<T> = { ok: true; value: T } | { ok: false; error: unknown };

async function settle<T>(run: () => Promise<T>): Promise<Outcome<T>> {
  try {
    return { ok: true, value: await run() };
  } catch (error) {
    return { ok: false, error };
  }
}

export async function parallelRun<T>(
  name: string,
  trusted: () => Promise<T>,
  candidate: () => Promise<T>,
  equivalent: (a: T, b: T) => boolean,
  report: (name: string, details: Record<string, unknown>) => void,
): Promise<T> {
  const [trustedOutcome, candidateOutcome] = await Promise.all([
    settle(trusted),
    settle(candidate),
  ]);

  if (!candidateOutcome.ok) {
    report(name, { candidateError: String(candidateOutcome.error) });
  } else if (trustedOutcome.ok && !equivalent(trustedOutcome.value, candidateOutcome.value)) {
    report(name, { trusted: trustedOutcome.value, candidate: candidateOutcome.value });
  }

  if (!trustedOutcome.ok) {
    throw trustedOutcome.error;
  }
  return trustedOutcome.value;
}

Ông phân biệt nó với canary release (một nhóm người dùng nhận bản mới) và dark launching (bản mới chạy nhưng người dùng không thấy), và gom cả ba dưới cái ô progressive delivery. Parallel run tốn công xây dựng, nên ông chỉ dành nó cho chức năng rủi ro cao.

Decorating collaborator và change data capture

Hai pattern kích hoạt hành vi mới mà không sửa monolith. Decorating collaborator để request đi tới monolith như bình thường, rồi dùng request hoặc response để gọi một service mới, ví dụ cộng điểm thành viên sau khi đặt hàng thành công. Nó hiệu quả khi request và response đã mang đủ dữ liệu bạn cần; nếu proxy phải gọi ngược vào monolith để lấy thêm, thiết kế sẽ rối.

Change data capture thì thay vào đó phản ứng với thay đổi trong datastore của monolith. Newman so sánh ba cách triển khai: database trigger (dễ bắt đầu, nhưng là con dốc trơn nếu lạm dụng), transaction log poller (gọn gàng nhất, chạy ngoài database và chỉ thấy thay đổi đã commit) và batch delta copier (đơn giản, nhưng phải tự tìm ra cái gì đã đổi). Cái giá không tránh được là coupling với schema của monolith, nên ông chỉ dùng pattern này khi các pattern ở rìa không áp dụng được.

Đọc lại ở năm 2026

  • Strangler fig ở rìa. Proxy giờ thường là một API gateway, một rule trên CDN hoặc chính framework. Với team React, Next.js multi-zones chia một domain cho nhiều app bằng rewrites, tức là một strangler cho frontend. Rewrites là cơ chế định tuyến được khuyên dùng, còn middleware dành cho trường hợp cần quyết định động, như bật một flag trong lúc migrate.
  • Feature toggle. Branch by abstraction đi cặp tự nhiên với một dịch vụ flag. OpenFeature, một dự án CNCF, chuẩn hoá API đánh giá flag để code toggle không trói bạn vào một vendor.
  • Service mesh đã ổn định. Newman từng khuyên nên chờ mảng này chín muồi. Ambient mode của Istio, bỏ sidecar trên từng pod, đạt GA ở Istio 1.24 vào tháng 11/2024, và sidecar mode vẫn được hỗ trợ.
  • Change data capture. Debezium là công cụ CDC dựa trên log phổ biến, và outbox event router của nó xây trên cùng ý tưởng (nói ở phần sau). GitHub Scientist cùng các bản port vẫn là cách triển khai tham chiếu cho ý tưởng parallel run ở mức code.
  • Micro frontends. Module Federation, multi-zones và ghép phía server đơn thuần đều tồn tại, và lời cảnh báo của sách vẫn đúng: hãy ghép theo những lát dọc mà một team sở hữu, nếu không bạn sẽ dựng lại một distributed monolith ngay trong trình duyệt.

Áp dụng vào dự án

  1. Đặt một proxy chuyển tiếp nguyên vẹn trước monolith ngay bây giờ, kể cả trước lần tách đầu tiên, và đo latency nó thêm vào.
  2. Deploy mỗi service mới lên production trước khi nó nhận traffic, và release bằng cách đổi một route hoặc một flag.
  3. Đóng băng thay đổi hành vi của chức năng đang được migrate; xếp hàng bug fix và tính năng cho tới khi chuyển xong.
  4. Dùng branch by abstraction thay cho branch sống lâu với mọi thứ không đi tới được từ rìa, và xoá toggle sau đó.
  5. Với các phép tính rủi ro cao, chạy song song cũ và mới và xem báo cáo sai lệch trước khi đổi nguồn sự thật.
  6. Ở frontend, migrate theo cả route hoặc cả mục do một team sở hữu trước khi tính tới cách ghép chi tiết hơn.

Tự kiểm tra

  1. Ba bước của một cuộc migration strangler fig qua HTTP là gì? Chèn một proxy chuyển tiếp nguyên vẹn, xây và deploy service mới khi chưa có traffic, rồi chuyển các lời gọi liên quan sang nó.

  2. Vì sao thay đổi hành vi nên chờ tới khi migration xong? Vì rollback về bản cũ sẽ âm thầm lấy mất những bug fix hoặc tính năng đó khỏi người dùng.

  3. Khi nào branch by abstraction phù hợp hơn strangler fig? Khi chức năng được gọi từ sâu bên trong monolith và không thể chặn ở rìa của nó.

  4. Parallel run khác canary release thế nào? Parallel run gửi mọi request tới cả hai bản triển khai và so sánh; canary chỉ đưa một phần người dùng sang bản mới.

  5. Cái giá chính của change data capture là gì? Coupling với datastore và schema của monolith, cộng độ phức tạp của trigger hoặc tooling dựa trên log.

Đọc thêm