Phỏng Vấn & Sự Nghiệp • 13 phút đọc • 12/10/2026

Phỏng Vấn Frontend Lead: Frontend System Design Với Micro-Frontend, Monorepo Và Trade-off

Bài này có bản tiếng Anh. Read in English →

Nguyen Hai Trinh

Nguyen Hai Trinh • Senior Software Engineer @ SmileViet

Bản vẽ kiến trúc
Ảnh: Seattle Municipal Archives from Seattle, WA · CC BY 2.0

Phỏng vấn Frontend Lead khác senior ở một chỗ. Người ta thôi hỏi “hook này chạy thế nào”. Câu hỏi thành kiểu: “Công ty có 6 team cùng làm một web app, build mất 25 phút, release phải xếp hàng. Bạn sẽ làm gì?”

Không có đáp án đúng duy nhất. Interviewer đang nghe xem bạn hỏi ngược lại cái gì trước, từ chối giải pháp nào, và có nói được cái giá của mỗi lựa chọn hay không.

Cách mình đi qua vòng này, gom từ mấy lần ngồi cả hai phía bàn phỏng vấn. Ví dụ là tình huống tổng hợp thôi, không gắn với công ty nào.

1. Đừng vẽ kiến trúc vội, hỏi về tổ chức trước

Ở cấp Lead, chuyện thường bắt đầu từ con người và quy trình, rồi mới lộ ra thành vấn đề code. Mình luôn mở đầu bằng mấy câu:

  • Ai đang đau? Build chậm, deploy phải phối hợp, hay hai team sửa cùng một file liên tục dẫn đến conflict?
  • Bao nhiêu team, và họ có sở hữu domain rõ ràng không? Team theo tính năng (checkout, catalog, account) khác hẳn team theo layer (UI team, API team).
  • Ràng buộc stack: tất cả đang dùng React hay có Angular/Vue legacy? Có SEO/SSR không?
  • Tần suất release mong muốn, và mức chấp nhận rủi ro khi deploy độc lập?
  • Con số hiện tại: thời gian CI, kích thước bundle, tỷ lệ flaky test, thời gian từ merge đến production (lead time).

Nếu interviewer không đưa số, mình nói rõ giả định của mình: “Mình giả định 6 team, ~40 frontend engineer, một codebase Next.js lớn, CI khoảng 25 phút.” Nói giả định ra thành tiếng, chứ không im lặng suy diễn.

2. Micro-frontend: trả lời “khi nào KHÔNG nên dùng” trước

Nhiều ứng viên nghe chữ “nhiều team” là nhảy thẳng sang micro-frontend (MFE). Mình làm ngược: nói trước khi nào không nên dùng.

MFE sinh ra để giải bài độc lập deploy và độc lập sở hữu giữa các team. Build chậm, code lộn xộn, thiếu convention — nó không giải quyết hẳn. Cái giá phải trả:

Chi phíBiểu hiện thực tế
Bundle trùng lặpMỗi remote mang theo thư viện riêng nếu shared cấu hình sai
Trải nghiệm không đồng nhấtSpacing, font, hành vi loading khác nhau giữa các vùng
Độ phức tạp runtimeVersion skew giữa host và remote, lỗi chỉ xuất hiện ở production
Khó debug và testE2E phải chạy trên tổ hợp nhiều phiên bản
Chi phí nền tảngCần một nhóm platform duy trì shell, routing, shared lib

Vì vậy mình không đề xuất MFE khi:

  1. Dưới khoảng 3-4 team hoặc dưới vài chục engineer. Chi phí phối hợp vẫn thấp hơn chi phí hạ tầng MFE.
  2. Các team chưa có ranh giới domain rõ ràng. Cắt sai ranh giới thì mỗi tính năng chạm vào 3 remote, và bạn có một distributed monolith.
  3. Vấn đề thật là build chậm hoặc CI chậm. Chuyện đó thuộc monorepo, caching và test selection (mục 3).
  4. Cần UX liền mạch, state chia sẻ nhiều — editor phức tạp, dashboard có state đan xen.
  5. Chưa có design system. MFE mà thiếu design system thì sản phẩm trông như ghép từ nhiều website.

Quy tắc ngón tay cái mình hay dùng: chưa thử modular monolith với ranh giới enforce bằng tooling (lint rule, CODEOWNERS, dependency boundaries) thì chưa nên nhảy sang MFE.

Ba cách tiếp cận, và khi nào thì chọn cái nào

Route-based (tách theo URL)
  /          -> app-shell
  /checkout  -> app-checkout (deploy riêng, full page load hoặc rewrite ở edge)

