Back to blog

MVC, MVP & MVVM: So Sánh Các Pattern Tầng Trình Bày

software-architecturedesign-patternsfrontendbackendsystem-design
MVC, MVP & MVVM: So Sánh Các Pattern Tầng Trình Bày

Mọi ứng dụng có giao diện người dùng — dù là trang web, ứng dụng di động, hay công cụ desktop — đều đối mặt với cùng một vấn đề cơ bản: làm sao giữ code UI tách biệt khỏi logic nghiệp vụ?

Nếu không có chiến lược rõ ràng, code trình bày sẽ trở thành mớ hỗn độn. HTTP handler chứa câu truy vấn database. Button click handler chạy business rules. View template tính giá tiền. Kiểm thử trở nên bất khả thi vì mọi thứ phụ thuộc lẫn nhau.

MVC, MVP và MVVM là ba pattern kiến trúc giải quyết vấn đề này — mỗi pattern có cách tiếp cận khác nhau để tách biệt những gì người dùng nhìn thấy (View), những gì ứng dụng làm (logic nghiệp vụ), và cách dữ liệu luân chuyển giữa chúng.

Trong bài viết này, chúng ta sẽ đề cập:

✅ Nguồn gốc và sự phát triển của MVC
✅ MVC hoạt động thế nào trong web backend (Spring MVC, Express.js)
✅ Model-View-Presenter (MVP) — logic UI có thể kiểm thử
✅ Model-View-ViewModel (MVVM) — data binding phản ứng
✅ So sánh cả ba pattern cạnh nhau
✅ Triển khai thực tế trong các framework thật
✅ Các biến thể hiện đại (MVI, VIPER, Flux/Redux)
✅ Cách chọn pattern phù hợp cho dự án của bạn


Vấn Đề Mà Cả Ba Pattern Giải Quyết

Trước khi đi sâu vào từng pattern, hãy hiểu điểm chung của chúng. Mọi pattern tầng trình bày đều giải quyết cùng các vấn đề cốt lõi:

  1. Tách biệt mối quan tâm — render UI không nên chứa logic nghiệp vụ
  2. Khả năng kiểm thử — logic nghiệp vụ phải kiểm thử được mà không cần render UI
  3. Khả năng bảo trì — thay đổi UI không nên làm hỏng business rules (và ngược lại)
  4. Khả năng tái sử dụng — cùng logic nghiệp vụ nên hoạt động với các UI khác nhau

Cả ba pattern đều chia sẻ một thành phần chung: Model. Model đại diện cho dữ liệu và business rules của ứng dụng. Nó không biết gì về cách nó được hiển thị.

Điểm khác biệt nằm ở cách View (những gì người dùng nhìn thấy) giao tiếp với Model và ai điều phối giữa chúng.

MVC, MVP và MVVM mỗi pattern trả lời câu hỏi này theo cách khác nhau.


MVC: Model-View-Controller

Nguồn Gốc

MVC được phát minh bởi Trygve Reenskaug tại Xerox PARC năm 1979 cho Smalltalk-80. Đây là pattern đầu tiên chính thức tách biệt các mối quan tâm UI. Ý tưởng ban đầu: mỗi phần tử UI có một Model (dữ liệu), một View (hiển thị), và một Controller (xử lý đầu vào).

Cách Hoạt Động

Ba thành phần:

  • Model — Dữ liệu ứng dụng và business rules. Không biết gì về View hay Controller.
  • View — Render dữ liệu của Model cho người dùng. Quan sát Model để phát hiện thay đổi (trong MVC cổ điển) hoặc nhận dữ liệu từ Controller (trong web MVC).
  • Controller — Xử lý input từ người dùng, cập nhật Model, và chọn View nào để render.

Đặc điểm chính: Controller đóng vai trò trung gian giữa người dùng và Model. Trong MVC cổ điển, View trực tiếp quan sát Model. Trong web MVC, Controller thường truyền dữ liệu cho View.

MVC Trong Web Backend (Spring MVC)

Web MVC là biến thể phổ biến nhất hiện nay. Chu trình HTTP request/response ánh xạ tự nhiên sang MVC:

// Spring MVC — Controller xử lý HTTP, ủy quyền cho service
@Controller
@RequestMapping("/products")
public class ProductController {
 
    private final ProductService productService;
 
    public ProductController(ProductService productService) {
        this.productService = productService;
    }
 
    @GetMapping
    public String listProducts(Model model) {
        // 1. Gọi service (tầng Model)
        List<Product> products = productService.findAll();
 
        // 2. Truyền dữ liệu cho View qua Spring's Model
        model.addAttribute("products", products);
 
        // 3. Trả về tên view — Spring resolve thành template
        return "products/list";
    }
 
