GĐ26 — Frontend Architecture: ứng dụng mở rộng, Design System, Micro Frontends & Module Federation

Tài liệu dành cho frontend engineer đã làm được một SPA/React app và muốn giữ codebase, UI consistency cùng nhịp giao hàng khi sản phẩm và số team lớn lên. Giai đoạn này bắt đầu từ kiến trúc modular trong một ứng dụng, rồi mới xem xét chia ứng dụng thành nhiều phần deploy độc lập.

Kiểm chứng ngày 2026-10-05: các đoạn code mới ở GĐ này được cài và chạy trong thư mục tạm (không nằm trong repo) với ESLint 10.12, typescript-eslint 8.71, eslint-plugin-boundaries 7.2, @module-federation/runtime 2.9.2, @module-federation/vite 1.23, webpack 5.111, TanStack Query 5.104, Changesets CLI 3.0, Playwright 1.63, @axe-core/playwright, React 19.3, TypeScript 6.0, Chrome thật điều khiển bằng Playwright. Nguồn tra cứu: module-federation.io, eslint-plugin-boundaries, Changesets, TanStack Query, Design Tokens Format 2025.10. Những gì mỗi mục đã chạy hay chưa được ghi ngay tại mục đó; Radix và React Aria chỉ được kiểm là gói có trên npm, chưa dựng thử.


1. Kết quả cần đạt#

Sau giai đoạn này, bạn có thể:

  • Chia frontend theo năng lực nghiệp vụ và luồng người dùng, thay vì chỉ chia theo loại file.
  • Thiết lập ranh giới module có public API, dependency rule, route ownership và test tương ứng.
  • Thiết kế Design System có token, component API, trạng thái, accessibility, tài liệu, release/versioning và quy trình nhận đóng góp.
  • Giải thích Micro Frontend giải quyết bài toán tổ chức đội ngũ và triển khai độc lập như thế nào, cùng chi phí về tải, vận hành và governance.
  • Phân biệt package/component library, route-level app, server-side composition, iframe/Web Component và runtime Module Federation.
  • Dựng một host + remote bằng Module Federation ở mức minh họa, hiểu remotes, exposes, shared, version negotiation, remote failure và rollback.
  • Đưa ra quyết định có bằng chứng: modular monolith, monorepo package hay Micro Frontend phù hợp với quy mô nào.

Điều kiện vào#

Bạn nên đọc JavaScript/TypeScript, viết được HTML/CSS, dùng một UI framework và hiểu route, component state, npm package cùng quá trình build/deploy frontend. Kiến thức backend/CI ở các giai đoạn trước giúp hiểu API, auth và pipeline, nhưng GĐ26 là nhánh chuyên sâu frontend; không cần làm xong GĐ25 Capstone mới được học.


2. Kiến trúc frontend là gì?#

Kiến trúc không phải cây thư mục trông “enterprise”. Nó là các quyết định có chủ đích về:

  1. Ranh giới: tính năng nào sở hữu state, API, route và UI nào?
  2. Hướng phụ thuộc: phần nào được phép import phần nào?
  3. Giao tiếp: module trao đổi dữ liệu qua function, event, route, API hay message contract?
  4. Thay đổi: một yêu cầu nghiệp vụ bình thường sửa bao nhiêu module và có cần phối hợp bao nhiêu team?
  5. Chất lượng: lỗi permission, render lại, bundle tăng và visual regression bị phát hiện ở đâu?

Kiến trúc tốt giúp một thay đổi có phạm vi đoán trước. Ví dụ: thay cách tải danh sách sản phẩm nên chủ yếu ở feature catalog; không làm luồng checkout, navigation và mọi package UI cùng thay đổi.

Chia theo feature / vertical slice#

Một tổ chức đơn giản có thể bắt đầu như sau:

textReady
src/  app/                 # bootstrap, router, providers, layout toàn ứng dụng  features/    catalog/           # route, UI, state, API adapter, types của catalog    cart/              # giỏ hàng    checkout/          # checkout    account/            # hồ sơ và cài đặt tài khoản  shared/    ui/                # primitive UI trung tính: Button, Dialog, TextField    lib/               # helper dùng chung, không chứa nghiệp vụ    api/               # client nền, error mapping, request ID

features/checkout có thể dùng primitive shared/ui/Button, nhưng shared/ui không import features/checkout. Feature không nên đọc thẳng internal file của feature khác; trao đổi qua public API hẹp hoặc tầng ứng dụng điều phối.

Ví dụ public API:

typescriptReady
// features/catalog/index.tsexport { CatalogRoutes } from './routes/CatalogRoutes'export { useProductSearch } from './api/useProductSearch'export type { Product, ProductFilter } from './model/types'

Consumer import từ package boundary:

typescriptReady
import { CatalogRoutes } from '@/features/catalog'

Tránh:

typescriptReady
// Coupling vào cấu trúc nội bộ, đổi tên file có thể phá consumer.import { ProductCard } from '@/features/catalog/components/internal/ProductCard'

Public barrel chỉ có ích nếu nó thật sự là contract. Đừng export mọi file vì “mai mốt tiện”; mỗi export làm tăng bề mặt API cần giữ tương thích.

Phân ranh component#

  • Primitive: button, text field, dialog, menu. Không biết order/catalog/user cụ thể.
  • Domain component: ProductPrice, OrderStatus, AddressForm. Hiểu domain nhưng không sở hữu cả page.
  • Feature/page: điều phối query, mutation, loading/empty/error state và các component domain.
  • Application shell: routing, session, navigation toàn app, global providers, error boundaries.

Không có một luật “component phải dưới 200 dòng” đủ tốt cho mọi dự án. Hãy tách khi một module có hơn một lý do thay đổi, public API khó hiểu, test setup nặng hoặc một thay đổi kéo theo sửa nhiều consumer.


3. Ranh giới dữ liệu, route và state#

Chọn đúng nơi giữ state#

Loại stateVí dụNơi phù hợp
Tạm thời của một controldialog đang mở, input chưa submitlocal component state
Theo URL, có thể bookmark/sharefilter, sort, page, tabroute/query string
Dữ liệu từ serverproduct list, order detailquery/cache layer có invalidation rõ
State cross-cutting của appsession hiện tại, localeapplication provider/store với owner rõ
State bền giữa lần mởdraft, offline queueIndexedDB/server; có schema version và cleanup
State giữa các micro frontendcurrent route, identitycontract tối thiểu từ shell; tránh global mutable store dùng chung

Không đưa mọi state vào một global store. Global hóa làm thay đổi nhỏ ảnh hưởng khó theo dõi, mở rộng quyền ghi và khiến feature không còn tự kiểm soát lifecycle của state.

API adapter theo feature#

typescriptReady
// features/catalog/api/products.tsexport type ProductFilter = { query?: string; categoryId?: string }export async function searchProducts(filter: ProductFilter, signal?: AbortSignal) {  const url = new URL('/api/products', window.location.origin)  if (filter.query) url.searchParams.set('q', filter.query)  if (filter.categoryId) url.searchParams.set('categoryId', filter.categoryId)  const response = await fetch(url, { signal })  if (!response.ok) throw new Error(`Product search failed: ${response.status}`)  return response.json() as Promise<{ items: Product[]; nextCursor?: string }>}

Feature giữ mapping từ giao thức API sang kiểu UI của nó. Đừng để component rải URL, header, retry policy và cách parse lỗi khắp nơi.

Gắn adapter vào cache dữ liệu server bằng TanStack Query#

Adapter ở trên chỉ biết gọi API. Việc cache, dedupe, hủy request và invalidate sau mutation thuộc về một query layer; TanStack Query là lựa chọn phổ biến cho React. Hook nằm trong feature và chỉ hook này được export qua public API:

typescriptReady
// features/catalog/api/useProductSearch.tsimport { useQuery } from '@tanstack/react-query'import { searchProducts, type ProductFilter } from './products'// Key có cấu trúc: invalidate được cả nhóm ['catalog', 'products'] sau mutation.export const productKeys = {  all: ['catalog', 'products'] as const,  search: (filter: ProductFilter) => [...productKeys.all, 'search', filter] as const,}export function useProductSearch(filter: ProductFilter) {  return useQuery({    queryKey: productKeys.search(filter),    // `signal` do TanStack Query cấp: hủy request khi key đổi hoặc component unmount.    queryFn: ({ signal }) => searchProducts(filter, signal),    staleTime: 30_000,  })}

Quy tắc đi kèm: mọi giá trị làm đổi kết quả (filter) phải nằm trong queryKey; staleTime là cam kết "dữ liệu này đủ mới bao lâu", không phải mặc định tùy tiện; một QueryClient cho cả ứng dụng do shell tạo, feature và remote chỉ dùng hook. Nếu remote và shell không chia sẻ cùng instance @tanstack/react-query thì hai bên có hai cache riêng (xem phần shared ở mục 7). Bản này qua tsc --strict (đã chạy, sạch); chưa chạy với server thật.

Kiểm tra dependency rule#

Đặt lint rule hoặc dependency graph check để chặn:

  • shared import features;
  • feature A import internal path của feature B;
  • module UI gọi trực tiếp module route cấp cao;
  • code trình duyệt import build config hoặc secret tooling;
  • vòng phụ thuộc giữa các feature.

Khi codebase nhỏ, import discipline và vài module boundary đủ dùng. Thêm Nx/Turborepo/dep graph khi nhiều package hoặc team thật sự cần cache, affected builds hay enforce ownership — không thêm công cụ chỉ vì repo có vẻ lớn.

Cấu hình ESLint flat để máy chặn import sai tầng#

ESLint 10 chỉ đọc flat config (eslint.config.js). Plugin eslint-plugin-boundaries 7 gán mỗi file vào một "element" theo đường dẫn, rồi boundaries/dependencies quyết định element nào được import element nào. Cấu hình dưới đây mã hóa đúng cây app / features / shared ở mục 2: app chỉ vào feature qua index.ts, feature chỉ vào shared, trong chính nó, hoặc index.ts của feature khác, shared chỉ vào shared.

typescriptReady
// eslint.config.jsimport { defineConfig } from 'eslint/config'import tseslint from 'typescript-eslint'import boundaries from 'eslint-plugin-boundaries'export default defineConfig(  { ignores: ['dist/**', 'node_modules/**'] },  {    files: ['src/**/*.{ts,tsx}'],    languageOptions: { parser: tseslint.parser },    plugins: { boundaries },    settings: {      // Không có resolver thì plugin không phân giải được import và im lặng bỏ qua.      'import/resolver': { typescript: { project: './tsconfig.json' } },      'boundaries/elements': [        { type: 'app', pattern: 'src/app' },        { type: 'feature', pattern: 'src/features/*', capture: ['name'] },        { type: 'shared', pattern: 'src/shared' },      ],    },    rules: {      'boundaries/dependencies': ['error', {        default: 'disallow',        policies: [          { from: { element: { type: 'app' } },            allow: { to: { element: { type: 'feature', fileInternalPath: 'index.ts' } } } },          { from: { element: { type: 'app' } }, allow: { to: { element: { type: 'shared' } } } },          { from: { element: { type: 'feature' } }, allow: { to: { element: { type: 'shared' } } } },          { from: { element: { type: 'feature' } },            allow: { to: { element: { type: 'feature',              captured: { name: '{{ from.element.captured.name }}' } } } } },          { from: { element: { type: 'feature' } },            allow: { to: { element: { type: 'feature', fileInternalPath: 'index.ts' } } } },          { from: { element: { type: 'shared' } }, allow: { to: { element: { type: 'shared' } } } },        ],      }],    },  },)

Cần thêm eslint-import-resolver-typescript cùng typescript-eslint vào devDependencies. Đã chạy (ESLint 10.12, plugin 7.2) trên một cây mẫu có hai lỗi cố ý: features/cart import ../catalog/components/ProductCard và shared/ui/Dialog import features/catalog. Kết quả quan sát: npx eslint . thoát với 2 lỗi boundaries/dependencies, mỗi lỗi nêu cặp element bị cấm (feature name="cart" tới feature name="catalog", và shared tới feature), còn import qua ../catalog (index) không bị báo. Trước khi thêm dòng import/resolver, cùng cây đó báo 0 lỗi dù có vi phạm: đó là lỗi cấu hình dễ gặp nhất, nên luôn cố tình viết một import sai để chắc rằng rule đang chạy. Chạy lệnh này như một bước CI bắt buộc: cách dựng pipeline ở GĐ15 mục 8, cách gắn kiểm tra vào CI ở GĐ13 mục 14.


4. Mở rộng ứng dụng: monolith, package hay Micro Frontend?#

Micro Frontend là một cách phân phối và sở hữu UI, không phải cấp độ trưởng thành bắt buộc sau React. Bắt đầu bằng modular frontend trong một SPA thường có ít chi phí nhất.

Lựa chọnKhi hợpTrade-off chính
Modular monolithMột team hoặc vài team phối hợp được; deploy chung vẫn đáp ứng nhu cầuBuild/release chung, cần giữ module boundary bằng code review/lint
Shared package trong monorepoMuốn chia component/util, version code cùng release của appConsumer thường vẫn build/deploy lại; chưa phải runtime independent deployment
Route-level applications + reverse proxy/server compositionDomain/route có owner/deploy riêng, composition ở navigation/serverCross-app navigation, shared shell, cache/headers và observability cần vận hành
IframeCần cách ly mạnh hoặc nhúng sản phẩm bên thứ baUX, sizing, focus, auth, communication và accessibility phức tạp
Web ComponentsMuốn trao đổi qua HTML/custom element contract hoặc phục vụ nhiều frameworkStyling, event API, lifecycle và SSR/hydration phải thiết kế rõ
Runtime Module FederationMuốn host nạp code từ build khác khi chạy, remote release độc lậpRuntime dependency graph, version drift, tải lỗi, security và debugging phức tạp

Dấu hiệu đáng xem xét Micro Frontend#

  • Nhiều team sở hữu domain/luồng người dùng riêng và thường bị nghẽn do phải release chung.
  • Các ranh giới nghiệp vụ và UX đủ rõ để một team có thể chịu trách nhiệm từ UI đến API contract.
  • Có nhu cầu nâng cấp dần hệ thống cũ hoặc công nghệ theo từng domain, không thể rewrite toàn bộ.
  • Đã có pipeline, monitoring, rollback, versioned contract và người chịu trách nhiệm vận hành remote.

Dấu hiệu chưa nên chia#

  • “Code nhiều file” nhưng một team vẫn làm việc hiệu quả trong repo hiện tại.
  • Các module cùng sửa navigation/global state/design system mỗi sprint.
  • Các team chỉ tách theo loại kỹ thuật (team CSS, team forms, team routing) thay vì domain.
  • Chưa có quyền owner, compatibility test, observability hoặc rollback.
  • Tách từng component nhỏ thành remote nhưng host phải chờ hàng chục request/remote mới render được trang.

MFE có thể làm team độc lập hơn nhưng không làm nghiệp vụ tự nhiên ít phụ thuộc hơn. Nếu hai remote cần đồng bộ deploy mỗi lần đổi contract, bạn có thể đã tạo distributed monolith cùng thêm network latency. Cùng bài toán ở phía backend được phân tích ở GĐ20 mục 3: modular monolith trước, tách sau khi có bằng chứng.

Monorepo bằng npm workspaces#

Bước trung gian rẻ nhất giữa một app và nhiều app là một repo, nhiều package cùng version control. Chỉ cần npm workspaces, chưa cần công cụ build:

jsonReady
{  "name": "storefront",  "private": true,  "workspaces": ["apps/*", "packages/*"],  "scripts": { "lint": "eslint .", "build": "npm run build --workspaces --if-present" }}
textReady
apps/shell                 app chạy được, phụ thuộc hai package bên dướipackages/design-system     tokens + component, có version riêngpackages/catalog           feature đóng gói, import design-system