Module Federation (ghép runtime)
  Shell (host) --import()--> remoteCart/CartWidget  (JS tải lúc chạy)

iframe
  Shell --<iframe src="https://partner.x/widget">
Tiêu chíRoute-basedModule Federationiframe
Độ cô lậpCao (app riêng)Thấp (cùng JS context)Rất cao (browser sandbox)
Ghép nhiều remote trên 1 trangKhôngCóCó
Chia sẻ state/UIKhó (qua URL, cookie, storage)Dễ hơn nhưng dễ couplingRất khó (postMessage)
Hiệu năngTốt, có thể SSR từng appRủi ro bundle trùng, waterfallNặng, mỗi iframe là một document
SEO/SSRDễPhức tạpNội dung iframe gần như không được tính cho trang cha
Phù hợpPhần lớn trường hợp thật sự cần táchNhiều team cùng một trang, cần ghép widgetNội dung bên thứ ba, legacy, cần sandbox bảo mật

Quan điểm của mình: route-based là mặc định đáng thử trước, vì nó giữ được ranh giới deploy mà không cần runtime integration phức tạp. Module Federation chỉ đáng khi bạn thật sự cần ghép nhiều phần của các team khác nhau trên cùng một màn hình. iframe thì để dành cho ca cần cô lập tuyệt đối — nội dung bên thứ ba, app legacy mà viết lại không đáng.

Cây quyết định mình hay vẽ nháp khi bị hỏi “vậy chọn cái nào”:

flowchart TD
    A{"Đã thử modular monolith với ranh giới enforce bằng tooling chưa?"}
    A -->|"chưa"| B["Ở lại modular monolith: lint rule, CODEOWNERS, dependency boundaries"]
    A -->|"rồi"| C{"Vấn đề thật đang là gì?"}
    C -->|"build chậm, CI chậm, code trùng lặp"| D["Monorepo + build cache + affected test"]
    C -->|"cần độc lập deploy và độc lập sở hữu"| E{"Cần ghép nhiều phần của nhiều team trên cùng một màn hình?"}
    E -->|"không"| F["Route-based: app riêng theo URL"]
    E -->|"có"| G["Module Federation"]
    C -->|"cần cô lập tuyệt đối"| H["iframe: nội dung bên thứ ba, legacy"]

Nếu chọn Module Federation, chỗ nào dễ vỡ

// webpack.config.js của một remote (Module Federation, webpack 5)
new ModuleFederationPlugin({
  name: "cart",
  filename: "remoteEntry.js",
  exposes: { "./CartWidget": "./src/CartWidget" },
  shared: {
    react: { singleton: true, requiredVersion: "^18.2.0" },
    "react-dom": { singleton: true, requiredVersion: "^18.2.0" },
  },
});

Ba chỗ mình luôn nhắc khi bị hỏi sâu:

  • singleton cho React và các thư viện giữ context (router, query client). Hai bản React trên cùng một trang là hooks báo lỗi ngay, hoặc context không chia sẻ được.
  • Version skew. Host và remote deploy độc lập nên phải có chính sách tương thích. Contract của component expose ra phải được version hóa và test (contract test), host phải có fallback khi remote tải lỗi — error boundary kèm UI thay thế.
  • Ai sở hữu shared dependency? Không ai sở hữu thì việc nâng React từ 18 lên 19 thành dự án liên team kéo dài nhiều quý.

Ngưỡng mình tự đặt: MFE là quyết định về tổ chức, được hiện thực hóa bằng công nghệ. Chỉ làm khi chi phí phối hợp giữa các team đã lớn hơn chi phí vận hành nền tảng.

3. Monorepo và build cache

Nếu vấn đề là build chậm, release chậm và code trùng lặp, monorepo cộng với caching thường rẻ hơn MFE rất nhiều.

repo/
├── apps/
│   ├── web/            # Next.js
│   └── admin/
├── packages/
│   ├── ui/             # design system
│   ├── config/         # eslint, tsconfig dùng chung
│   ├── api-client/     # client sinh từ OpenAPI
│   └── utils/
├── turbo.json          # hoặc nx.json
└── pnpm-workspace.yaml

Build cache: chỗ đáng tiền nhất

Turborepo và Nx đi từ cùng một ý tưởng: hash đầu vào của task (source, dependency, biến môi trường khai báo, config). Hash nào đã chạy rồi thì lấy output từ cache, khỏi chạy lại. Cache có hai loại, local và remote. Remote dùng chung cho CI lẫn máy dev.

// turbo.json (Turborepo 2.x dùng key "tasks")
{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["src/**", "package.json", "tsconfig.json"],
      "outputs": ["dist/**", ".next/**", "!.next/cache/**"]
    },
    "test": { "dependsOn": ["build"], "outputs": ["coverage/**"] },
    "lint": {}
  }
}