    @GetMapping("/{id}")
    public String showProduct(@PathVariable Long id, Model model) {
        Product product = productService.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Product not found"));
 
        model.addAttribute("product", product);
        return "products/detail";
    }
 
    @PostMapping
    public String createProduct(@Valid @ModelAttribute CreateProductForm form,
                                BindingResult result, Model model) {
        // Validate đầu vào
        if (result.hasErrors()) {
            return "products/create";  // Render lại form với lỗi
        }
 
        // Ủy quyền cho service
        productService.create(form.toCommand());
        return "redirect:/products";  // Post-Redirect-Get pattern
    }
}
// Tầng Model — logic nghiệp vụ, không biết gì về HTTP
@Service
public class ProductService {
 
    private final ProductRepository productRepository;
 
    public List<Product> findAll() {
        return productRepository.findAll();
    }
 
    public Product create(CreateProductCommand command) {
        // Business rule: tên sản phẩm phải duy nhất
        if (productRepository.existsByName(command.getName())) {
            throw new DuplicateProductException(command.getName());
        }
 
        Product product = new Product(
            command.getName(),
            command.getPrice(),
            command.getCategory()
        );
        return productRepository.save(product);
    }
}
<!-- View (Thymeleaf template) — render dữ liệu, không có business logic -->
<div th:each="product : ${products}">
    <h3 th:text="${product.name}">Product Name</h3>
    <p th:text="${product.formattedPrice}">$0.00</p>
    <a th:href="@{/products/{id}(id=${product.id})}">Xem Chi Tiết</a>
</div>

MVC Trong Express.js

// Controller — xử lý HTTP requests
const productController = {
  async listProducts(req: Request, res: Response) {
    const products = await productService.findAll();
    res.render('products/list', { products });
  },
 
  async createProduct(req: Request, res: Response) {
    try {
      await productService.create(req.body);
      res.redirect('/products');
    } catch (error) {
      if (error instanceof DuplicateProductError) {
        res.render('products/create', { error: error.message });
        return;
      }
      throw error;
    }
  },
};
 
// Routes
router.get('/products', productController.listProducts);
router.post('/products', validateProduct, productController.createProduct);

Điểm Mạnh và Điểm Yếu của MVC

Điểm mạnh:

  • Mô hình tư duy đơn giản — hầu hết developer học MVC đầu tiên
  • Phù hợp xuất sắc cho chu trình web request/response
  • Tách biệt rõ ràng giữa xử lý HTTP và logic nghiệp vụ
  • Hệ sinh thái trưởng thành (Spring MVC, Rails, Laravel, ASP.NET MVC)

Điểm yếu:

  • Trong MVC cổ điển, View quan sát Model tạo ra coupling chặt
  • Controller có thể phình to ("fat controllers") khi tích lũy logic
  • Test controller thường yêu cầu HTTP mocking infrastructure
  • View khó unit test — thường cần integration tests

MVP: Model-View-Presenter

Nguồn Gốc

MVP phát triển từ MVC vào đầu những năm 1990 tại Taligent (liên doanh giữa Apple, IBM và HP). Nó được phổ biến bởi Martin Fowler và được sử dụng rộng rãi trong phát triển Android và ứng dụng desktop.

Tại Sao Cần MVP?

MVC có vấn đề trong ứng dụng rich client (desktop, mobile): View thường cần logic UI phức tạp — format ngày, bật/tắt nút dựa trên state, hiện/ẩn panel. Logic này quá "presentation" cho Model nhưng quá "logic" cho một View template thụ động.

MVP giải quyết điều này bằng cách giới thiệu Presenter chứa toàn bộ presentation logic và điều khiển hoàn toàn View.

Cách Hoạt Động

Ba thành phần:

  • Model — Giống MVC: dữ liệu và business rules.
  • View — Render UI và bắt input từ người dùng. Nhưng nó thụ động — ủy quyền mọi quyết định cho Presenter. View implement một interface mà Presenter điều khiển.
  • Presenter — Chứa toàn bộ presentation logic. Nhận sự kiện từ View, tương tác với Model, và nói cho View biết chính xác phải hiển thị gì.

Đặc điểm chính: View và Presenter giao tiếp qua một interface. Presenter không bao giờ trực tiếp thao tác UI widget — nó gọi các phương thức trên View interface. Điều này khiến Presenter hoàn toàn có thể kiểm thử mà không cần bất kỳ UI framework nào.

MVP Trong Thực Tế (Kiểu Android)

