Hub

Kiến trúc

Bài này nói về kiến trúc frontend mức senior cho phỏng vấn: mẫu component, vài tên GoF map sang Vue, SOLID như bài test cho thay đổi, micro-frontend như nước đi tổ chức, thư mục theo feature, và design system khác component của app. Người phỏng vấn không chấm Animal / Dog. Họ muốn biết bạn cấu trúc app Vue 3 thế nào để team còn đổi được.

Đọc 15.1–15.3 trước (composition, pattern có tên, đường cắt SOLID). Rồi 15.5 cho thư mục và 15.6 cho thứ thuộc design system. 15.4 là bẫy: tám dev Vue không cần Module Federation. Stack mặc định: Vue 3 + TypeScript + Pinia, Nuxt khi SSR hoặc BFF quan trọng. Trả lời bằng một quyết định, một ràng buộc, một kịch bản lỗi, và cách kiểm chứng.


Tóm tắt: Component Vue 3 render. Composable chịu trách nhiệm một use-case (fetch, permissions, form). Cặp container/presentational là workaround React-Redux 2015. Bạn vẫn dùng phần compound khi chúng chia state và caller phải compose DOM, và headless / renderless chỉ khi DOM phải khác nhau rất nhiều.

Vì sao quan trọng: Một teammate muốn UserListContainer.vue + UserListView.vue; người khác muốn DataProvider renderless slot mười hai binding. Họ hỏi bạn chia hành vi thế nào: slot khác renderless khác composable, và bạn còn bịa cặp container/view trong <script setup> không.

  1. Cách hoạt động: Presentational/container tách fetch “smart” khỏi markup “dumb” vì connect() rò Redux. <script setup> kèm composable đã tách nhìn khỏi hành vi: useUserList() trả items / pending; SFC vẽ. Tách composable ở consumer thứ hai hoặc khi SFC không còn đọc được, không phải ở bốn mươi dòng theo nguyên tắc.
  2. Compound: FilterBar.Search / .Facet / .Clear chia state qua provide / inject có type, không phải $parent. Caller compose DOM. Headless / renderless: composable (useListbox) hoặc primitive kiểu Reka UI phụ trách bàn phím và state; app phụ trách markup. DataProvider.vue slot mười hai binding là shape Vue 3 sai: đó là composable.
  3. Mixin xong rồi: đụng tên, data ngầm, không type. Routing sống ở trang (mỏng), trang gọi composable của feature, không phải trên badge. Analog React: hook kèm composition; cùng rule “đừng bịa Container.js”. Scoped slot tạo closure mới mỗi render thường update child: ổn trừ khi bạn đã đo.
<!-- Compound: state ở root qua provide; phần không phải widget độc lập. -->
<script setup lang="ts">
const q = ref("")
provide(filterBarKey, { q, clear: () => { q.value = "" } })
</script>
<template>
  <FilterBar>
    <FilterBar.Search v-model="q" />
    <FilterBar.Facet field="status" />
    <FilterBar.Clear />
  </FilterBar>
</template>
<!-- Shape Vue 3 sai: <DataProvider v-slot="{ data, loading, error, refetch, ... }"> -->

Điều gì xảy ra: Bar giữ q. Search, Facet và Clear đọc qua injection key. Caller vẫn quyết thứ tự DOM. DataProvider mười hai slot lẽ ra là useFilterBar().

Thói cũMặc định Vue 3Giữ khi
UserListContainer + viewuseUserList() trong trang featureKhông bao giờ dùng cặp SFC
MixinComposable với return tường minhKhông bao giờ: đụng tên
HOC / render-propsSlot + composableDOM compound mà caller phải compose
Renderless slot 12 bindinguseListbox / Reka / useAsyncDataMarkup phải khác theo brand

Góc nhìn senior

  • Composition, composable và thư mục feature là mặc định. Component render; composable chịu trách nhiệm use-case.
  • Compound provide/inject khi phần chia state và caller phải compose DOM. Key có type, không phải $parent.
  • Headless là composable hoặc primitive. DataProvider.vue là shape sai trong Vue 3.
  • Tách ở consumer thứ hai, không phải ở bốn mươi dòng. Routing ở lại trên trang mỏng.
  • Mixin xong rồi. Analog React: hook, không phải cặp Container.js.