Mấy ghi chú để nói được khi bị hỏi sâu:

  • dependsOn: ["^build"] nghĩa là build các package mà nó phụ thuộc trước.
  • Cache chỉ đúng khi khai báo đúng input. Task đọc biến môi trường mà không khai báo thì bạn nhận cache cũ, và lỗi kiểu đó rất khó truy. Mình thấy đây là lỗi phổ biến nhất khi team mới áp dụng.
  • Affected/—filter: trên CI chỉ chạy task cho package bị ảnh hưởng bởi thay đổi (Nx có nx affected, Turborepo có --filter=...[origin/main]). Ghép với remote cache thì PR chạm một package thường chỉ tốn một phần nhỏ thời gian so với build toàn bộ.

Turborepo hay Nx?

Mình không chọn theo “cái nào hot”. So sánh thực dụng thôi:

  • Turborepo: nhẹ, cấu hình ít, hợp repo JS/TS thuần cần task orchestration và cache nhanh.
  • Nx: nhiều tính năng hơn — project graph, generator, enforce module boundaries, hỗ trợ nhiều ngôn ngữ. Đổi lại đường cong học và độ “có chính kiến” cao hơn.

Câu trả lời an toàn mình hay dùng: team chủ yếu cần cache và task graph thì bắt đầu với công cụ nhẹ hơn. Cần enforce ranh giới giữa các module và generator thì Nx đáng cân nhắc. Mà công cụ chỉ là một nửa; nửa còn lại là kỷ luật — có CODEOWNERS, có quy tắc dependency một chiều, có người sở hữu CI.

Cái giá của monorepo, nói thẳng

  • CI phải được thiết kế lại để không build mọi thứ.
  • Quyền truy cập: ai được merge vào packages/ui? Cần CODEOWNERS và review bắt buộc.
  • Nâng dependency toàn repo là quyết định tập trung, có lợi (đồng nhất) nhưng cần quy trình.
  • Release: package nào publish ra ngoài thì dùng Changesets hoặc tương đương để quản lý version.

4. Design system governance, và chỗ dễ nghẽn

Câu interviewer hay hỏi: “Làm sao để 6 team dùng chung một bộ UI mà không nghẽn ở team design system?” Đây là câu hỏi governance, không phải câu hỏi Storybook.

Mình hay mô tả theo ba tầng:

Tokens (màu, spacing, typography)     <- thay đổi hiếm, review chặt
   └─ Primitives (Button, Input, Modal) <- API ổn định, semver nghiêm
        └─ Patterns / Domain components  <- team sản phẩm sở hữu, không bắt buộc vào core

Mấy nguyên tắc mình giữ:

  1. Core nhỏ, ổn định, có người sở hữu rõ ràng. Không phải component nào cũng đáng vào design system. Quy tắc “dùng ở ít nhất 2-3 team mới được promote” giúp khỏi phình.
  2. Có đường contribute rõ ràng. Team sản phẩm đề xuất và đóng góp, team DS review. Chỉ team DS được sửa thì nghẽn, ai cũng sửa được thì loạn.
  3. Versioning và deprecation: semver, changelog, codemod cho breaking change, thời hạn deprecate công bố trước.
  4. Kiểm thử tự động: visual regression (ảnh chụp từ Storybook), test accessibility cho primitives, test type cho props.
  5. Đo mức độ chấp nhận: tỷ lệ UI dùng component từ DS so với tự viết, số phiên bản cũ còn lưu hành. Kèm roadmap và SLA phản hồi, vì design system cũng là một sản phẩm — chỉ khác là người dùng ngồi cùng công ty.

5. State và data fetching: phân loại trước, chọn công cụ sau

Chỗ này nhiều ứng viên trả lời chung chung kiểu “dùng Redux”. Mình phân loại state trước, chọn công cụ sau:

Loại stateVí dụCông cụ phù hợp
Server stateDanh sách sản phẩm, profileReact Query / SWR (cache, dedupe, revalidate)
URL stateFilter, trang, tabRouter / search params
Client UI state cục bộModal đang mở, inputuseState, useReducer
Client state toàn cục thật sựTheme, phiên làm việc, giỏ hàng chưa đồng bộContext hoặc store nhỏ (Zustand…)
Form stateGiá trị, lỗi validateThư viện form

Phần lớn “state toàn cục” trong app thật ra là server state bị chép vào Redux. Tách đúng loại thì store tự nhẹ đi.

React Query: mấy khái niệm nói cho chính xác