// View interface — định nghĩa những gì Presenter có thể yêu cầu View làm
interface ProductListView {
    fun showProducts(products: List<ProductViewModel>)
    fun showLoading()
    fun hideLoading()
    fun showError(message: String)
    fun showEmptyState()
    fun navigateToDetail(productId: String)
}
 
// Presenter — toàn bộ presentation logic, không có Android dependency
class ProductListPresenter(
    private val productRepository: ProductRepository,
    private val dateFormatter: DateFormatter
) {
    private var view: ProductListView? = null
 
    fun attachView(view: ProductListView) {
        this.view = view
    }
 
    fun detachView() {
        this.view = null
    }
 
    fun loadProducts() {
        view?.showLoading()
 
        try {
            val products = productRepository.findAll()
 
            if (products.isEmpty()) {
                view?.showEmptyState()
            } else {
                // Presentation logic: chuyển đổi domain objects sang view models
                val viewModels = products.map { product ->
                    ProductViewModel(
                        id = product.id,
                        name = product.name,
                        price = "$${product.price}",
                        formattedDate = dateFormatter.format(product.createdAt),
                        isOnSale = product.discountPercent > 0,
                        saleBadge = if (product.discountPercent > 0)
                            "${product.discountPercent}% OFF" else null
                    )
                }
                view?.showProducts(viewModels)
            }
        } catch (e: Exception) {
            view?.showError("Failed to load products. Please try again.")
        } finally {
            view?.hideLoading()
        }
    }
 
    fun onProductClicked(productId: String) {
        view?.navigateToDetail(productId)
    }
}
// View implementation (Android Activity) — chỉ render UI
class ProductListActivity : AppCompatActivity(), ProductListView {
 
    private lateinit var presenter: ProductListPresenter
 
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_product_list)
 
        presenter = ProductListPresenter(
            ProductRepositoryImpl(database),
            AndroidDateFormatter()
        )
        presenter.attachView(this)
        presenter.loadProducts()
    }
 
    override fun showProducts(products: List<ProductViewModel>) {
        adapter.submitList(products)
        recyclerView.visibility = View.VISIBLE
        emptyView.visibility = View.GONE
    }
 
    override fun showLoading() {
        progressBar.visibility = View.VISIBLE
    }
 
    override fun hideLoading() {
        progressBar.visibility = View.GONE
    }
 
    override fun showError(message: String) {
        Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
    }
 
    override fun showEmptyState() {
        recyclerView.visibility = View.GONE
        emptyView.visibility = View.VISIBLE
    }
 
    override fun navigateToDetail(productId: String) {
        startActivity(ProductDetailActivity.intent(this, productId))
    }
 
    override fun onDestroy() {
        presenter.detachView()
        super.onDestroy()
    }
}
// Unit test — test Presenter không cần bất kỳ Android framework nào
class ProductListPresenterTest {
 
    private val mockView = mock<ProductListView>()
    private val mockRepository = mock<ProductRepository>()
    private val mockDateFormatter = mock<DateFormatter>()
    private val presenter = ProductListPresenter(mockRepository, mockDateFormatter)
 
    @Before
    fun setUp() {
        presenter.attachView(mockView)
    }
 
    @Test
    fun `should show products when loaded successfully`() {
        // Arrange
        val products = listOf(
            Product("1", "Widget", 9.99, createdAt = someDate, discountPercent = 0)
        )
        whenever(mockRepository.findAll()).thenReturn(products)
        whenever(mockDateFormatter.format(someDate)).thenReturn("Jan 20, 2026")
 
        // Act
        presenter.loadProducts()
 
        // Assert
        verify(mockView).showLoading()
        verify(mockView).showProducts(listOf(
            ProductViewModel("1", "Widget", "$9.99", "Jan 20, 2026", false, null)
        ))
        verify(mockView).hideLoading()
        verify(mockView, never()).showError(any())
    }
 
    @Test
    fun `should show empty state when no products`() {
        whenever(mockRepository.findAll()).thenReturn(emptyList())
 
        presenter.loadProducts()
 
        verify(mockView).showEmptyState()
    }
 
    @Test
    fun `should show error on failure`() {
        whenever(mockRepository.findAll()).thenThrow(RuntimeException("DB error"))
 
        presenter.loadProducts()
 
        verify(mockView).showError("Failed to load products. Please try again.")
    }
}

Lưu ý rằng test của Presenter có không một phụ thuộc UI nào. Không có Android framework, không mock views hay activities — chỉ là unit tests thuần túy. Đây là tính năng mạnh nhất của MVP.

Biến Thể MVP: Passive View vs Supervising Controller