Lỗi hay gặp

  • Triệu chứng: UserListContainer.vue + UserListView.vue. Nguyên nhân: thói React-Redux. Cách xử lý: useUserList() trong trang.
  • Triệu chứng: SFC 400 dòng fetch, validate và vẽ chart. Nguyên nhân: không cắt ở consumer thứ hai. Cách xử lý: composable theo lý do đổi.
  • Triệu chứng: DataProvider với mười hai slot prop. Nguyên nhân: theo đám renderless. Cách xử lý: một composable.
  • Triệu chứng: đụng data mixin. Nguyên nhân: mixin. Cách xử lý: return composable tường minh.

Nhà tuyển dụng hay hỏi gì

Presentational khác container: bạn còn làm vậy không? Họ muốn composable, phần compound, và khi nào headless là thật, không phải một cặp SFC.

Trả lời khi phỏng vấn

Mình không bịa cặp container/view. Vue 3 đã tách nhìn khỏi hành vi bằng script setup và composable: component render, composable chịu trách nhiệm use-case. Mình vẫn dùng compound khi phần chia state và caller phải compose DOM, implement bằng provide/inject có type, không phải parent. Headless là composable hoặc primitive như listbox; DataProvider slot mười hai binding là shape sai. Mình tách ở consumer thứ hai, không phải ở bốn mươi dòng. Mixin xong rồi. Routing sống ở trang mỏng. Trên sản phẩm admin FilterBar là compound; useUserList nằm cạnh feature orders, không phải trong SFC Container.

Nhớ nhanh

  • Component render. Composable chịu trách nhiệm use-case. Không cặp Container/View.
  • Compound provide/inject khi caller phải compose DOM.
  • DataProvider.vue là composable. Mixin xong rồi.
  • Tách ở consumer thứ hai. Routing ở lại trên trang mỏng.

Câu hỏi nối tiếp thường gặp

  • Mixin khác composable? Đụng tên, data ngầm, không type: mixin xong rồi.
  • Routing sống ở đâu? Trang phụ trách composable, không phải badge.
  • Khi nào tách SFC 40 dòng? Ở consumer thứ hai, hoặc khi không còn đọc được.
  • Compound provide khác prop items trên Tabs? Tab theo data muốn items. Chrome do caller compose muốn compound.

Tóm tắt: Nêu rõ Observer, Strategy, Adapter, Facade như quyết định Vue, không phải sơ đồ lớp GoF. Observer là reactivity và invalidation query: Vue 3 cố ý gỡ $on/$off. Strategy là map hàm. Adapter bọc SDK third-party. Facade là BFF hoặc module API nhỏ. Singleton là module scope hoặc Pinia, per-request trên Nuxt SSR.

Vì sao quan trọng: “Bạn dùng design pattern nào ở frontend?” Sơ đồ lớp Singleton nghe như junior cầm sách. Họ muốn thanh toán, block CMS, và cart không được rò giữa user SSR, nêu như quyết định Vue.

  1. Cách hoạt động: Observer là props xuống, emit lên, Pinia, provide/inject, và invalidation query. Không phải bus mitt global giữa OrderTable và HeaderCart. Strategy là Record<PaymentKind, PayFn> để Apple Pay là hàm mới, không phải switch 200 dòng trong Checkout.vue.
  2. Adapter bọc Stripe hoặc client SOAP legacy sau PaymentGateway. Phần còn lại của app không bao giờ thấy type SDK. Facade là Nitro server/api/checkout.post.ts hoặc orders.api.ts: một shape cho UI. Factory là whitelist hero / faq → () => import(...); import tĩnh mọi block CMS là main 3 MB.
  3. Pinia là singleton theo app trên client và per-request trên Nuxt SSR. class Foo { static instance } rò data user xuyên request. ref mức module là cùng chỗ rò. Analog React: Observer là state + Query; Strategy là cùng map.
// Strategy: thêm Apple Pay mà không sửa switch 200 dòng của Checkout.vue.
const processors: Record<PaymentKind, PayFn> = {
  card: payWithCard,
  paypal: payWithPaypal,
  apple: payWithApple, // kind mới = hàm mới, không phải nhánh mới trong SFC
}
export async function pay(kind: PaymentKind, input: PayInput) {
  const fn = processors[kind]
  if (!fn) throw new Error(`unsupported payment ${kind}`)
  return fn(input)
}
// Adapter: Checkout nói với PaymentGateway, không bao giờ với type Stripe.js.
export const stripeGateway: PaymentGateway = {
  charge: (cents) => stripe.paymentIntents.create({ amount: cents, currency: "usd" }),
}