const { data } = useQuery({
  queryKey: ["products", { category, page }],
  queryFn: () => api.getProducts({ category, page }),
  staleTime: 60_000,   // trong 60s dữ liệu được coi là "fresh", không refetch
  gcTime: 5 * 60_000,  // (v5; v4 gọi là cacheTime) thời gian giữ cache khi không còn observer
});
  • staleTime khác gcTime. staleTime quyết định lúc nào refetch, gcTime quyết định lúc nào xóa khỏi bộ nhớ. Nhầm hai cái này là lỗi phổ biến.
  • Query key là khóa cache. Thiết kế key theo phân cấp (["products", filters]) giúp invalidate chính xác.
  • Mutation và cập nhật cache: sau mutation, hoặc invalidateQueries để refetch, hoặc optimistic update với rollback khi lỗi.
  • Prefetch và SSR hydration: prefetch ở server rồi hydrate xuống client để tránh spinner đầu trang.

Chọn chiến lược cache theo độ tươi của dữ liệu — chỗ này hay được hỏi tiếp:

Dữ liệu gần như tĩnh (danh mục)   -> staleTime dài, có thể cache ở CDN/edge
Dữ liệu theo người dùng (profile) -> staleTime ngắn, cache riêng theo user
Dữ liệu realtime (tồn kho, giá)   -> staleTime ~0, polling hoặc WebSocket/SSE

Cái bẫy: cache ở nhiều tầng — CDN, server, client — thì phải có một chiến lược invalidation thống nhất, không thì người dùng thấy dữ liệu cũ mà team không tái hiện được. Với dữ liệu nhạy cảm theo người dùng, cẩn thận để CDN cache nhầm response cá nhân hóa.

6. Migration: đi strangler fig, đừng big-bang rewrite

Câu hay gặp: “Codebase AngularJS/jQuery/CRA cũ, chuyển sang kiến trúc mới kiểu gì?” Mình không viết lại từ đầu. Rewrite big-bang thường vượt thời gian dự kiến, đóng băng tính năng mới, và rủi ro dồn hết vào ngày go-live.