Passive View (như trên): View hoàn toàn "ngu" — chỉ có setters mà Presenter gọi. Toàn bộ presentation logic nằm trong Presenter. Khả năng kiểm thử tối đa nhưng nhiều boilerplate hơn.

Supervising Controller: View tự xử lý data binding đơn giản (ví dụ hiển thị thuộc tính model), và Presenter chỉ xử lý presentation logic phức tạp. Ít boilerplate hơn nhưng khó test toàn diện hơn.

Điểm Mạnh và Điểm Yếu của MVP

Điểm mạnh:

  • Khả năng kiểm thử xuất sắc — Presenter là plain class không có framework dependency
  • View interface rõ ràng giúp dễ dàng swap UI implementations
  • Presentation logic hoàn toàn được chứa trong Presenter
  • Tuyệt vời cho nền tảng nơi View khó test (Android, Windows Forms)

Điểm yếu:

  • Boilerplate — mỗi thao tác View cần một phương thức interface
  • View interface có thể phình to khi UI trở nên phức tạp
  • Presenter có thể bị phình to (tương tự "fat controller" trong MVC)
  • Đồng bộ state thủ công giữa Presenter và View

MVVM: Model-View-ViewModel

Nguồn Gốc

MVVM được giới thiệu bởi John Gossman tại Microsoft năm 2005 cho WPF (Windows Presentation Foundation). Nó được thiết kế đặc biệt cho các nền tảng có khả năng data binding mạnh mẽ. Ngày nay, nó là pattern chiếm ưu thế trong Angular, Vue.js, SwiftUI, Jetpack Compose và WPF/MAUI.

Tại Sao Cần MVVM?

Điểm yếu của MVP là quản lý state thủ công — Presenter phải gọi view.showProducts(), view.hideLoading(), v.v. một cách rõ ràng. Khi UI có nhiều phần tử phụ thuộc vào state, việc này trở nên nhàm chán và dễ sai.

MVVM giải quyết điều này với data binding: View tự động quan sát thay đổi trong ViewModel và tự cập nhật. Không cần đồng bộ thủ công.

Cách Hoạt Động

Ba thành phần:

  • Model — Giống MVC và MVP: dữ liệu và business rules.
  • View — Render UI và bind vào các thuộc tính của ViewModel. Tự động cập nhật khi ViewModel thay đổi.
  • ViewModel — Expose dữ liệu và commands mà View bind vào. Chứa presentation logic. Chuyển đổi dữ liệu Model sang format mà View có thể hiển thị trực tiếp. KHÔNG tham chiếu đến View (khác với Presenter của MVP).

Đặc điểm chính: ViewModel không biết View tồn tại. Nó chỉ đơn giản expose observable properties và commands. View bind vào chúng và phản ứng với thay đổi. Đây là sự khác biệt cơ bản so với MVP nơi Presenter giữ tham chiếu đến View interface.

MVVM Trong Angular

// Model — domain entity
interface Product {
  id: string;
  name: string;
  price: number;
  category: string;
  createdAt: Date;
  discountPercent: number;
}
 
// ViewModel (Angular Component class đóng vai trò ViewModel)
@Component({
  selector: 'app-product-list',
  templateUrl: './product-list.component.html',
})
export class ProductListComponent implements OnInit {
  // State có thể quan sát — View bind vào những thuộc tính này
  products$: Observable<ProductViewModel[]>;
  isLoading$ = new BehaviorSubject<boolean>(false);
  error$ = new BehaviorSubject<string | null>(null);
  isEmpty$: Observable<boolean>;
 
  private searchQuery$ = new BehaviorSubject<string>('');
 
  constructor(private productService: ProductService) {
    // Luồng dữ liệu reactive: thay đổi search query → products tự động cập nhật
    this.products$ = this.searchQuery$.pipe(
      debounceTime(300),
      distinctUntilChanged(),
      switchMap((query) => {
        this.isLoading$.next(true);
        this.error$.next(null);
        return this.productService.search(query).pipe(
          map((products) => products.map(this.toViewModel)),
          catchError((err) => {
            this.error$.next('Failed to load products');
            return of([]);
          }),
          finalize(() => this.isLoading$.next(false)),
        );
      }),
    );
 
    this.isEmpty$ = this.products$.pipe(map((products) => products.length === 0));
  }
 
  ngOnInit() {
    this.searchQuery$.next(''); // Kích hoạt load ban đầu
  }
 
  // Command — View gọi khi người dùng nhập
  onSearch(query: string) {
    this.searchQuery$.next(query);
  }
 