Điều gì xảy ra: Checkout gọi pay("apple", input). Map điều phối. Stripe ở sau PaymentGateway. Thêm kind không đụng SFC. Bus global cố ý không có trong ví dụ này.

MẫuShape VueTừ chối
ObserverReactivity, Pinia, invalidation query$bus / mitt global giữa feature
StrategyRecord<Kind, Fn>switch 200 dòng trong SFC
Adapter / FacadeType gateway; BFF / *.api.tsType SDK trong mọi component
SingletonModule api hoặc Pinia (per-request trên SSR)static instance rò xuyên user Nuxt

Góc nhìn senior

  • Chỉ nêu tên pattern khi nó làm rõ quyết định. Observer là ngôn ngữ; bạn không cần class EventBus.
  • Thông báo giữa các feature: domain event ở BFF, action Pinia, hoặc query invalidation, không phải mitt.
  • Pinia là per-request trên Nuxt SSR. ref ở module và static instance rò data user.
  • Factory cho block CMS phải whitelist + import(). Import tĩnh mọi block là main 3 MB.
  • EventTarget dành cho API browser, không phải feature trong app nói với nhau.

Lỗi hay gặp

  • Triệu chứng: OrderTable nói với HeaderCart qua mitt. Nguyên nhân: Observer giả. Cách xử lý: action Pinia hoặc query invalidation.
  • Triệu chứng: user B thấy cart của user A trên Nuxt. Nguyên nhân: static instance / ref ở module. Cách xử lý: Pinia per-request.
  • Triệu chứng: main 3 MB sau một trang CMS. Nguyên nhân: factory import tĩnh mọi block. Cách xử lý: whitelist + import().
  • Triệu chứng: keep-alive đăng listener bus hai lần. Nguyên nhân: event bus. Cách xử lý: đừng dùng bus.

Nhà tuyển dụng hay hỏi gì

Bạn dùng design pattern nào ở frontend? Sơ đồ lớp Singleton là câu junior. Họ muốn Observer, Strategy, Adapter, Facade như những quyết định Vue.

Trả lời khi phỏng vấn

Mình dùng ngôn ngữ, không phải sơ đồ lớp GoF. Observer là reactivity của Vue và query invalidation: Vue 3 gỡ on/off có chủ đích, nên OrderTable không nói với HeaderCart qua mitt. Strategy là map hàm thanh toán, mình thêm Apple Pay mà không cần switch hai trăm dòng. Adapter bọc Stripe sau PaymentGateway. Facade là BFF hoặc một module api nhỏ. Singleton là module scope hoặc Pinia, và trên Nuxt thì per-request: static instance sẽ rò user. Factory cho block CMS là whitelist kèm import. Ở admin nội bộ team mình thêm kind thanh toán như một hàm, không phải nhánh mới trong Checkout.vue.

Nhớ nhanh

  • Observer là reactivity và query invalidation, không phải bus global.
  • Strategy là map hàm. Adapter giấu type SDK.
  • Pinia là per-request trên Nuxt SSR. static instance rò user.
  • Factory CMS: whitelist + import(). Đừng import tĩnh mọi block.

Câu hỏi nối tiếp thường gặp

  • Dùng EventTarget thật ở đâu? API browser, không phải feature trong app nói với nhau xuyên cây.
  • Test map strategy thế nào? Một bảng kind → mock gateway kỳ vọng.
  • Vì sao static instance rò trên Nuxt SSR? Một isolate, nhiều request. Pinia là per-request.
  • Khi nào mitt chấp nhận được? Hầu như không bao giờ giữa các feature. Dùng action Pinia hoặc event BFF thì hơn.

Tóm tắt: SOLID trên frontend là bài test cho thay đổi, không phải chuyện động vật. S tách composable khi có lý do đổi thứ hai. O mở qua slot và registry, không phải extends BaseButton. Liskov: MenuButton vẫn hành xử như button. Interface: props nhỏ. D phụ thuộc OrderApi, không phải new AxiosClient() trong composable.