Strangler fig: dựng hệ thống mới bên cạnh, chuyển từng lát (route hoặc tính năng) sang, hệ thống cũ teo dần.

            ┌────────────── Edge / Reverse proxy ─────────────┐
 Request -> │ /checkout/*  ──> App mới                         │
            │ /*           ──> App cũ (legacy)                 │
            └──────────────────────────────────────────────────┘
 Mỗi sprint: chuyển thêm một route sang App mới, đo, rồi tắt phần cũ

Các bước mình đề xuất:

  1. Chọn lát đầu tiên nhỏ nhưng có giá trị thật, đủ để chứng minh pipeline (build, deploy, observability) chạy được.
  2. Routing ở tầng edge hoặc proxy để chuyển lưu lượng theo đường dẫn, rollout theo phần trăm (feature flag) và rollback nhanh.
  3. Chia sẻ session và auth giữa hai hệ thống để người dùng không phải đăng nhập lại.
  4. Chia sẻ design token để giao diện không bị “đứt gãy” giữa cũ và mới.
  5. Đặt tiêu chí hoàn thành cho mỗi lát (parity về tính năng, không tăng lỗi, hiệu năng không tệ hơn), và đặt ngày tắt phần cũ. Không có ngày tắt thì bạn duy trì hai hệ thống mãi.

Cần thừa nhận một chuyện: giai đoạn chuyển tiếp tốn hơn, vì có hai stack song song. Cái giá đó là có chủ đích — đổi lấy rủi ro thấp hơn.

7. Observability cho frontend: chỗ hệ thống mù nhất

Frontend thường là phần mù nhất. Backend có log với trace, frontend thì chỉ biết khi người dùng phàn nàn. Mình chia làm bốn lớp:

  1. Error tracking: bắt lỗi runtime, unhandled rejection, lỗi tải chunk. Gắn release version và source map để stack trace đọc được, kèm context (route, user id đã ẩn danh, feature flag đang bật).
  2. Real User Monitoring (RUM) cho performance: đo Core Web Vitals (LCP, INP, CLS) từ người dùng thật, phân tách theo thiết bị, mạng, trang. Dữ liệu lab (Lighthouse) chỉ để tham khảo; dữ liệu thật mới quyết định.
  3. Tracing xuyên tầng: gửi header trace (ví dụ W3C traceparent) từ frontend xuống API để nối một thao tác của người dùng với các request backend.
  4. Business/funnel metrics: tỷ lệ hoàn tất checkout, tỷ lệ lỗi theo bước. Một lần deploy có thể không làm tăng lỗi JS nhưng vẫn làm tụt conversion.

Ở cấp Lead, phần đáng nói thêm là gắn observability vào quy trình release:

Deploy -> canary 5% -> so sánh error rate & INP với baseline
       -> nếu vượt ngưỡng: tự động rollback / tắt feature flag
       -> nếu ổn: tăng dần 25% -> 100%

Kèm theo là SLO cho frontend (ví dụ “95% phiên không có lỗi JS nghiêm trọng”, “p75 INP dưới ngưỡng tốt”) và error budget để biết lúc nào nên dừng tính năng mới mà sửa chất lượng. Nói đến chuyện lưu dữ liệu người dùng thì phải nhắc quyền riêng tư — không log PII, masking trong session replay.

8. Khung đánh đổi mình dùng để chốt câu trả lời

Cuối mỗi câu trả lời, mình chạy qua một khung ngắn để lộ ra tư duy trade-off:

1. Vấn đề thật là gì? (đo bằng con số nào)
2. Các phương án khả thi (tối thiểu 2, gồm cả "không làm gì lớn")
3. Mỗi phương án: lợi ích / chi phí / rủi ro / khả năng đảo ngược
4. Quyết định + điều kiện sẽ khiến mình đổi ý
5. Cách đo xem quyết định có đúng không

Ví dụ áp dụng cho câu “6 team, build 25 phút, release xếp hàng”:

  • Phương án A: giữ monolith, thêm monorepo cache và affected test. Rẻ, đảo ngược dễ, giải quyết build chậm nhưng chưa giải quyết xếp hàng release.
  • Phương án B: modular monolith, enforce boundary, release theo feature flag. Giảm phối hợp release mà không cần hạ tầng mới.
  • Phương án C: route-based tách app cho domain ít liên quan (ví dụ checkout), giữ phần còn lại.
  • Phương án D: Module Federation toàn diện. Linh hoạt nhất nhưng đắt nhất.

Mình đề xuất A rồi B trước, đo lại sau một quý. Chi phí phối hợp release vẫn là nút thắt thì mới sang C. Điều kiện đổi ý nói rõ luôn: nếu các team vẫn thường xuyên chờ nhau để release dù đã có feature flag, mình sẽ tách thêm.

9. Câu hỏi bẫy và red flags

Câu hỏi bẫy hay gặp:

  • “Team bạn muốn dùng micro-frontend vì nghe nói Big Tech đều dùng. Bạn nói gì?” Mình không phản đối thẳng. Mình hỏi vấn đề cụ thể đang cần giải quyết, rồi đề nghị đo trước. Người phỏng vấn muốn thấy bạn thuyết phục bằng dữ liệu, không phải bằng quyền.
  • “Làm sao để mọi team dùng đúng design system?” Đáp án tồi là “bắt buộc bằng lint”. Đáp án tốt kết hợp lint với việc làm cho con đường đúng trở thành con đường dễ nhất (tài liệu, codemod, hỗ trợ nhanh).
  • “Bạn chọn công nghệ nào?” Nếu trả lời ngay tên công cụ mà không hỏi bối cảnh, đó là một red flag.

Red flags từ phía ứng viên:

  • Đề xuất giải pháp phức tạp ngay lập tức, không nêu chi phí.
  • Không nói được khi nào giải pháp của mình là sai.
  • Không có số đo nào cho thành công.
  • Chỉ nói về công nghệ, không nói về con người và quy trình.

Red flags từ phía công ty (đáng để bạn để ý khi là người được phỏng vấn):

  • Muốn “chuyển sang micro-frontend” nhưng không ai mô tả được vấn đề cụ thể.
  • Không có ai sở hữu CI/CD hay design system, nhưng mong Lead “xử lý hết”.
  • Không có observability cơ bản ở frontend mà vẫn hỏi về hiệu năng.

Vòng Lead chấm cái gì

Vòng frontend system design ở cấp Lead gần như không chấm kiến thức công cụ. Nó chấm phán đoán: bạn có phân biệt được vấn đề tổ chức với vấn đề kỹ thuật không, có chọn giải pháp rẻ nhất đủ giải quyết vấn đề không, và có nói được cách biết mình đã sai không.

Ba câu mình tự hỏi trước khi rời phòng: vấn đề này đo bằng con số nào, phương án rẻ hơn là gì, và điều kiện nào sẽ khiến mình đổi ý.

Cần người cho dự án contract hoặc freelance?

Mình nhận dự án fullstack — backend Go, frontend React/Next.js, hoặc cả hai đầu. Gửi vài dòng về phạm vi công việc và thời gian, mình phản hồi trong 48 giờ.