  // Presentation logic — chuyển đổi domain model sang format thân thiện với view
  private toViewModel(product: Product): ProductViewModel {
    return {
      id: product.id,
      name: product.name,
      displayPrice: `$${product.price.toFixed(2)}`,
      category: product.category,
      isOnSale: product.discountPercent > 0,
      saleBadge: product.discountPercent > 0
        ? `${product.discountPercent}% OFF`
        : null,
    };
  }
}
<!-- View (Angular template) — bind vào các observable của ViewModel -->
<div class="product-list">
  <input
    type="text"
    placeholder="Tìm kiếm sản phẩm..."
    (input)="onSearch($event.target.value)" />
 
  <!-- Trạng thái loading -->
  <div *ngIf="isLoading$ | async" class="loading">Đang tải...</div>
 
  <!-- Trạng thái lỗi -->
  <div *ngIf="error$ | async as error" class="error">{{ error }}</div>
 
  <!-- Trạng thái rỗng -->
  <div *ngIf="isEmpty$ | async" class="empty">Không tìm thấy sản phẩm.</div>
 
  <!-- Danh sách sản phẩm -->
  <div *ngFor="let product of products$ | async" class="product-card">
    <h3>{{ product.name }}</h3>
    <span class="price">{{ product.displayPrice }}</span>
    <span *ngIf="product.isOnSale" class="sale-badge">
      {{ product.saleBadge }}
    </span>
  </div>
</div>

MVVM Trong Vue.js (Composition API)

Vue.js là MVVM ngay từ cốt lõi — reactive state của component là ViewModel, và template là View:

<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { useProductService } from '@/services/productService';
 
// ViewModel — reactive state
const searchQuery = ref('');
const products = ref<Product[]>([]);
const isLoading = ref(false);
const error = ref<string | null>(null);
 
const productService = useProductService();
 
// Computed properties — state phái sinh (presentation logic)
const productViewModels = computed(() =>
  products.value.map((product) => ({
    id: product.id,
    name: product.name,
    displayPrice: `$${product.price.toFixed(2)}`,
    isOnSale: product.discountPercent > 0,
    saleBadge:
      product.discountPercent > 0
        ? `${product.discountPercent}% OFF`
        : null,
  })),
);
 
const isEmpty = computed(
  () => !isLoading.value && products.value.length === 0,
);
 
// Watcher — luồng dữ liệu reactive
watch(
  searchQuery,
  async (query) => {
    isLoading.value = true;
    error.value = null;
    try {
      products.value = await productService.search(query);
    } catch (e) {
      error.value = 'Failed to load products';
    } finally {
      isLoading.value = false;
    }
  },
  { immediate: true },
);
</script>
 
<template>
  <div class="product-list">
    <input v-model="searchQuery" placeholder="Tìm kiếm sản phẩm..." />
 
    <div v-if="isLoading">Đang tải...</div>
    <div v-if="error" class="error">{{ error }}</div>
    <div v-if="isEmpty" class="empty">Không tìm thấy sản phẩm.</div>
 
    <div v-for="product in productViewModels" :key="product.id">
      <h3>{{ product.name }}</h3>
      <span>{{ product.displayPrice }}</span>
      <span v-if="product.isOnSale" class="badge">{{ product.saleBadge }}</span>
    </div>
  </div>
</template>

Điểm Mạnh và Điểm Yếu của MVVM

Điểm mạnh:

  • Tự động đồng bộ UI qua data binding — ít boilerplate hơn MVP
  • ViewModel không phụ thuộc framework (chỉ là data + functions) — rất dễ test
  • UI khai báo: View mô tả hiển thị gì, ViewModel quản lý khi nào
  • Xuất sắc cho UI phức tạp, stateful với nhiều phần tử phụ thuộc lẫn nhau

Điểm yếu:

  • "Ma thuật" data binding có thể khiến debug khó hơn — không phải lúc nào cũng rõ tại sao UI element cập nhật
  • Two-way data binding có thể tạo ra chu kỳ cập nhật không mong muốn
  • ViewModel có thể tích lũy quá nhiều observables trong màn hình phức tạp
  • Quá mức cho UI đơn giản với state tối thiểu

So Sánh Cạnh Nhau

Luồng Dữ Liệu

MVC:

MVP:

MVVM:

Khác Biệt Chính

Khía cạnhMVCMVPMVVM
Ai xử lý inputControllerView ủy quyền cho PresenterView bind vào ViewModel commands
Coupling View-ModelView có thể quan sát Model trực tiếpView chỉ biết Presenter interfaceView bind vào ViewModel properties
Mức "thông minh" của ViewTrung bình (có thể quan sát Model)Ngu (thụ động, chỉ render)Thông minh (bind và phản ứng)
Đồng bộ stateThủ công hoặc Observer patternThủ công (Presenter gọi View)Tự động (data binding)
Khả năng testController cần HTTP mockingPresenter là plain classViewModel là plain class
BoilerplateThấpCao (View interfaces)Trung bình (binding setup)
Phù hợp nhấtWeb request/responseMobile/desktop cần testabilityUI reactive với data binding