Vì sao quan trọng: “Áp SOLID vào Vue”. Nếu bạn nhắc động vật, họ ngừng nghe. Checkout.vue fetch cart, validate VAT, nói với Stripe, và vẽ stepper: đó là bốn lý do để đổi. Bạn cắt ở đâu, và đâu là chỗ SOLID bị áp quá đà (20 file cho một badge)?

  1. Cách hoạt động: SRP: useCart, useVat(country), usePayment(strategy), leaf OrderSummary. Vuex như god store là fail SRP; store Pinia theo domain mới là cách xử lý. OCP: slot, registry variant; v-if ladder là mùi; Options API extends copy data và cãi nhau với TS.
  2. LSP: MenuButton phải kích hoạt được bằng bàn phím và có state disabled. Interface dùng chung mà chỉ một implementer cần là nói dối. ISP: props Pick; đừng bắt mọi ListItem implement drag. DIP: useOrders(api = defaultApi) để Storybook và test không bao giờ đụng network. DI container là Singleton trên server thì cùng chỗ rò SSR như 15.2.
  3. Tách ở consumer thứ hai hoặc test đầu tiên bị đau. Hai mươi file cho một badge là YAGNI đội lốt SOLID. Analog React: cùng đường cắt là hook kèm client được inject.
export function useOrders(api: OrderApi = defaultApi) {
  return useQuery({
    queryKey: ["orders"],
    queryFn: () => api.listOrders(), // DIP: test truyền fakeApi
  })
}
// Checkout.vue render OrderSummary. Không nói với Stripe hay VAT.
export function useVat(country: MaybeRef<string>, rates: VatRates = defaultRates) {
  return computed(() => rates[toValue(country)] ?? 0)
}
// Nuxt: đừng container.get("api") như singleton cả process.

Điều gì xảy ra: Test gọi useOrders(fakeApi). Storybook không bao giờ đụng network. VAT là lý do đổi thứ hai, nên không nằm trong Checkout.vue. Client mặc định là module, không phải singleton cả process.

ChữVí dụ FEÁp quá đà
SMột composable, một lý do đổi20 file cho một badge
OSlot, registry, variantextends BaseButton; v-if mỗi client
LMenuButton vẫn là buttonInterface chỉ một implementer cần
I / DProps nhỏ; inject OrderApiGod props; new Axios() trong composable

Góc nhìn senior

  • Coi SOLID như bài test cho thay đổi. Checkout.vue không nên fetch, validate VAT, nói với Stripe, và vẽ stepper.
  • Abstraction là hàm hoặc interface, không phải abstract class. Action store gọi api được inject.
  • Vuex như god store là vi phạm SRP. Đó là lý do có store Pinia theo domain.
  • Tách ở consumer thứ hai. Hai mươi file cho một badge là câu SOLID sai.
  • Component chỉ chạy trên window mà dùng trong trang SSR thì đã gãy D (và hydration). Hãy inject IO.

Lỗi hay gặp

  • Triệu chứng: động vật trong câu trả lời. Nguyên nhân: SOLID giáo khoa. Cách xử lý: cắt Checkout.vue.
  • Triệu chứng: 20 file cho một badge. Nguyên nhân: SOLID thành tôn giáo. Cách xử lý: tách ở consumer thứ hai.
  • Triệu chứng: test đụng network. Nguyên nhân: new AxiosClient() trong useOrders. Cách xử lý: inject OrderApi.
  • Triệu chứng: Options extends copy data và gãy TS. Nguyên nhân: OCP giả. Cách xử lý: slot / composition.

Nhà tuyển dụng hay hỏi gì

Áp SOLID vào Vue: Checkout.vue làm bốn việc. Họ muốn đường cắt, và muốn bạn từ chối hai mươi file cho một badge.

Trả lời khi phỏng vấn

Mình coi SOLID như bài test cho thay đổi, không phải chuyện động vật. Checkout.vue không nên fetch cart, validate VAT, nói với Stripe, và vẽ stepper: đó là bốn lý do đổi, nên mình cắt useCart, useVat, usePayment, và leaf OrderSummary. Mình inject API client để Storybook và test không bao giờ đụng network. Mình mở rộng bằng slot và registry, không phải extends BaseButton. MenuButton vẫn hành xử như button. Mình tách ở consumer thứ hai; hai mươi file cho một badge là YAGNI. Vuex như god store là fail SRP mà store Pinia theo domain sửa. Ở admin nội bộ, VAT thành useVat khi quốc gia thứ hai xuất hiện, không phải ngày đầu.