Đã chạy (npm 11, thư mục tạm): npm install ở gốc tạo symlink node_modules/@acme/* trỏ vào packages/* và apps/*; npm ls --workspaces cho thấy @acme/catalog yêu cầu design-system@^1.2.0 được thỏa bằng đúng bản 1.3.0 trong repo (deduped), và import('@acme/catalog') chạy qua symlink. Điều đó chứng minh package dùng chung theo version cùng một lần build, không phải triển khai độc lập: đổi design-system vẫn phải build và deploy lại app. Thêm Nx hay Turborepo khi cần cache build hoặc "chỉ build phần bị ảnh hưởng"; đừng thêm trước khi đo được thời gian build là nút thắt.

Import map và custom element, phương án nhẹ hơn Federation#

Khi remote chỉ là một widget độc lập, trình duyệt đã đủ cơ chế: import map ánh xạ tên module sang URL có version, custom element là ranh giới component giữa các framework, Shadow DOM cô lập CSS, CustomEvent mang contract.

textReady
<script type="importmap">{ "imports": { "catalog-widget": "/catalog/v1/catalog-widget.js" } }</script><catalog-widget category="shoes"></catalog-widget><script type="module">  import 'catalog-widget'  document.addEventListener('cart.updated.v1', (e) => console.log(e.detail.itemCount))</script>
typescriptReady
// catalog-widget.js (remote, không phụ thuộc framework của shell)class CatalogWidget extends HTMLElement {  static observedAttributes = ['category']  #root = this.attachShadow({ mode: 'open' })  connectedCallback() { this.#render() }  attributeChangedCallback() { if (this.isConnected) this.#render() }  #render() {    const category = this.getAttribute('category') ?? 'all'    this.#root.innerHTML = `<style>button{font:inherit}</style>      <p>catalog v1: ${category}</p><button type="button">Thêm vào giỏ</button>`    this.#root.querySelector('button').onclick = () =>      this.dispatchEvent(new CustomEvent('cart.updated.v1',        { bubbles: true, composed: true, detail: { itemCount: 1 } }))  }}customElements.define('catalog-widget', CatalogWidget)

Đã chạy (Chrome, server tạm): bấm "Thêm vào giỏ" hai lần làm <output> của shell lên 2 nhờ sự kiện đi xuyên Shadow DOM (composed: true); đổi đường dẫn trong import map từ /catalog/v1/ sang /catalog/v2/ thì widget in catalog v2 mà không sửa mã shell. Giới hạn: không có thương lượng phiên bản dependency, không có type tự động, SSR và hydration phải tự lo (xem bảng ở đầu mục). Nếu nhu cầu dừng ở "nhúng một widget có version", đây là chi phí thấp hơn Federation; khi remote cần chia sẻ React singleton với host, quay lại mục 7.


5. Design System — nền tảng, component, governance#

Design System là hệ thống giúp đội ngũ tạo giao diện thống nhất và tiếp tục thay đổi an toàn. Nó thường gồm design tokens, component primitives, patterns, accessibility rules, tài liệu, ownership và cách phát hành. Một thư viện button đơn lẻ chưa phải Design System.

Lớp cấu thành#

  1. Design foundations: màu, typography, spacing, radius, elevation, motion, breakpoints, focus ring.
  2. Semantic tokens: ý nghĩa dùng ở UI (text.primary, surface.canvas, action.primary) độc lập khỏi palette thô.
  3. Primitives: Button, TextField, Checkbox, Dialog, Tooltip, Select, Table.
  4. Patterns: form validation, empty state, pagination, confirm destructive action, filter bar.
  5. Guidance: dùng ở đâu, accessibility behavior, responsive states, do/don't, migration.
  6. Governance: owner, contribution proposal, review, deprecation window, release notes và support.

Primitive tokens và semantic tokens#

Primitive token mô tả giá trị palette/scale; semantic token diễn tả vai trò. Component nên dùng semantic token để đổi theme/brand mà không thay từng component.

Ví dụ định dạng JSON của Design Tokens Community Group, theo Format Module 2025.10 (đây là Final Community Group Report của một W3C Community Group, không phải tiêu chuẩn W3C và không nằm trên W3C Standards Track):

jsonReady
{  "color": {    "blue": {      "600": {        "$type": "color",        "$value": { "colorSpace": "srgb", "components": [0.0902, 0.3059, 0.651], "hex": "#174ea6" }      }    },    "action": {      "primary": {        "background": { "$type": "color", "$value": "{color.blue.600}" },        "foreground": {          "$type": "color",          "$value": { "colorSpace": "srgb", "components": [1, 1, 1], "hex": "#ffffff" }        }      }    }  },  "space": {    "4": { "$type": "dimension", "$value": { "value": 1, "unit": "rem" } }  }}

Điểm khác với bản draft cũ (chuỗi "#174ea6", "1rem"): ở 2025.10, $value của color là object có colorSpace và components (với srgb, mỗi component là số từ 0 đến 1), alpha và hex là tùy chọn (không có alpha thì coi là 1). $value của dimension là { "value": số, "unit": "px" | "rem" }. Alias vẫn viết "{color.blue.600}". components ở trên là #174ea6 chia 255, làm tròn 4 chữ số; để hex làm fallback cho công cụ chưa hiểu colorSpace. Công cụ build token (ví dụ Style Dictionary 5 với usesDtcg: true) tự đổi object này ra #174ea6 và 1rem khi sinh CSS; chọn công cụ và version đang hỗ trợ đúng bản spec bạn dùng, vì nhiều tool vẫn đọc cả kiểu chuỗi cũ.

Spec 2025.10 chưa kèm JSON Schema hay validator chính thức (mục editor's note nói nhóm đang cân nhắc thêm), nên cách kiểm là đối chiếu với spec và cho token đi qua công cụ build thật.

Có thể build ra CSS custom properties:

textReady
:root {  --color-action-primary-background: #174ea6;  --color-action-primary-foreground: #ffffff;  --space-4: 1rem;}[data-theme="dark"] {  --color-action-primary-background: #a8c7fa;  --color-action-primary-foreground: #10213b;}

Token names nên biểu đạt ý nghĩa, không khóa vào giá trị hiện tại. text-danger là semantic; red-600 là palette. Đừng tạo hàng trăm token trước khi có ví dụ UI và cách duy trì chúng.

Component API là contract#

Một component hệ thống cần có:

  • API hẹp, có kiểu và không nhận mọi prop implementation của DOM một cách vô kiểm soát;
  • trạng thái mặc định, loading, disabled, error, focus, hover, pressed, empty rõ;
  • keyboard behavior và semantic HTML đúng;
  • nội dung tùy chỉnh qua slot/children thay vì yêu cầu fork component;
  • ví dụ và story cho các trạng thái quan trọng;
  • policy về breaking changes, deprecation và phiên bản.

Ví dụ API:

tsxReady
import type { ButtonHTMLAttributes } from 'react'type ButtonProps = {  variant?: 'primary' | 'secondary' | 'danger'  size?: 'sm' | 'md' | 'lg'  loading?: boolean} & Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'aria-busy'>export function Button({  variant = 'primary',  size = 'md',  loading = false,  type = 'button',  disabled,  className,  children,  ...buttonProps}: ButtonProps) {  const classes = ['button', `button--${variant}`, `button--${size}`, className]    .filter(Boolean)    .join(' ')  return (    <button      {...buttonProps}      type={type}      className={classes}      disabled={disabled || loading}      aria-busy={loading || undefined}    >      {children}    </button>  )}

Hai chi tiết dễ sai trong bản Button: đặt className sau {...buttonProps} mà không gộp sẽ làm mất className của consumer; thiếu type="button" thì nút trong <form> mặc định là submit và gửi form ngoài ý muốn. Bản trên đã qua tsc --strict (đã chạy, sạch).

loading không thay cho accessible label. Với action async, cần quyết định focus, thông báo kết quả qua live region và chống submit lặp. Với destructive action, “danger” chỉ là visual variant; nghiệp vụ xác nhận vẫn nằm ở consumer/pattern.

Publish và vận hành Design System#

  • Có package source of truth, changelog, owner và release cadence.
  • Dùng semver nếu consumer dựa vào version; breaking prop/behavior cần migration guide.
  • Chạy typecheck, component test, visual review và accessibility audit trên các trạng thái đại diện.
  • Tạo story/docs cho component mới; tài liệu cho người dùng component, không chỉ nội bộ tác giả.
  • Có quy trình contribution: request → RFC ngắn → prototype/story → review design + engineering + accessibility → release.
  • Theo dõi adoption và deprecated API; không để một component cũ vĩnh viễn vì không có chủ dọn.
  • Đừng buộc mọi sản phẩm dùng chung mọi pattern nếu khác nhau thật; standardize primitive/contract, cho phép domain composition.

Storybook có thể giúp xem, tài liệu hóa và kiểm tra các trạng thái component; chọn công cụ tương ứng với stack hiện tại, không để docs chạy lệch khỏi production component.

Tự xây hay dùng headless primitive#

Dialog, Menu, Select, Combobox là loại component khó làm đúng bàn phím, focus trap, aria-* và đọc bằng screen reader. Quyết định theo ba câu hỏi: component có hành vi tương tác phức tạp không, đội có người đủ chuyên môn a11y để bảo trì không, và cần giao diện riêng đến đâu.

CáchHợp khiChi phí
HTML gốc (<button>, <dialog>, <select>)Hành vi trình duyệt đã đủÍt tùy biến giao diện; kiểm hỗ trợ trình duyệt
Headless primitive (Radix: gói @radix-ui/react-dialog; React Aria: gói react-aria-components)Cần hành vi chuẩn, giao diện tự làm theo tokenThêm dependency, tuân theo API và lịch phát hành của họ
Tự viết toàn bộHành vi rất khác thường, hoặc không dùng được thư việnTự chịu mọi lỗi a11y và regression

Hai gói trên có trên npm (đã kiểm khi soạn); phần API chi tiết và trạng thái hỗ trợ React 19 chưa dựng thử, hãy đọc tài liệu đúng bản bạn cài. Dù chọn cách nào, test a11y vẫn thuộc về design system: axe bắt được một nhóm lỗi tĩnh, không thay thế pass bàn phím bằng tay.

typescriptReady
// ds.spec.ts (Playwright + @axe-core/playwright)import { test, expect } from '@playwright/test'import AxeBuilder from '@axe-core/playwright'test('icon button phải có tên', async ({ page }) => {  await page.setContent(`<!doctype html><html lang="vi"><title>t</title>    <main><h1>Demo</h1>    <button type="button"><svg width="16" height="16" aria-hidden="true"></svg></button></main></html>`)  const result = await new AxeBuilder({ page }).analyze()  expect(result.violations.map((v) => v.id)).toContain('button-name')})

Đã chạy (Playwright 1.63 với Chrome thật): axe báo button-name cho nút chỉ có icon, và hết lỗi sau khi thêm aria-label="Đóng". Fixture đầu tiên còn bị báo page-has-heading-one vì thiếu <h1>: axe đánh giá cả trang, nên fixture phải là một trang hợp lệ.

Phát hành bằng Changesets và chặn regression giao diện#

Changesets ghi ý định phát hành thành file nhỏ nằm trong PR, rồi gom lại thành version và changelog. Cấu hình tối thiểu:

jsonReady
{  "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",  "changelog": "@changesets/cli/changelog",  "commit": false,  "access": "restricted",  "baseBranch": "main",  "updateInternalDependencies": "patch",  "ignore": []}

Quy trình: PR đổi design system chạy npx changeset (chọn package, chọn patch/minor/major, viết một câu mô tả); khi phát hành chạy npx changeset version để tăng version, cập nhật CHANGELOG.md và bump dependency nội bộ, rồi npx changeset publish. Đã chạy changeset version trên workspace mẫu: một changeset minor cho @acme/design-system đưa nó từ 1.2.0 lên 1.3.0, sinh dòng changelog "Button: thêm prop loading ..." và cập nhật ^1.3.0 trong apps/shell. Chưa chạy publish (cần registry).

Visual regression bắt thay đổi giao diện mà test logic không thấy. Với Playwright:

typescriptReady
test('button primary khớp ảnh chuẩn', async ({ page }) => {  await page.setContent(buttonHtml)  await expect(page.locator('button')).toHaveScreenshot('button-primary.png')})

Đã chạy: lần đầu cần npx playwright test --update-snapshots để tạo ảnh chuẩn (không có ảnh chuẩn thì test fail); các lần sau pass; đổi màu nút từ #174ea6 sang #b3261e làm test fail với báo cáo số pixel khác. Ảnh chuẩn phụ thuộc font và anti-aliasing của máy, nên chạy visual test trong cùng môi trường CI (cùng image, cùng trình duyệt) và duyệt ảnh mới như duyệt code.


6. Micro Frontend: ownership, composition, contract#

Micro Frontend là cách ghép nhiều frontend app có thể phát triển/phát hành độc lập thành một trải nghiệm thống nhất. Ranh giới tốt thường là vertical slice của domain như Catalog, Billing, Account; tránh chia ngang theo Button, CSS hay form.

Application shell sở hữu phần giao nhau#

Shell thường giữ:

  • URL/navigation và route table toàn ứng dụng;
  • global chrome như header/footer và layout;
  • đăng nhập/đăng xuất, lấy identity và cách truyền session an toàn;
  • design-system entrypoint và theme;
  • error/loading boundary, telemetry, feature flag và fallback khi remote hỏng.

Remote sở hữu:

  • route/feature/domain UI của mình;
  • query, local state và adapter tới API domain;
  • tests, pipeline, artifact/version và on-call/owner;
  • semantic events cần thông báo cho shell hoặc feature khác.

Shell không nên đọc internal store của remote; remote không nên sửa shell navigation bằng global variable. Trao đổi qua contract có version, ví dụ route, typed props ở ranh giới, custom event hoặc API server.

Ví dụ event contract:

typescriptReady
type CartUpdatedV1 = {  type: 'cart.updated.v1'  detail: { itemCount: number }}window.dispatchEvent(new CustomEvent<CartUpdatedV1['detail']>(  'cart.updated.v1',  { detail: { itemCount: 3 } },))

Trong app thật, bọc event bằng adapter có schema/runtime validation và contract test. Tên event tự do không thay thế versioning hay documentation.

Authentication và dữ liệu#

  • Shell xác thực user; remote chỉ nhận thông tin cần thiết hoặc gọi API theo cơ chế được thống nhất.
  • Không phát tán access token qua window.__GLOBAL_AUTH__ nếu có lựa chọn an toàn hơn như cùng-origin secure session/API gateway.
  • Backend vẫn phải authorize mọi request; frontend remote không phải security boundary.
  • Xác định CORS, cookie SameSite, credential behavior, CSP, logout/revocation và API error contract.
  • Không giữ hai bản global session state có thể lệch nhau; chọn owner và cách refresh.

Chốt một bên sở hữu history/router, deep link, 404, browser back/forward và route collision. CSS nên có scope/prefix hoặc Shadow DOM nơi phù hợp; tránh global button, h1 selector đè app khác. Reset, font loading, z-index/modal layer và focus management cần contract chung.

Failure containment#

  • Nếu remote load lỗi: fallback có thông báo và nút retry; shell/navigation phần khác vẫn dùng được.
  • Timeout remote API/load không được treo toàn app; lazy-load route khi người dùng đến đó.
  • Theo dõi remote version, host version, browser, route, request ID và load error.
  • Có kill switch/roll back URL về artifact trước; deploy remote độc lập không có nghĩa là không cần tương thích ngược.

7. Module Federation — dynamic module sharing#

Module Federation là một cách để nhiều build độc lập expose và consume module ở runtime. Trong Webpack, mỗi build vừa có thể đóng vai host/container vừa đóng vai remote/container. Đây là một kỹ thuật triển khai Micro Frontend, không phải định nghĩa của Micro Frontend; MFE cũng có thể dùng server-side composition hoặc route-level deploy mà không dùng Federation.

Thuật ngữ#

  • Host: app khởi tạo trang, router/shell và nạp remote.
  • Remote: build expose module (route/page/widget) cho host.
  • Remote entry: metadata/runtime entry dùng để tải exposed module và chunk liên quan.
  • exposes: module remote cho consumer dùng.
  • remotes: container mà host biết cách tải.
  • shared: dependency có thể dùng chung trong share scope thay vì mỗi build đóng gói riêng.

Ví dụ Webpack Module Federation, rút gọn. Cấu hình thực tế khác nhau giữa Webpack, Rspack, Rsbuild, Vite plugin và framework; dùng integration đúng bundler/version của repo.

Phân biệt hai thứ dễ bị gộp: Module Federation 2.0 là tên sản phẩm/bộ tính năng (manifest mf-manifest.json, runtime init/loadRemote/registerRemotes/loadShare, hỗ trợ nhiều bundler), còn @module-federation/enhanced là gói npm, hiện ở dòng phiên bản 2.x (2.0.0 phát hành trên npm ngày 2026-02-06). Tên "MF 2.0" đã dùng từ khi gói còn ở 0.x, nên đừng suy từ tên sản phẩm ra số phiên bản gói. Ví dụ dưới dùng ModuleFederationPlugin của webpack 5 cho dễ đọc; với @module-federation/enhanced bạn import plugin từ gói đó (ví dụ @module-federation/enhanced/webpack) và các option name/exposes/remotes/shared giữ nghĩa tương tự. Cách import, hỗ trợ manifest và type hint khác nhau theo bundler và version: đọc tài liệu module-federation.io đúng bản bạn cài, không chép nguyên ví dụ này.

Trong webpack.config.js, dependencies ở các ví dụ dưới lấy từ package.json của chính app: const { dependencies } = require('./package.json') (CommonJS) hoặc import pkg from './package.json' with { type: 'json' } rồi dùng pkg.dependencies (ESM); requiredVersion nhờ đó luôn khớp phiên bản đã cài.

Remote catalog:

typescriptReady
new ModuleFederationPlugin({  name: 'catalog',  filename: 'remoteEntry.js',  exposes: {    './Routes': './src/routes/CatalogRoutes',  },  shared: {    react: { singleton: true, requiredVersion: dependencies.react },    'react-dom': { singleton: true, requiredVersion: dependencies['react-dom'] },  },})

Host shell:

typescriptReady
new ModuleFederationPlugin({  name: 'shell',  remotes: {    catalog: 'catalog@https://cdn.example.com/catalog/remoteEntry.js',  },  shared: {    react: { singleton: true, requiredVersion: dependencies.react },    'react-dom': { singleton: true, requiredVersion: dependencies['react-dom'] },  },})

Host dùng route-level lazy load:

tsxReady
const CatalogRoutes = React.lazy(() =>  import('catalog/Routes').then(({ CatalogRoutes }) => ({ default: CatalogRoutes })),)function CatalogRoute() {  return (    <RemoteErrorBoundary fallback={<CatalogUnavailable />}>      <React.Suspense fallback={<PageSkeleton />}>        <CatalogRoutes />      </React.Suspense>    </RemoteErrorBoundary>  )}

Webpack TypeScript consumer thường cần declaration cho module remote hoặc types plugin/manifest:

typescriptReady
declare module 'catalog/Routes' {  import type { ComponentType } from 'react'  export const CatalogRoutes: ComponentType}

Shared dependency là một policy, không phải “cài một lần miễn phí”#

singleton: true hợp với thư viện cần một instance/runtime như React context; nhưng khi host và remote mong major version khác nhau, có thể gây runtime error hoặc fallback ngoài dự kiến. Shared dependency giảm duplication khi cùng version tương thích, nhưng thêm version negotiation và coupling release. Chia sẻ càng nhiều, ranh giới dependency càng chặt.

Chỉ share thứ đã được đo và cần singleton/giảm transfer. Để dependency khác bundled nếu rủi ro sharing lớn hơn bytes tiết kiệm. Ghi rõ:

  • ai cung cấp version chuẩn;
  • requiredVersion / fallback behavior;
  • singleton có bắt buộc không;
  • quy trình nâng React/design-system;
  • compatibility matrix host × remote;
  • remote có được deploy mà không release host không.

Artifact, cache và rollback#

  • Tên runtime build phải duy nhất (output.uniqueName trong Webpack); tránh collision giữa host/remotes.
  • Phát hành manifest/remote entry theo version bất biến hoặc có strategy cập nhật minh bạch.
  • Upload chunk assets trước khi trỏ remote entry tới version mới để tránh manifest trỏ file chưa tồn tại.
  • Giữ artifact trước đó và rollback bằng config/manifest thay vì rebuild lại “phiên bản gần giống”.
  • Host phải xử lý timeout/404/CSP/CORS/runtime mismatch và remote load ở browser cũ.
  • Không để remote URL từ user-controlled input; chỉ nạp artifact từ origin được allowlist, áp CSP phù hợp và review supply chain.
  • Đưa việc tải remote vào performance budget: remote entry, chunk count, shared fallback, duplicate library, cache hit và route first render.

Entry bất đồng bộ để sửa lỗi eager consumption#

Lỗi Shared module is not available for eager consumption xảy ra khi file entry import react ngay, trước khi runtime Federation kịp thương lượng share scope. Cách sửa là để entry chỉ làm một việc: nạp bất đồng bộ file bootstrap chứa mã thật.

typescriptReady
// src/index.js (entry của webpack): chỉ có một dòngimport('./bootstrap')
typescriptReady
// src/bootstrap.js: mã ứng dụng như thườngimport React from 'react'import { createRoot } from 'react-dom/client'createRoot(document.getElementById('root'))  .render(React.createElement('p', null, 'hello ' + React.version))

Đã chạy (webpack 5.111, React 19.3, Chrome): cùng cấu hình ModuleFederationPlugin với shared: { react: { singleton: true, ... } }, entry đồng bộ (import React ... ngay trong index.js) cho trang trắng và pageerror Shared module is not available for eager consumption; đổi entry thành import('./bootstrap') thì trang render hello 19.3.0, không lỗi. Bật eager: true cũng làm hết lỗi nhưng đóng gói React vào chunk khởi động và bỏ thương lượng: chỉ dùng khi hiểu rõ lý do. Với plugin Vite hay Rspack, cách tránh lỗi này khác webpack (chưa kiểm): đọc tài liệu đúng bundler.

Module Federation 2.0 ở runtime: manifest, loadRemote, con trỏ phát hành#

Với @module-federation/vite (và các plugin Module Federation khác), manifest: true sinh mf-manifest.json cạnh remoteEntry.js. Host có thể khai báo remote bằng URL của manifest và nạp bằng API runtime thay vì import('catalog/...') tĩnh:

typescriptReady
// remote/vite.config.ts: mỗi bản phát hành nằm dưới một đường dẫn bất biếnconst version = process.env.REMOTE_VERSION ?? 'v1'export default defineConfig({  base: `https://cdn.example.com/catalog/${version}/`,  plugins: [federation({    name: 'catalog', filename: 'remoteEntry.js', manifest: true, dts: false,    exposes: { './render': './src/render.ts' },  })],  build: { target: 'esnext', outDir: `dist/${version}` },})
typescriptReady
// host.tsimport { init, loadRemote, registerRemotes } from '@module-federation/runtime'// Con trỏ nhỏ, no-cache: đổi nó là phát hành hoặc rollback.async function readPointer(): Promise<{ catalog: string }> {  const res = await fetch('/remotes.json', { cache: 'no-store' })  if (!res.ok) throw new Error(`remotes.json ${res.status}`)  return res.json()}const pointer = await readPointer()init({  name: 'host',  remotes: [{ name: 'catalog', entry: pointer.catalog }], // .../v1/mf-manifest.json  plugins: [{    name: 'fallback-when-remote-fails',    // Gọi khi tải manifest, entry hay module thất bại: trả module thay thế thay vì ném lỗi lên UI.    errorLoadRemote({ id, error, lifecycle }) {      console.warn('remote lỗi', id, lifecycle, (error as Error).message)      return { default: (el: HTMLElement) => { el.textContent = 'Catalog tạm thời không dùng được' } }    },  }],})const mod = await loadRemote<{ default: (el: HTMLElement) => void }>('catalog/render')mod?.default(document.querySelector('#slot') as HTMLElement)// Không reload: trỏ lại remote sang bản mới, force để thay entry đã đăng ký// await registerRemotes([{ name: 'catalog', entry: (await readPointer()).catalog }], { force: true })

Quy tắc header cho hai loại file (đúng cho mọi remote, không riêng Vite):

FileVí dụ URLCache-ControlLý do
Artifact có version/v1/mf-manifest.json, /v1/remoteEntry.js, /v1/assets/*.jspublic, max-age=31536000, immutableĐường dẫn chứa version nên nội dung không bao giờ đổi
Con trỏ phát hành/remotes.jsonno-cachePhải đọc lại mỗi lần để thấy bản mới và rollback có hiệu lực

Remote nằm khác origin với host thì server của remote cần Access-Control-Allow-Origin hợp lệ cho origin host (lab dùng * vì dữ liệu công khai); thêm origin đó vào CSP script-src và connect-src của host.

Đã chạy (@module-federation/runtime 2.9.2, @module-federation/vite 1.23, Chrome, server tạm hai cổng): (1) con trỏ ở v1, host in catalog v1, header của file v1/* đúng như bảng và remotes.json là no-cache; (2) đổi con trỏ sang v2 rồi gọi registerRemotes(..., { force: true }) và loadRemote lại, cùng trang, hiện catalog v2; (3) tải lại trang thì vẫn v2; (4) đưa con trỏ về v1, tải lại, hiện catalog v1: rollback chỉ là đổi con trỏ, không build lại; (5) con trỏ trỏ vào v9 (404): hook errorLoadRemote được gọi ba lần (afterResolve hai lần, onLoad một lần) và host hiện fallback "Catalog tạm thời không dùng được" thay vì trang trắng. Lưu ý: API runtime, tên hook và message lỗi thay đổi theo bản; những gì ghi ở đây đúng với 2.9.2.

Rủi ro dễ gặp#

Triệu chứngNguyên nhân hay gặpHướng điều tra
remoteEntry.js 404URL/environment/publicPath sai hoặc deploy chưa upload entrynetwork tab, remote URL và CDN cache
Shared module is not available for eager consumptionEntry thực thi trước async sharing boundaryfederation bootstrap theo bundler/version; không bật eager đại trà
React invalid hook call / context không thấyCó hai React instance hoặc version scope mismatchbundle/share scope, dependency tree, singleton + requiredVersion
Host chạy local, production lỗi chunkpublic path/CDN prefix/runtime artifact thiếukiểm tra chunk URL, deploy ordering, cache headers
Deploy remote làm shell vỡContract không tương thích hoặc không có fallbackhost-remote contract matrix, backward compatibility, rollback

Tên lỗi và chi tiết config phụ thuộc phiên bản plugin. Tra troubleshooting của bundler/plugin đang dùng, không copy config từ một tutorial cũ vào mọi stack.


8. Lộ trình chuyển đổi thực tế#

Đừng bắt đầu bằng việc tách một app đang chạy thành năm repository. Chuyển từng bước để luôn có đường quay lại.

Giai đoạn A — làm monolith modular#

  1. Gắn ownership rõ cho route/features.
  2. Di chuyển logic vào feature modules với public API và dependency check.
  3. Đo build time, conflict rate, cycle count, release cadence, change failure và phần trùng code.
  4. Tách Design System package trước nếu nhiều app/team thực sự dùng chung UI.

Giai đoạn B — xác nhận nguyên nhân cần tách#

Viết decision record trả lời: vấn đề hiện tại là build chậm, release coordination, ownership, legacy stack hay runtime UX? Micro Frontend chỉ giải quyết một số nguyên nhân. Nếu vấn đề là import boundary thì ESLint/monorepo modules rẻ hơn runtime federation.

Giai đoạn C — tách một domain ít blast radius#

  1. Chọn một route/vertical slice có contract backend độc lập và metrics rõ.
  2. Định nghĩa route, props/events, auth, design tokens, API/error behavior và loading/error fallback.
  3. Dựng remote song song nhưng route mặc định còn phục vụ implementation cũ.
  4. Chạy compatibility tests trên host hiện tại, remote mới và tổ hợp version cần hỗ trợ.
  5. Canary một phần traffic, theo dõi page error, route completion, bundle/latency và rollback về app cũ nếu vượt ngưỡng.
  6. Sau vài lần release độc lập an toàn mới đánh giá có tách remote kế tiếp không.

9. Lab — storefront có shell, design system và một remote#

Phạm vi sản phẩm#

Một storefront có ba route: /catalog, /cart, /account. Bắt đầu trong một SPA modular. Sau khi boundary và owner rõ, chỉ tách catalog thành remote. Shell tiếp tục quản lý navigation/session; cart/account ở local để có comparison và fallback thật.

Việc cần làm#

  1. Architecture baseline: vẽ dependency graph; ghi route owner, data owner và public API cho ba feature. Thêm lint test chặn import cross-feature nội bộ.

    Lời giải và cách kiểm tra

    Sơ đồ.

    textReady
     shell (owner: platform)                    catalog (owner: team catalog) ┌─────────────────────────────┐            ┌──────────────────────────┐ │ router + history + 404      │  route     │ routes/ pages/ query/    │ │ session / identity          │ ─────────► │ adapter API /catalog     │ │ header, layout, theme       │            │ tests + pipeline         │ │ ErrorBoundary + telemetry   │ ◄───────── │ semantic events          │ └──────┬──────────────────────┘            └────────────────┬─────────┘        │ import                                             │ import        ▼                                                    ▼   ┌──────────────────── design-system (tokens + component) ──────────┐   └──────────────────────────────────────────────────────────────────┘ cart, account: feature local trong shell (đối chứng + fallback) (mũi tên ◄ là sự kiện cart.updated.v1: catalog phát, shell nghe)

    Hướng làm: mỗi feature một thư mục có index.ts là public API; ghi bảng route | data owner | public API cho /catalog, /cart, /account. Lint đơn giản nhất là script quét import (có thể thay bằng rule no-restricted-imports hoặc eslint-plugin-boundaries nếu repo đã dùng ESLint). Code tham chiếu (đã chạy):

    typescriptReady
    // scripts/check-boundaries.mjs: shell không được import nội bộ catalogimport { readdirSync, readFileSync, statSync } from 'node:fs'import { join } from 'node:path'const bad = []const walk = (d) => readdirSync(d).forEach((f) => {  const p = join(d, f)  if (statSync(p).isDirectory()) return walk(p)  if (!/\.(ts|tsx)$/.test(p)) return  readFileSync(p, 'utf8').split('\n').forEach((l, i) => {    if (/from ['"][^'"]*catalog\/src/.test(l)) bad.push(`${p}:${i + 1}: ${l.trim()}`)  })})walk('shell')if (bad.length) { console.error(bad.join('\n')); process.exit(1) }console.log('ok: shell không import nội bộ catalog')

    Kết quả quan sát: node scripts/check-boundaries.mjs in ok: ... (exit 0); thêm import x from '../../catalog/src/CatalogRoutes' vào shell/ thì in file:dòng và exit 1 (nhánh lỗi: suy ra từ code, chưa chạy). Lỗi hay gặp: chỉ vẽ sơ đồ mà không có check tự động nên ranh giới mục sau vài tháng.

  2. Design System: tạo token primitive + semantic; Button, TextField, Dialog, PageSkeleton, EmptyState. Ghi variant, keyboard behavior, disabled/loading/error và story cho ít nhất ba trạng thái mỗi component.

    Lời giải và cách kiểm tra

    Hướng làm: primitive trước (color.blue.600), semantic sau (color.action.primary.background); component chỉ đọc semantic. Dùng đúng dạng DTCG 2025.10 đã trình bày ở mục 5: $value color là object, dimension là { value, unit }. Mỗi component ghi bảng variant, bàn phím, disabled/loading/error. Button tối thiểu:

    tsxReady
    import type { ButtonHTMLAttributes } from 'react'type Props = ButtonHTMLAttributes<HTMLButtonElement> & { loading?: boolean }export function Button({ loading, disabled, type = 'button', children, ...rest }: Props) {  return (    <button      {...rest}      type={type}      disabled={disabled || loading}      aria-busy={loading || undefined}      style={{ background: 'var(--color-action-primary-background)',               color: 'var(--color-action-primary-foreground)' }}    >      {loading ? 'Đang tải…' : children}    </button>  )}

    (Đã chạy bản gần giống, dùng token CSS viết tay; chưa chạy Style Dictionary hay Storybook.) Story tối thiểu cho ba trạng thái: Default, Disabled, Loading (chưa chạy, tuỳ công cụ docs repo chọn). Lỗi hay gặp: component đọc primitive trực tiếp nên đổi dark theme phải sửa từng nơi; loading mà quên disabled nên bấm đúp gửi hai lần.

  3. Accessibility: tab/shift-tab, focus visible, Dialog Escape/restore focus, accessible name, color contrast, zoom 200%. Không coi axe check là thay thế manual keyboard/screen-reader pass.

    Lời giải và cách kiểm tra

    Hướng làm: checklist thủ công cho từng component: Tab/Shift+Tab đi đúng thứ tự, focus ring thấy rõ, Dialog đóng bằng Escape và trả focus về nút mở (dùng <dialog> + showModal() hoặc một thư viện đã kiểm chứng thay vì tự viết), có accessible name, tương phản chữ thường tối thiểu 4,5:1 theo WCAG 2.2, zoom 200% không mất nội dung. Chưa chạy: axe và pass screen reader. Kết quả mong đợi: axe không báo lỗi nghiêm trọng, nhưng không đủ để đạt; phải có pass bàn phím bằng tay. Lỗi hay gặp: outline: none không thay thế; focus bị kẹt trong Dialog khi remote lỗi.

  4. Federation: deploy catalog remote, expose route module, host nạp lazy. Gắn RemoteErrorBoundary, skeleton và retry. Không expose internal API rộng hơn cần thiết.

    Lời giải và cách kiểm tra

    Sơ đồ.

    textReady
     build catalog ─► dist/remoteEntry.js + assets/*.js  (upload CDN/origin 2) trình duyệt mở shell   1. shell khởi tạo runtime, đọc remote entry của catalog   2. user vào /catalog ─► import('catalog/Routes')   3. runtime thương lượng shared (react, react-dom: singleton)   4. tải chunk CatalogRoutes ─► render trong Suspense lỗi ở 2-4 ─► RemoteErrorBoundary ─► thông báo + Thử lại; nav còn sống

    Hướng làm: remote expose một module route, shell nạp lazy, bọc RemoteErrorBoundary và Suspense. Chỉ expose ./Routes, không expose store hay API client. Code tham chiếu (đã chạy với @module-federation/vite 1.23; Webpack: xem mục 7):

    typescriptReady
    // catalog/vite.config.tsimport { defineConfig } from 'vite'import react from '@vitejs/plugin-react'import { federation } from '@module-federation/vite'export default defineConfig({  plugins: [    react(),    federation({      name: 'catalog',      filename: 'remoteEntry.js',      exposes: { './Routes': './src/CatalogRoutes.tsx' },      shared: {        react: { singleton: true, requiredVersion: '^19.0.0' },        'react-dom': { singleton: true, requiredVersion: '^19.0.0' },      },    }),  ],  build: { target: 'esnext' },  preview: { port: 4511, strictPort: true, cors: true },})// shell/vite.config.ts (rút gọn: chỉ khác name, remotes)federation({  name: 'shell',  remotes: { catalog: { type: 'module', name: 'catalog',                        entry: 'http://localhost:4511/remoteEntry.js' } },  shared: { /* giống catalog */ },})
    tsxReady
    // shell/src/main.tsx (trích)const Catalog = lazy(() => import('catalog/Routes').then((m) => ({ default: m.CatalogRoutes })))<RemoteErrorBoundary>  <Suspense fallback={<p>Đang tải catalog…</p>}><Catalog /></Suspense></RemoteErrorBoundary>
    tsxReady
    // shell/src/RemoteErrorBoundary.tsximport { Component, type ReactNode } from 'react'export class RemoteErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> {  state = { error: null as Error | null }  static getDerivedStateFromError(error: Error) { return { error } }  render() {    if (!this.state.error) return this.props.children    return (      <div role="alert">        <p>Catalog tạm thời không dùng được.</p>        <button onClick={() => location.reload()}>Thử lại</button>      </div>    )  }}

    Khai báo kiểu cho catalog/Routes bằng declare module như mục 7 (đã chạy: tsc sạch). Kết quả quan sát (Chrome headless, vite preview hai cổng): vào /cart shell chỉ gọi remote 1 request (tải remote entry lúc khởi tạo, chưa tải chunk catalog); vào /catalog tổng số request tới remote tăng lên 10 (entry, chunk route, react và react-dom đã thương lượng); bấm "Thêm vào giỏ" hai lần thì shell hiện Giỏ: 2 qua event. Lỗi hay gặp, đã gặp thật: (a) tắt remote rồi bấm "Thử lại" bằng cách tạo lại lazy() không hồi phục trong cùng trang, dù remote đã sống lại; tải lại trang thì được. Nghi do module map của trình duyệt (và runtime) nhớ URL import đã lỗi: đây là suy luận, chưa kiểm cách khác như đổi URL entry kèm query. Vì vậy nút Thử lại trong RemoteErrorBoundary gọi location.reload() và route nên nằm trong URL để không mất chỗ. (b) Host thấy Shared module is not available for eager consumption: xem bảng mục 7, đặt entry bất đồng bộ.

  5. Contract: viết typed contract cho route/events; test trường hợp remote mới với host trước đó và host mới với remote đang production.

    Lời giải và cách kiểm tra

    Hướng làm: gom tên event, kiểu và hàm parse vào một file dùng chung; shell chỉ tin dữ liệu sau khi parse. Test tối thiểu cho "remote mới + host cũ" và ngược lại là test parser với payload của từng phiên bản đang sống ở production. Code tham chiếu (đã chạy với Vitest 5):

    typescriptReady
    export type CartUpdatedV1 = { itemCount: number }export const CART_UPDATED_V1 = 'cart.updated.v1'export function parseCartUpdatedV1(detail: unknown): CartUpdatedV1 | null {  if (typeof detail !== 'object' || detail === null) return null  const n = (detail as { itemCount?: unknown }).itemCount  return Number.isInteger(n) && (n as number) >= 0 ? { itemCount: n as number } : null}// test: { itemCount: 3 } -> chấp nhận; { itemCount: '3' }, { count: 3 }, null -> null

    Kết quả quan sát: npx vitest run thấy 1 passed. Thay đổi phá vỡ (đổi tên field) phải ra cart.updated.v2 và shell nghe cả hai trong thời gian chuyển tiếp. Lỗi hay gặp: as ép kiểu thay vì parse nên remote lệch kiểu làm shell crash.

  6. Release: artifact immutable, health/availability check, staged rollout, rollback về remote cũ; mô phỏng remote URL 404 và React version conflict.

    Lời giải và cách kiểm tra

    Hướng làm: thứ tự phát hành là upload chunk bất biến lên đường dẫn có version, chạy health check (GET remoteEntry.js trả 200 và đúng content type), rồi mới đổi con trỏ entry. Rollback là đổi con trỏ về version trước, không build lại. Entry nên đọc từ cấu hình lúc chạy (hoặc manifest do shell tải) để rollback không cần build lại shell; bản lab ghim entry lúc build nên chỉ minh hoạ. Mô phỏng 404 / remote chết (đã chạy): dừng vite preview của catalog, tải lại shell, vào /catalog. Kết quả quan sát: thấy Catalog tạm thời không dùng được., console có Federation Runtime ... #RUNTIME-008 (Failed to load script resources), điều hướng sang Cart vẫn hoạt động. Chưa chạy: xung đột phiên bản React (cách mô phỏng: build remote với requiredVersion: '^18' trong khi shell cung cấp 19, mong đợi cảnh báo hoặc lỗi version negotiation tuỳ cấu hình strictVersion; kết quả chính xác chưa kiểm), staged rollout, canary. Lỗi hay gặp: đổi con trỏ entry trước khi chunk upload xong; cache CDN dài trên remoteEntry.js nên rollback không có hiệu lực.

  7. Measure: trước/sau đo app shell bytes, catalog route bytes, remote entry/chunk requests, route load/error và time-to-interactive.

    Lời giải và cách kiểm tra

    Hướng làm: bảng trước/sau cùng build mode, cùng cache state: bytes shell, bytes route catalog, số request tới remote, tỉ lệ lỗi tải route, time-to-interactive. Công cụ: tab Network + vite build báo cáo kích thước + web-vitals ở GĐ27 mục 3. Chưa đo: chưa có bảng số cho cấu hình monolith so với federation; quan sát có căn cứ duy nhất là số request ở bước 4. Kết quả mong đợi (suy luận): federation tăng số request và có một lần tải remote entry, đổi lại release độc lập; nếu không đo được lợi ích đó thì quay lại monolith modular (mục 8).

