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:
- Tách biệt mối quan tâm — render UI không nên chứa logic nghiệp vụ
- Khả năng kiểm thử — logic nghiệp vụ phải kiểm thử được mà không cần render UI
- Khả năng bảo trì — thay đổi UI không nên làm hỏng business rules (và ngược lại)
- 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ạnh | MVC | MVP | MVVM |
|---|---|---|---|
| Ai xử lý input | Controller | View ủy quyền cho Presenter | View bind vào ViewModel commands |
| Coupling View-Model | View có thể quan sát Model trực tiếp | View chỉ biết Presenter interface | View bind vào ViewModel properties |
| Mức "thông minh" của View | Trung bình (có thể quan sát Model) | Ngu (thụ động, chỉ render) | Thông minh (bind và phản ứng) |
| Đồng bộ state | Thủ công hoặc Observer pattern | Thủ công (Presenter gọi View) | Tự động (data binding) |
| Khả năng test | Controller cần HTTP mocking | Presenter là plain class | ViewModel là plain class |
| Boilerplate | Thấp | Cao (View interfaces) | Trung bình (binding setup) |
| Phù hợp nhất | Web request/response | Mobile/desktop cần testability | UI reactive với data binding |
Patterns Giao Tiếp
| Pattern | View → Logic | Logic → View | View → Model |
|---|---|---|---|
| MVC | View → Controller (user input) | Controller chọn View | View quan sát Model (cổ điển) |
| MVP | View → Presenter (events) | Presenter → View (qua interface) | Không bao giờ trực tiếp |
| MVVM | View → 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ống | Pattern Khuyến Nghị | Lý do |
|---|---|---|
| Web backend (Spring, Express, Rails) | MVC | Request/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.js | MVVM | Data binding tích hợp hỗ trợ MVVM tự nhiên |
| Ứng dụng React | MVVM-ish (hooks là ViewModel) | Custom hooks + component = ViewModel + View |
| Android app (hệ thống View cũ) | MVP hoặc MVI | Testability không cần Android dependency |
| Android app (Jetpack Compose) | MVVM hoặc MVI | Mô hình reactive của Compose hỗ trợ data binding |
| iOS app (SwiftUI) | MVVM | SwiftUI được xây dựng cho MVVM với @Observable |
| iOS app (UIKit) | MVP hoặc VIPER | API mệnh lệnh của UIKit phù hợp MVP |
| State phức tạp cần debug | MVI | Luồng một chiều, state bất biến, dễ dự đoán |
| Form CRUD đơn giản | MVC | Đừng over-engineer — MVC là đủ |
Quy Tắc Ngón Tay Cái
-
Nếu framework có data binding, dùng MVVM. Angular, Vue, SwiftUI và Jetpack Compose được thiết kế cho nó.
-
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.
-
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.
-
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.
-
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.
-
Đừ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:
- ✅ ARCH-1: Software Architecture Patterns Roadmap
- ✅ ARCH-2: Monolithic Architecture
- ✅ ARCH-3: Layered (N-Tier) Architecture
- ✅ ARCH-4: MVC, MVP & MVVM Patterns (bài viết này)
- 🔜 ARCH-5: Microservices Architecture
- 🔜 ARCH-6: Event-Driven Architecture
- 🔜 ARCH-7: CQRS & Event Sourcing
- 🔜 ARCH-8: Hexagonal Architecture (Ports & Adapters)
- 🔜 ARCH-9: Clean Architecture
- 🔜 ARCH-10: Domain-Driven Design (DDD)
- 🔜 ARCH-11: Serverless & Function-as-a-Service
- 🔜 ARCH-12: Choosing the Right Architecture
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.