Nhớ nhanh

  • SOLID là bài test cho thay đổi. Cắt Checkout.vue thành composable.
  • Mở qua slot và registry, không phải extends BaseButton.
  • Inject OrderApi. new Axios() trong composable làm gãy test.
  • Tách ở consumer thứ hai. Hai mươi file cho một badge là YAGNI.

Câu hỏi nối tiếp thường gặp

  • Tương tác với Pinia thế nào? Action store gọi api được inject, không phải fetch trong action.
  • Vuex có vi phạm SRP? God store thì có. Đó là lý do có store Pinia theo domain.
  • Khi nào 20 file cho một badge là câu SOLID sai? Một consumer, không test đau: YAGNI.
  • Component chỉ chạy trên window trong trang SSR, chữ nào? D. Inject IO; đừng đọc window trong setup.

Tóm tắt: Micro-frontend tách một product thành lát UI deploy riêng (Module Federation, web component, iframe, hoặc điều hướng cứng). Đó là giải pháp tổ chức, kèm cái giá về performance và UX, không phải pattern scale cho tám dev Vue. Ưu tiên monorepo với package cho đến khi deploy độc lập và ranh giới team buộc phải tách.

Vì sao quan trọng: “Có nên làm micro-frontend không?” Tám dev Vue, một product. Staff engineer muốn Module Federation để các team deploy độc lập. User vẫn có một document, một thứ tự tab, một cookie auth, một ngôn ngữ design. Tách build nhân những vấn đề đó lên.

  1. Cách hoạt động: Module Federation (Webpack/Rspack; Vite qua plugin) load remote lúc runtime và share vue nếu shared đúng, hoặc nhân đôi Vue nếu không. Web component cô lập style tốt hơn và vẫn chung một thế giới JS; a11y và form thì vụng. iframe cô lập crash và CSS, và đây là lựa chọn tệ nhất về UX/a11y/SEO/auth. Điều hướng cứng sang SPA khác thì ổn cho product tách hẳn (/careers khác /app).
  2. Chỉ nói có khi đã có lịch deploy độc lập và ranh giới team vốn đã không muốn dùng chung một pipeline. Cái giá: version shared-dep, nhân đôi byte framework, UX lệch, điều phối incident, LCP/INP so với monolith. Shell vẫn chịu trách nhiệm routing, auth, và design token.
  3. Analog React: cùng cái giá. Next + Module Federation vẫn là chuyện coupling lúc runtime. Nuxt: một app kèm package là mặc định; layers không phải micro-frontend.
// Mặc định: package trong một deploy. Không phải remoteEntry.
// apps/web phụ thuộc packages/ui và packages/domain-orders.
export default defineConfig({
  // federation chỉ khi đã có hai lịch deploy
  plugins: [
    // federation({ name: "shell", remotes: { checkout: "checkout@/remoteEntry.js" },
    //   shared: { vue: { singleton: true, requiredVersion: "^3.5" } } })
  ],
})
// Nếu vue không singleton, bạn tốn hai runtime và gãy provide/inject xuyên seam.
export const shouldFederate =
  independentDeploysPerWeek >= 1 && teamBoundaries.length >= 2

Điều gì xảy ra: Config mặc định không có remote. Federation được comment với vue là singleton: đó là seam mà provide/inject và Pinia phụ thuộc. Boolean là chuyện tổ chức, không phải slogan về bundle size.

ShapeDeploy độc lậpCái giá
Package monorepoKhông (trừ khi publish)Thấp nhất. Mặc định.
Module FederationCóCoupling runtime; Vue share hoặc hai Vue
Web componentCóAPI Vue vụng; đau đầu a11y/form
iframe / SPA táchCóUX/a11y tệ nhất, hoặc ổn cho product tách biệt

Góc nhìn senior

  • Micro-frontend là nước đi tổ chức, kèm cái giá hiệu năng. Tám dev Vue: không.
  • User vẫn có một document, một thứ tự tab, một cookie, một ngôn ngữ design.
  • Vue share kiểu singleton thì nhỏ hơn và khóa version. Nhân đôi Vue là team “tự do” và seam provide/inject gãy.
  • iframe cho admin không tin cậy hoặc legacy. Hard navigation cho product vốn tách biệt.
  • Kiểm chứng: deploy độc lập không cần shell phối hợp; LCP/INP so với monolith; byte framework trùng.