Khung và mã dùng chung

Tự làm trước, rồi mới mở. Đã chạy (máy tạm, không dùng Docker): Node 24.21, Vite 8.3, @module-federation/vite 1.23, React 19.3, Vitest 5.0, TypeScript 7.0 (tsc sạch), Chrome headless điều khiển bằng playwright-core. Phần chưa chạy được ghi rõ ở từng bước: mô phỏng xung đột phiên bản React, canary/staged rollout, axe và pass screen reader, Storybook, đo bytes bằng số liệu thật. Lab dùng React + Vite cho gọn; Webpack/Rspack khác ở cách import plugin và cấu hình (mục 7), tư duy giữ nguyên.

Cấu trúc thư mục của lab (Vite, hai ứng dụng độc lập dùng chung ds/ và contracts/):

textReady
ds/            tokens.css, Button.tsx   (design system, import bởi cả hai app)contracts/     cart-updated.ts, *.test.ts  (tên event, kiểu, parse, test)shell/         vite.config.ts, src/main.tsx, RemoteErrorBoundary.tsxcatalog/       vite.config.ts, src/CatalogRoutes.tsx  (remote: expose ./Routes)scripts/       check-boundaries.mjs

Mỗi bước bên trên tham chiếu các file này.

Definition of done#

  • Có sơ đồ module/dependency và decision record giải thích vì sao boundary theo domain này hợp lý.
  • Feature public API không lộ internal components/state; dependency rule được kiểm tra tự động.
  • Design tokens có semantic aliases; component docs mô tả states, a11y và ví dụ.
  • Host vẫn điều hướng được nếu remote lỗi; remote có owner, version, pipeline và rollback artifact.
  • Nêu rõ chi phí federation: thêm network hop/chunks/runtime contracts, shared dependency risk và ops.
  • Có bằng chứng remote tải độc lập thật; nếu phải deploy host mỗi khi đổi remote thì chưa đạt mục tiêu independent deployment.