Patterns Giao Tiếp

PatternView → LogicLogic → ViewView → Model
MVCView → Controller (user input)Controller chọn ViewView quan sát Model (cổ điển)
MVPView → Presenter (events)Presenter → View (qua interface)Không bao giờ trực tiếp
MVVMView → ViewModel (binding/commands)ViewModel → View (data binding)Không bao giờ trực tiếp

React Nằm Ở Đâu?

React không tuân theo nghiêm ngặt bất kỳ pattern đơn lẻ nào. Nó mượn ý tưởng từ cả ba:

  • View — React components render JSX (giống MVC Views)
  • State management — useState, useReducer (giống observable state của ViewModel)
  • Không có Controller/Presenter chính thức — event handlers inline hoặc custom hooks

React components gần với MVVM hơn khi bạn dùng pattern này:

// Custom hook đóng vai trò ViewModel
function useProductList() {
  const [products, setProducts] = useState<Product[]>([]);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [searchQuery, setSearchQuery] = useState('');
 
  // State phái sinh (presentation logic)
  const productViewModels = useMemo(
    () =>
      products.map((p) => ({
        id: p.id,
        name: p.name,
        displayPrice: `$${p.price.toFixed(2)}`,
        isOnSale: p.discountPercent > 0,
        saleBadge: p.discountPercent > 0 ? `${p.discountPercent}% OFF` : null,
      })),
    [products],
  );
 
  const isEmpty = !isLoading && products.length === 0;
 
  useEffect(() => {
    let cancelled = false;
    setIsLoading(true);
    setError(null);
 
    productService
      .search(searchQuery)
      .then((data) => {
        if (!cancelled) setProducts(data);
      })
      .catch(() => {
        if (!cancelled) setError('Failed to load products');
      })
      .finally(() => {
        if (!cancelled) setIsLoading(false);
      });
 
    return () => {
      cancelled = true;
    };
  }, [searchQuery]);
 
  return { productViewModels, isLoading, error, isEmpty, searchQuery, setSearchQuery };
}
 
// Component là View mỏng — chỉ render state từ ViewModel
function ProductList() {
  const { productViewModels, isLoading, error, isEmpty, searchQuery, setSearchQuery } =
    useProductList();
 
  return (
    <div>
      <input
        value={searchQuery}
        onChange={(e) => setSearchQuery(e.target.value)}
        placeholder="Tìm kiếm sản phẩm..."
      />
 
      {isLoading && <div>Đang tải...</div>}
      {error && <div className="error">{error}</div>}
      {isEmpty && <div>Không tìm thấy sản phẩm.</div>}
 
      {productViewModels.map((product) => (
        <div key={product.id}>
          <h3>{product.name}</h3>
          <span>{product.displayPrice}</span>
          {product.isOnSale && <span className="badge">{product.saleBadge}</span>}
        </div>
      ))}
    </div>
  );
}

Custom hook useProductList về bản chất là một ViewModel — nó expose observable state mà component (View) bind vào. Khi state thay đổi, component tự động re-render.

Khi bạn thêm Redux hoặc Zustand, React tiến gần hơn đến pattern Flux/MVI (thảo luận bên dưới).


Các Biến Thể Hiện Đại

MVI: Model-View-Intent

MVI phổ biến trong Android (Kotlin) và một số kiến trúc React. Nó bắt buộc luồng dữ liệu một chiều:

  • Intent — Hành động người dùng được biểu diễn dưới dạng data (tương tự Redux actions)
  • Model — State bất biến được thay thế mỗi lần cập nhật
  • View — Render state hiện tại, phát ra intents
// MVI trong Kotlin
sealed class ProductIntent {
    data class Search(val query: String) : ProductIntent()
    object Refresh : ProductIntent()
    data class SelectProduct(val id: String) : ProductIntent()
}
 
data class ProductState(
    val products: List<ProductViewModel> = emptyList(),
    val isLoading: Boolean = false,
    val error: String? = null,
)
 
class ProductViewModel : ViewModel() {
    private val _state = MutableStateFlow(ProductState())
    val state: StateFlow<ProductState> = _state
 
    fun processIntent(intent: ProductIntent) {
        when (intent) {
            is ProductIntent.Search -> search(intent.query)
            is ProductIntent.Refresh -> refresh()
            is ProductIntent.SelectProduct -> selectProduct(intent.id)
        }
    }
 