Lỗi hay gặp

  • Triệu chứng: hai runtime Vue, provide/inject chết. Nguyên nhân: shared sót vue. Cách xử lý: vue singleton, hoặc đừng federate.
  • Triệu chứng: LCP tệ hơn monolith. Nguyên nhân: thêm round-trip cho remote. Cách xử lý: package trong một deploy.
  • Triệu chứng: design lệch. Nguyên nhân: không có token chung. Cách xử lý: một package DS; federation không thay được cái đó.
  • Triệu chứng: tám người, bốn remote. Nguyên nhân: theo mốt. Cách xử lý: package trong monorepo.

Nhà tuyển dụng hay hỏi gì

Có nên làm micro-frontend? Tám dev Vue, một product, staff engineer muốn Module Federation. Mặc định của senior là không, trừ khi lịch deploy và ranh giới team buộc phải làm.

Trả lời khi phỏng vấn

Micro-frontend là giải pháp tổ chức kèm cái giá hiệu năng, không phải pattern scale cho tám dev Vue. User vẫn có một document, một thứ tự tab, một cookie auth, và một ngôn ngữ design. Mình ưu tiên monorepo với package (design system, ui, domain-orders) và một deploy đến khi cách đó không còn chịu nổi. Mình chỉ nói có khi lịch deploy đã độc lập và ranh giới team vốn đã không chịu chung một pipeline. Module Federation phải share Vue như singleton, không thì provide/inject gãy. iframe dành cho admin không tin cậy hoặc legacy. Ở admin nội bộ, team mình vẫn dùng package; không federate checkout.

Nhớ nhanh

  • Micro-frontend là nước đi tổ chức, phải trả giá. Tám dev Vue: không.
  • Mặc định: package trong monorepo, một deploy. Federation cần hai lịch deploy.
  • Share Vue như singleton, không thì provide/inject chết xuyên seam.
  • iframe cho thứ không tin cậy/legacy. Hard nav cho product vốn tách biệt.

Câu hỏi nối tiếp thường gặp

  • Vite khác Webpack cho Federation? Vẫn là chuyện Webpack/Rspack; plugin Vite thì có. Vue share vẫn là seam.
  • Khi nào iframe đáng dùng? Admin third-party không tin cậy, hoặc app legacy bạn sẽ không rewrite.
  • Nuxt layers cho gì thay? Tái sử dụng code trong một deploy, không phải remote độc lập.
  • Version design system chung xuyên remote thế nào? Một package, semver, không copy-paste token.

Tóm tắt: Tổ chức theo feature, không theo type (components/, composables/, stores/ ở root). Feature chịu trách nhiệm component, composable, module API và test của nó, và export một public index.ts. Ranh giới được enforce bằng lint (không import sâu), không phải bằng README.

Vì sao quan trọng: Thư mục theo type trông gọn đến khi checkout đụng vào internals của orders/components/OrderRow.vue và một lần đổi tên gãy ba app. Người phỏng vấn muốn public API của feature, shared/ được biết gì, và rule lint bạn sẽ thêm ngày đầu của codebase 100 feature. Thư mục không phải kiến trúc (16.1 nói chuyện đó), nhưng chúng là cách kiến trúc hiện ra trong git.

  1. Cách hoạt động: features/orders/ giữ components/, composables/useOrders.ts, api/orders.api.ts, types.ts, và index.ts (import duy nhất feature khác được dùng). pages/ (hoặc Nuxt pages/) mỏng: chúng compose feature. shared/ui là composite mức app, không phải package design system (15.6). shared/lib là http.ts và helper ngày tháng: cố ý giữ nhỏ.
  2. Enforce bằng eslint-plugin-import no-restricted-paths, hoặc Nx/Vite enforce-module-boundaries: checkout được import @app/orders, không bao giờ @app/orders/components/OrderRow.vue. Feature-sliced design (FSD) là một scheme có tên; phần bắt buộc là public API, không phải tên thư mục kiểu Nga.
  3. Cùng kiểu React/Next: app/(dashboard)/orders/ kèm package features/orders; đừng nhét hết vào components/. Cùng kiểu Vue: Nuxt layers để tái sử dụng xuyên app, vẫn một public API mỗi feature.
app/
  features/
    orders/
      components/          # OrderTable, không phải Button
      composables/useOrders.ts
      api/orders.api.ts
      types.ts
      index.ts             # public API: import duy nhất feature khác dùng
    checkout/
  shared/
    ui/                    # composite của app, không phải package DS (15.6)
    lib/http.ts
  pages/                   # mỏng: compose feature