10. Tự kiểm tra#

  • Giải thích cách ESLint chặn features/cart import file nội bộ của features/catalog, và cách chắc rằng rule thật sự đang chạy

    Đáp án

    Mỗi file được gán một element theo boundaries/elements (feature kèm captured.name); boundaries/dependencies với default: 'disallow' chỉ cho các cặp có policy: cùng feature, hoặc index.ts của feature khác, hoặc shared. Import ../catalog/components/ProductCard không khớp policy nào nên báo lỗi. Để chắc rule chạy, cố tình viết một import sai và xem npx eslint . có báo không; nếu im lặng, thường là thiếu import/resolver cho TypeScript. Sai thường gặp: chỉ kiểm cây sạch và tin rằng 0 lỗi là đúng. Xem mục 3 của GĐ này và GĐ15 mục 8 để đưa lệnh vào CI.

  • Vì sao chuyển entry thành import('./bootstrap') sửa được lỗi Shared module is not available for eager consumption?

    Đáp án

    Entry đồng bộ import react trước khi runtime Federation khởi tạo share scope, nên module share chưa có. import('./bootstrap') tạo một ranh giới bất đồng bộ: runtime thương lượng share scope trước, rồi mới chạy mã ứng dụng. Bật eager: true cũng làm hết lỗi nhưng đóng gói dependency vào chunk khởi động và mất thương lượng phiên bản. Sai thường gặp: bật eager đại trà để "hết lỗi". Xem mục 7.

  • Remote v2 hỏng sau khi phát hành. Nêu các bước rollback và header cache cần đặt cho từng loại file

    Đáp án

    Artifact nằm dưới /v1/, /v2/ với Cache-Control: public, max-age=31536000, immutable; con trỏ remotes.json là no-cache. Rollback là đổi con trỏ về v1/mf-manifest.json, không build lại; người dùng đang mở trang cần tải lại hoặc host gọi registerRemotes(..., { force: true }). Nếu remoteEntry.js ở URL cố định mà cache dài thì rollback không có hiệu lực. Sai thường gặp: đổi con trỏ trước khi chunk của bản mới upload xong. Cách làm CDN ở GĐ17 mục 4.

  • Khi nào chọn import map cộng custom element thay cho Module Federation?

    Đáp án

    Khi remote là widget tự chứa, chỉ cần version URL và contract CustomEvent, không cần chia sẻ React singleton với host và chấp nhận không có type tự động hay thương lượng phiên bản. Dùng Federation khi remote cần chia sẻ dependency runtime (React, design system) với host và có contract version rõ. Sai thường gặp: dùng Federation chỉ để nhúng một widget. Xem mục 4.

  • Monorepo với npm workspaces có cho phép deploy độc lập không? Chỉ ra bằng chứng

    Đáp án

    Không. npm ls --workspaces cho thấy @acme/catalog dùng đúng bản design-system trong repo (symlink), nên đổi design system vẫn phải build và deploy lại app dùng nó. Monorepo cho chia sẻ code và refactor an toàn cùng một commit; deploy độc lập cần artifact và con trỏ phát hành riêng như ở mục 7. Cùng nguyên tắc "modular monolith trước" của backend ở GĐ20 mục 3.

  • Vì sao Button trong design system phải gộp className của consumer và đặt type="button" mặc định?

    Đáp án

    Đặt className sau {...buttonProps} mà không gộp sẽ ghi đè class của consumer, làm họ không tùy biến được. Nút trong <form> không có type mặc định là submit, bấm sẽ gửi form ngoài ý muốn. Lỗi này thuộc loại test visual và hành vi nên có trong bộ kiểm của design system (Playwright, mục 5). Sai thường gặp: chỉ kiểm giao diện, không kiểm trong ngữ cảnh form.

  • Đo tác động của việc tải remote lên hiệu năng trang thế nào?

    Đáp án

    Đo LCP, INP và số request tới remote ở mỗi route trước và sau khi tách, cùng cache state, bằng Core Web Vitals trong RUM và Lighthouse CI; so sánh theo phiên bản app. Tách mà không đo lợi ích thì nên quay lại modular monolith. Cách thu và đọc số liệu ở GĐ27 mục 3. Sai thường gặp: chỉ đo lab một lần trên máy mạnh.


Tài liệu tham khảo#