    private fun search(query: String) {
        _state.update { it.copy(isLoading = true, error = null) }
        viewModelScope.launch {
            try {
                val products = repository.search(query)
                _state.update { it.copy(products = products, isLoading = false) }
            } catch (e: Exception) {
                _state.update { it.copy(error = e.message, isLoading = false) }
            }
        }
    }
}

Sức mạnh của MVI: quản lý state dễ dự đoán. Mỗi thay đổi state được kích hoạt bởi một intent rõ ràng, và state luôn bất biến. Debug rất đơn giản — bạn có thể log mỗi intent và state kết quả.

Flux / Redux

Redux về bản chất là MVI cho web. Actions là intents, reducers là logic cập nhật state, và store giữ model:

View → Action (Intent) → Reducer → Store (Model) → View

Đây là chu trình một chiều, giống hệt MVI.

VIPER (iOS)

VIPER mở rộng MVP với routing và use cases rõ ràng:

  • View — Render UI (UIKit/SwiftUI)
  • Interactor — Logic nghiệp vụ (giống use case)
  • Presenter — Presentation logic (giống MVP)
  • Entity — Data models
  • Router — Logic điều hướng

VIPER chi tiết hơn MVP nhưng tuân theo cùng nguyên tắc: giữ View thụ động và logic có thể test.


Chọn Pattern Phù Hợp

Ma Trận Quyết Định

Tình huốngPattern Khuyến NghịLý do
Web backend (Spring, Express, Rails)MVCRequest/response ánh xạ tự nhiên sang MVC
REST API (không server-side rendering)MVC (mỏng)Controllers xử lý routing; services xử lý logic
Ứng dụng Angular / Vue.jsMVVMData binding tích hợp hỗ trợ MVVM tự nhiên
Ứng dụng ReactMVVM-ish (hooks là ViewModel)Custom hooks + component = ViewModel + View
Android app (hệ thống View cũ)MVP hoặc MVITestability không cần Android dependency
Android app (Jetpack Compose)MVVM hoặc MVIMô hình reactive của Compose hỗ trợ data binding
iOS app (SwiftUI)MVVMSwiftUI được xây dựng cho MVVM với @Observable
iOS app (UIKit)MVP hoặc VIPERAPI mệnh lệnh của UIKit phù hợp MVP
State phức tạp cần debugMVILuồng một chiều, state bất biến, dễ dự đoán
Form CRUD đơn giảnMVCĐừng over-engineer — MVC là đủ

Quy Tắc Ngón Tay Cái

  1. Nếu framework có data binding, dùng MVVM. Angular, Vue, SwiftUI và Jetpack Compose được thiết kế cho nó.

  2. Nếu View khó test (Android Views, WinForms), dùng MVP. Presenter interface cho phép test toàn bộ presentation logic mà không cần UI framework.

  3. Nếu bạn xây dựng web backend với server-side rendering, dùng MVC. Chu trình HTTP request/response là Controller tự nhiên.

  4. Nếu bạn cần debug state dễ dự đoán, dùng MVI. Luồng một chiều và state bất biến khiến mỗi transition có thể truy vết.

  5. Nếu bạn xây dựng REST API, dùng MVC (controllers + services). Bạn không cần MVP hay MVVM — không có View để bind.

  6. Đừng trộn lẫn pattern trong cùng một layer. Chọn một và giữ nhất quán. Có một số màn hình dùng MVP và một số dùng MVVM tạo ra cognitive overhead cho team.


Lỗi Phổ Biến

Lỗi 1: Fat Controllers / Fat Presenters / Fat ViewModels

Tầng trung gian (Controller, Presenter, hoặc ViewModel) tích lũy quá nhiều logic:

// ❌ Fat Controller — business logic trong controller
@Controller
public class OrderController {
    @PostMapping("/orders")
    public String createOrder(@ModelAttribute OrderForm form, Model model) {
        // Business rules trong controller
        if (form.getItems().isEmpty()) {
            model.addAttribute("error", "Order must have items");
            return "orders/create";
        }
 
        double total = 0;
        for (var item : form.getItems()) {
            Product product = productRepo.findById(item.getProductId());
            if (product.getStock() < item.getQuantity()) {
                model.addAttribute("error", "Insufficient stock");
                return "orders/create";
            }
            total += product.getPrice() * item.getQuantity();
        }
 
        if (total < 10.0) {
            model.addAttribute("error", "Minimum order is $10");
            return "orders/create";
        }
 
        // ... lưu order
    }
}
// ✅ Thin Controller — ủy quyền cho service
@Controller
public class OrderController {
    @PostMapping("/orders")
    public String createOrder(@Valid @ModelAttribute OrderForm form,
                              BindingResult result, Model model) {
        if (result.hasErrors()) return "orders/create";
 
        try {
            orderService.createOrder(form.toCommand());
            return "redirect:/orders";
        } catch (BusinessException e) {
            model.addAttribute("error", e.getMessage());
            return "orders/create";
        }
    }
}