packages/ui/               # design system
# lint: checkout được import @app/orders, không bao giờ orders/components/*

Điều gì xảy ra: Checkout nói với orders qua index.ts. Đổi tên OrderRow.vue là local. pages/ vẫn mỏng. Package DS không nằm trong shared/ui. Lint, không phải wiki, giữ lằn ranh.

TừĐược importKhông được
pages/Public API của featureNội bộ feature
features/ordersFile của mình + shared + package DSNội bộ checkout/components/*
sharedlib/*, primitive DSMọi feature
packages/uiKhông gì từ app/id order, Pinia, tên route

Góc nhìn senior

  • Thư mục feature thắng thư mục type khi có team thứ hai. index.ts public mới là kiến trúc.
  • Enforce bằng lint. README chỉ là gợi ý; rule restricted-path mới là ranh giới.
  • shared/ mà import feature là cycle đang chờ xảy ra. Giữ shared nhàm.
  • FSD là cách đặt tên tùy chọn. Đừng cargo-cult entities/ / widgets/ nếu team không biết những từ đó.
  • Khác 16.1: thư mục không phải câu trả lời system-design. Chúng là cách câu trả lời hiện trong git.

Lỗi hay gặp

  • Triệu chứng: components/, composables/, stores/ ở root, mỗi cái 400 file. Nguyên nhân: thư mục theo type. Cách xử lý: slice feature + index public.
  • Triệu chứng: checkout import orders/components/OrderRow.vue. Nguyên nhân: không ranh giới lint. Cách xử lý: restricted-paths.
  • Triệu chứng: shared/ui biết orders. Nguyên nhân: shared thành chỗ chứa lộn xộn. Cách xử lý: dời vào features/orders.
  • Triệu chứng: câu kiến trúc chỉ là cây thư mục. Nguyên nhân: bỏ 16.1. Cách xử lý: nêu rõ box, SLA, kịch bản lỗi, rồi thư mục.

Nhà tuyển dụng hay hỏi gì

Bạn cấu trúc app Vue 100 feature thế nào? Họ muốn public API mỗi feature và một rule lint, không phải components/ ở root.

Trả lời khi phỏng vấn

Mình tổ chức theo feature, không theo type. features/orders chịu trách nhiệm table, useOrders, module API và test, rồi export index public. Pages mỏng và compose feature. shared giữ nhàm: http và composite của app, không phải widget domain. Design system là một package, không phải thư mục trong shared. Mình enforce ranh giới bằng lint: checkout được import @app/orders, không bao giờ đường dẫn component sâu. Tên feature-sliced là tùy chọn; public API thì không. Thư mục không phải câu trả lời system-design (cái đó là 16.1), nhưng đây là cách câu trả lời hiện trong git. Ở admin nội bộ, checkout thôi import internals của OrderRow sau rule restricted-path.

Nhớ nhanh

  • Thư mục feature, index.ts public. Không thư mục type ở root.
  • Lint enforce ranh giới. README thì không.
  • shared/ không được import feature. Giữ nó nhàm.
  • Thư mục không phải kiến trúc (16.1). Chúng là cách nó hiện trong git.

Câu hỏi nối tiếp thường gặp

  • Rule lint ngày đầu của 100 feature? Không import sâu xuyên feature; chỉ index public.
  • Store Pinia sống ở đâu? Trong feature chịu trách nhiệm client state, không phải stores/ ở root.
  • Nuxt layers khác thư mục feature? Layers tái sử dụng xuyên app. Feature vẫn cần public API.
  • Khi nào FSD đáng học vocabulary? Nhiều team, đã được train. Không thì feature + index là đủ.

Tóm tắt: Design system ship token kèm primitive accessible (Button, TextField, Dialog) như package có version với peer vue. Component của app (OrderTable, VatField, CheckoutStepper) biết id domain và store. Primitive bị rò (Button đòi orderId) là kịch bản fail phỏng vấn hay gặp.

Vì sao quan trọng: Bốn product team, một “Button” mọc thêm orderId và một import Pinia. Ownership, API ổn định, và token mới là câu phỏng vấn, không phải Storybook đẹp. Padding đẩy layout là major. Widget checkout domain ở lại trong app.

  1. Cách hoạt động: Token là biến CSS với tên semantic (--color-danger). Dark mode đổi token; class trên <html> lấy từ cookie để tránh flash lúc load. Primitive theo APG: dialog có focus trap; button là <button>. Package có exports map, entrypoint riêng từng component, sideEffects cho CSS, và vue là peerDependency.
  2. App ghim semver và compose. Không fork Button, và không bọc mọi primitive “phòng khi”. Primitive mới đi qua RFC; request riêng của app nhận lời không kèm cách compose thay thế. Visual breaking change = major. Changelog + codemod cho các bản major.
  3. Ownership là guild với roadmap công khai, không phải ba Button.vue không có owner. DS React + Vue: share token, primitive native mỗi framework, đừng bọc Vue trong React. Visual regression: Playwright/Chromatic với token làm fixture (xem testing 8.6).
<!-- Rò: DS giờ biết về orders. Mọi product team trả giá. -->
<!-- <Button order-id="1001" variant="pay">Pay</Button> -->
<!-- Primitive đúng nghĩa: không domain props. Composition nằm ở app. -->
<script setup lang="ts">
defineProps<{ variant?: "primary" | "ghost"; loading?: boolean }>()
</script>
<template>
  <button type="button" :data-variant="variant" :aria-busy="loading || undefined">
    <slot />
  </button>
</template>
<!-- App: <PayButton :order-id="id" /> dùng Button DS + usePay(orderId). -->

Điều gì xảy ra: Primitive biết variant và loading. orderId nằm ở PayButton của app. Thêm Pay không bump DS. order-id rò trên Button sẽ buộc mọi team hiểu checkout.

Trong DSTrong app
Button, TextField, Dialog, shell DataTableOrderTable, VatField, CheckoutStepper
Token, contrast, focus ring, primitive APGThứ gì cần order id hoặc store Pinia
Package có version; peerDependency vueConsumer ghim version; không sửa node_modules
RFC cho primitive mớiCompose, hoặc nhận lời không

Góc nhìn senior

  • Việc sai phải khó làm trong API primitive. orderId trên Button là chỗ rò.
  • A11y không thương lượng. Dialog không có focus trap thì không phải primitive.
  • Padding đẩy layout là major. Changelog + codemod.
  • Styled + token nhanh hơn cho một brand; headless nếu brand lệch nhau mạnh.
  • Đừng bundle Vue trong DS. Peer dep. App ghim semver.

Lỗi hay gặp

  • Triệu chứng: Button có orderId. Nguyên nhân: domain rò vào DS. Cách xử lý: PayButton trong app.
  • Triệu chứng: ba Button.vue, không có owner. Nguyên nhân: copy-paste. Cách xử lý: một package, một guild, một roadmap.
  • Triệu chứng: library bundle Vue. Nguyên nhân: thiếu peerDependency. Cách xử lý: peer vue; app ghim.
  • Triệu chứng: chỉ chạy Storybook, Nuxt SSR fail. Nguyên nhân: window trong setup. Cách xử lý: story SSR + ClientOnly chỉ chỗ cần.

Nhà tuyển dụng hay hỏi gì

Thiết kế component library cho bốn product team. Họ muốn token, primitive accessible, package có version, và một lời không với widget domain. Không phải một Storybook cho đẹp.

Trả lời khi phỏng vấn

Mình ship token và primitive thành package có version với peer Vue. App ghim semver rồi compose, không fork Button. A11y không thương lượng: dialog không có focus trap thì không phải primitive. Padding làm lệch layout là major. Widget checkout domain để trong app. Chỗ rò phải từ chối là orderId trên Button. Ownership là guild với roadmap công khai. Visual regression dùng token làm fixture. Ở sản phẩm admin, PayButton nằm cạnh checkout; Button trong DS chỉ biết variant và loading.

Nhớ nhanh

  • DS = token + primitive accessible, có version, peer vue.
  • Component app được biết order id và store. Primitive thì không.
  • orderId trên Button là chỗ rò. Padding làm lệch layout là major.
  • Ownership là guild kèm roadmap, không phải ba Button không có owner.

Câu hỏi nối tiếp thường gặp

  • Visual-regression test thế nào? Chromatic/Playwright trên primitive; token làm fixture. Không phải mọi chart app.
  • DS React + Vue? Dùng chung token. Primitive native mỗi framework. Đừng bọc Vue trong React.
  • Khi nào thay đổi visual là major? Nó làm lệch layout hoặc phá contract đã ghi (padding, role).
  • Bạn nói không điều gì từ product team? Prop domain trên primitive. Đề xuất compose trong app.