Tương tự cho Presenters và ViewModels — chúng nên điều phối, không chứa toàn bộ logic.

Lỗi 2: Coi MVC/MVP/MVVM Là Kiến Trúc Ứng Dụng

Đây là các pattern tầng trình bày, không phải kiến trúc ứng dụng đầy đủ. Chúng định nghĩa cách tổ chức code UI — không phải cách cấu trúc toàn bộ hệ thống.

Một ứng dụng thực cần:

MVC, MVP và MVVM tổ chức tầng trên cùng. Bạn vẫn cần kiến trúc phân lớp, kiến trúc hexagonal, hoặc pattern tương tự cho phần còn lại.

Lỗi 3: Bỏ Qua Model

Đặt business logic vào Controller/Presenter/ViewModel vì "đơn giản hơn":

// ❌ Business logic trong ViewModel
const useOrderForm = () => {
  const [items, setItems] = useState<CartItem[]>([]);
 
  // Đây là business logic, không phải presentation logic
  const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  const canCheckout = total >= 10 && items.length > 0;
  const shippingCost = total > 50 ? 0 : 5.99;
  const tax = total * 0.08;
  const grandTotal = total + shippingCost + tax;
 
  // ...
};
// ✅ Business logic trong Model/Service
class OrderCalculator {
  static calculate(items: CartItem[]): OrderSummary {
    const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
    return {
      subtotal,
      shippingCost: subtotal > 50 ? 0 : 5.99,
      tax: subtotal * 0.08,
      grandTotal: subtotal + (subtotal > 50 ? 0 : 5.99) + subtotal * 0.08,
      canCheckout: subtotal >= 10 && items.length > 0,
    };
  }
}
 
// ViewModel chỉ xử lý presentation
const useOrderForm = () => {
  const [items, setItems] = useState<CartItem[]>([]);
  const summary = useMemo(() => OrderCalculator.calculate(items), [items]);
 
  return {
    items,
    displayTotal: `$${summary.grandTotal.toFixed(2)}`,
    displayShipping: summary.shippingCost === 0 ? 'Miễn phí' : `$${summary.shippingCost}`,
    canCheckout: summary.canCheckout,
    // ...
  };
};

Tóm Tắt

MVC, MVP và MVVM là các pattern tầng trình bày giải quyết cùng vấn đề cơ bản — tách biệt UI khỏi business logic — với những đánh đổi khác nhau:

MVC (Model-View-Controller):

  • Phù hợp nhất cho web request/response (Spring MVC, Express, Rails)
  • Controller xử lý input, cập nhật Model, chọn View
  • Đơn giản, dễ hiểu, hệ sinh thái tuyệt vời
  • Views khó unit test

MVP (Model-View-Presenter):

  • Phù hợp nhất khi View khó test (Android Views, ứng dụng desktop)
  • Presenter chứa toàn bộ presentation logic, giao tiếp qua View interface
  • Khả năng test xuất sắc — Presenter là plain class
  • Nhiều boilerplate hơn MVC hay MVVM

MVVM (Model-View-ViewModel):

  • Phù hợp nhất với framework có data binding (Angular, Vue, SwiftUI, Compose)
  • ViewModel expose observable state, View tự động bind
  • Ít boilerplate hơn MVP, cập nhật UI khai báo
  • Data binding có thể khiến debug khó hơn

Điểm mấu chốt:

  • Đây là các pattern tầng trình bày, không phải kiến trúc ứng dụng đầy đủ
  • Model nên chứa business logic — không phải Controller, Presenter, hay ViewModel
  • Chọn dựa trên nền tảng và framework, không phải sở thích cá nhân
  • Giữ tầng trung gian (Controller/Presenter/ViewModel) mỏng — nó điều phối, không tính toán
  • Đừng suy nghĩ quá nhiều — chọn pattern mà framework hỗ trợ tốt nhất và tập trung giữ business logic trong Model

Tiếp Theo Trong Chuỗi Kiến Trúc Phần Mềm

Đây là bài viết thứ 4 trong 12 bài của chuỗi Software Architecture Patterns:

Bài viết liên quan:

📬 Đăng ký nhận bản tin

Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.

Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.

💬 Comments

Đăng nhập để bình luận

Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.