Back to blog

MVC, MVP & MVVM: Presentation Layer Patterns Compared

software-architecturedesign-patternsfrontendbackendsystem-design
MVC, MVP & MVVM: Presentation Layer Patterns Compared

Every application that has a user interface — whether it's a web page, mobile app, or desktop tool — faces the same fundamental problem: how do you keep UI code separate from business logic?

Without a clear strategy, presentation code becomes a tangled mess. HTTP handlers contain database queries. Button click handlers run business rules. View templates calculate prices. Testing becomes impossible because everything depends on everything else.

MVC, MVP, and MVVM are three architectural patterns that solve this problem — each with a different approach to separating what the user sees (the View), what the application does (the business logic), and how data flows between them.

In this post, we'll cover:

✅ The origin and evolution of MVC
✅ How MVC works in web backends (Spring MVC, Express.js)
✅ Model-View-Presenter (MVP) — testable UI logic
✅ Model-View-ViewModel (MVVM) — reactive data binding
✅ Side-by-side comparison of all three patterns
✅ Practical implementations in real frameworks
✅ Modern variations (MVI, VIPER, Flux/Redux)
✅ How to choose the right pattern for your project


The Problem All Three Patterns Solve

Before we dive into each pattern, let's understand what they all have in common. Every presentation layer pattern addresses the same core problems:

  1. Separation of concerns — UI rendering shouldn't contain business logic
  2. Testability — business logic should be testable without rendering a UI
  3. Maintainability — changing the UI shouldn't break business rules (and vice versa)
  4. Reusability — the same business logic should work with different UIs

All three patterns share a common element: the Model. The Model represents the application's data and business rules. It knows nothing about how it's displayed.

Where they differ is how the View (what the user sees) communicates with the Model and who coordinates between them.

MVC, MVP, and MVVM each answer this question differently.


MVC: Model-View-Controller

Origin

MVC was invented by Trygve Reenskaug at Xerox PARC in 1979 for Smalltalk-80. It was the first pattern to formally separate UI concerns. The original idea: every UI element has a Model (data), a View (display), and a Controller (input handling).

How It Works

The three components:

  • Model — Application data and business rules. Doesn't know about the View or Controller.
  • View — Renders the Model's data for the user. Observes the Model for changes (in classic MVC) or receives data from the Controller (in web MVC).
  • Controller — Handles user input, updates the Model, and selects which View to render.

Key characteristic: The Controller mediates between the user and the Model. In classic MVC, the View directly observes the Model. In web MVC, the Controller typically passes data to the View.

MVC in Web Backends (Spring MVC)

Web MVC is the most common variation today. The HTTP request/response cycle maps naturally to MVC:

// Spring MVC — Controller handles HTTP, delegates to 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. Call the service (Model layer)
        List<Product> products = productService.findAll();
 
        // 2. Pass data to the View via Spring's Model
        model.addAttribute("products", products);
 
        // 3. Return the view name — Spring resolves to a 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) {
        // Input validation
        if (result.hasErrors()) {
            return "products/create";  // Re-render form with errors
        }
 
        // Delegate to service
        productService.create(form.toCommand());
        return "redirect:/products";  // Post-Redirect-Get pattern
    }
}
// Model layer — business logic, knows nothing about HTTP
@Service
public class ProductService {
 
    private final ProductRepository productRepository;
 
    public List<Product> findAll() {
        return productRepository.findAll();
    }
 
    public Product create(CreateProductCommand command) {
        // Business rule: product name must be unique
        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) — renders data, no 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})}">View Details</a>
</div>

MVC in Express.js

// Controller — handles 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);

MVC Strengths and Weaknesses

Strengths:

  • Simple mental model — most developers learn MVC first
  • Excellent fit for web request/response cycle
  • Clear separation between HTTP handling and business logic
  • Mature ecosystem (Spring MVC, Rails, Laravel, ASP.NET MVC)

Weaknesses:

  • In classic MVC, the View observing the Model creates tight coupling
  • Controllers can become bloated ("fat controllers") when they accumulate logic
  • Testing controllers often requires HTTP mocking infrastructure
  • Views are hard to unit test — they usually need integration tests

MVP: Model-View-Presenter

Origin

MVP evolved from MVC in the early 1990s at Taligent (a joint venture between Apple, IBM, and HP). It was popularized by Martin Fowler and gained widespread use in Android development and desktop applications.

Why MVP?

MVC has a problem in rich client applications (desktop, mobile): the View often needs complex UI logic — formatting dates, enabling/disabling buttons based on state, showing/hiding panels. This logic is too "presentation-ish" for the Model but too "logic-ish" for a passive View template.

MVP solves this by introducing a Presenter that contains all presentation logic and fully controls the View.

How It Works

The three components:

  • Model — Same as MVC: data and business rules.
  • View — Renders UI and captures user input. But it's passive — it delegates all decisions to the Presenter. The View implements an interface that the Presenter controls.
  • Presenter — Contains all presentation logic. Receives events from the View, interacts with the Model, and tells the View exactly what to display.

Key characteristic: The View and Presenter communicate through an interface. The Presenter never directly manipulates UI widgets — it calls methods on the View interface. This makes the Presenter fully testable without any UI framework.

MVP in Practice (Android-style)

// View interface — defines what the Presenter can tell the View to do
interface ProductListView {
    fun showProducts(products: List<ProductViewModel>)
    fun showLoading()
    fun hideLoading()
    fun showError(message: String)
    fun showEmptyState()
    fun navigateToDetail(productId: String)
}
 
// Presenter — all presentation logic, no Android dependencies
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: transform domain objects to 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) — only UI rendering
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 without any Android framework
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.")
    }
}

Notice how the Presenter test has zero UI dependencies. No Android framework, no mocking of views or activities — just plain unit tests. This is MVP's killer feature.

MVP Variants: Passive View vs Supervising Controller

Passive View (shown above): The View is completely dumb — it only has setters that the Presenter calls. All presentation logic lives in the Presenter. Maximum testability but more boilerplate.

Supervising Controller: The View handles simple data binding directly (e.g., displaying a model property), and the Presenter only handles complex presentation logic. Less boilerplate but harder to test completely.

MVP Strengths and Weaknesses

Strengths:

  • Excellent testability — Presenter is a plain class with no framework dependencies
  • Clear View interface makes it easy to swap UI implementations
  • Presentation logic is fully contained in the Presenter
  • Great for platforms where the View is hard to test (Android, Windows Forms)

Weaknesses:

  • Boilerplate — every View operation needs an interface method
  • The View interface can grow large as UI becomes complex
  • Presenter can become bloated (similar to "fat controller" in MVC)
  • Manual state synchronization between Presenter and View

MVVM: Model-View-ViewModel

Origin

MVVM was introduced by John Gossman at Microsoft in 2005 for WPF (Windows Presentation Foundation). It was designed specifically for platforms with powerful data binding capabilities. Today, it's the dominant pattern in Angular, Vue.js, SwiftUI, Jetpack Compose, and WPF/MAUI.

Why MVVM?

MVP's weakness is manual state management — the Presenter must explicitly call view.showProducts(), view.hideLoading(), etc. When the UI has many elements that depend on state, this becomes tedious and error-prone.

MVVM solves this with data binding: the View automatically observes changes in the ViewModel and updates itself. No manual synchronization needed.

How It Works

The three components:

  • Model — Same as MVC and MVP: data and business rules.
  • View — Renders UI and binds to ViewModel properties. Updates automatically when ViewModel changes.
  • ViewModel — Exposes data and commands that the View binds to. Contains presentation logic. Transforms Model data into a format the View can display directly. Does NOT reference the View (unlike MVP's Presenter).

Key characteristic: The ViewModel doesn't know the View exists. It simply exposes observable properties and commands. The View binds to these and reacts to changes. This is a fundamental difference from MVP where the Presenter holds a reference to the View interface.

MVVM in Angular

// Model — domain entity
interface Product {
  id: string;
  name: string;
  price: number;
  category: string;
  createdAt: Date;
  discountPercent: number;
}
 
// ViewModel (Angular Component class acts as ViewModel)
@Component({
  selector: 'app-product-list',
  templateUrl: './product-list.component.html',
})
export class ProductListComponent implements OnInit {
  // Observable state — View binds to these
  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) {
    // Reactive data flow: search query changes → products update automatically
    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(''); // Trigger initial load
  }
 
  // Command — View calls this on user input
  onSearch(query: string) {
    this.searchQuery$.next(query);
  }
 
  // Presentation logic — transform domain model to view-friendly format
  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) — binds to ViewModel observables -->
<div class="product-list">
  <input
    type="text"
    placeholder="Search products..."
    (input)="onSearch($event.target.value)" />
 
  <!-- Loading state -->
  <div *ngIf="isLoading$ | async" class="loading">Loading...</div>
 
  <!-- Error state -->
  <div *ngIf="error$ | async as error" class="error">{{ error }}</div>
 
  <!-- Empty state -->
  <div *ngIf="isEmpty$ | async" class="empty">No products found.</div>
 
  <!-- Product list -->
  <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 in Vue.js (Composition API)

Vue.js is MVVM at its core — the component's reactive state is the ViewModel, and the template is the 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 — derived state (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 — reactive data flow
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="Search products..." />
 
    <div v-if="isLoading">Loading...</div>
    <div v-if="error" class="error">{{ error }}</div>
    <div v-if="isEmpty" class="empty">No products found.</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>

MVVM Strengths and Weaknesses

Strengths:

  • Automatic UI synchronization via data binding — less boilerplate than MVP
  • ViewModel is framework-agnostic (just data + functions) — highly testable
  • Declarative UI: View describes what to show, ViewModel manages when
  • Excellent for complex, stateful UIs with many interdependent elements

Weaknesses:

  • Data binding "magic" can make debugging harder — it's not always obvious why a UI element updated
  • Two-way data binding can create unexpected update cycles
  • ViewModel can accumulate too many observables in complex screens
  • Overkill for simple UIs with minimal state

Side-by-Side Comparison

Data Flow

MVC:

MVP:

MVVM:

Key Differences

AspectMVCMVPMVVM
Who handles inputControllerView delegates to PresenterView binds to ViewModel commands
View-Model couplingView may observe Model directlyView only knows Presenter interfaceView binds to ViewModel properties
View intelligenceMedium (can observe Model)Dumb (passive, only renders)Smart (binds and reacts)
State synchronizationManual or Observer patternManual (Presenter calls View)Automatic (data binding)
TestabilityController needs HTTP mockingPresenter is plain classViewModel is plain class
BoilerplateLowHigh (View interfaces)Medium (binding setup)
Best fitWeb request/responseMobile/desktop with testability needsReactive UIs with data binding

Communication Patterns

PatternView → LogicLogic → ViewView → Model
MVCView → Controller (user input)Controller selects ViewView observes Model (classic)
MVPView → Presenter (events)Presenter → View (via interface)Never direct
MVVMView → ViewModel (binding/commands)ViewModel → View (data binding)Never direct

Where Does React Fit?

React doesn't strictly follow any single pattern. It borrows ideas from all three:

  • View — React components render JSX (like MVC Views)
  • State managementuseState, useReducer (like ViewModel's observable state)
  • No formal Controller/Presenter — event handlers are inline or custom hooks

React components are closer to MVVM when you use this pattern:

// Custom hook acts as ViewModel
function useProductList() {
  const [products, setProducts] = useState<Product[]>([]);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [searchQuery, setSearchQuery] = useState('');
 
  // Derived state (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 is a thin View — just renders ViewModel state
function ProductList() {
  const { productViewModels, isLoading, error, isEmpty, searchQuery, setSearchQuery } =
    useProductList();
 
  return (
    <div>
      <input
        value={searchQuery}
        onChange={(e) => setSearchQuery(e.target.value)}
        placeholder="Search products..."
      />
 
      {isLoading && <div>Loading...</div>}
      {error && <div className="error">{error}</div>}
      {isEmpty && <div>No products found.</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>
  );
}

The custom hook useProductList is essentially a ViewModel — it exposes observable state that the component (View) binds to. When state changes, the component re-renders automatically.

When you add Redux or Zustand, React moves closer to a Flux/MVI pattern (discussed below).


Modern Variations

MVI: Model-View-Intent

MVI is popular in Android (Kotlin) and some React architectures. It enforces unidirectional data flow:

  • Intent — User actions expressed as data (similar to Redux actions)
  • Model — Immutable state that gets replaced on each update
  • View — Renders the current state, emits intents
// MVI in 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) }
            }
        }
    }
}

MVI's strength: predictable state management. Every state change is triggered by an explicit intent, and the state is always immutable. Debugging is straightforward — you can log every intent and resulting state.

Flux / Redux

Redux is essentially MVI for the web. Actions are intents, reducers are the state update logic, and the store holds the model:

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

This is a unidirectional cycle, just like MVI.

VIPER (iOS)

VIPER extends MVP with explicit routing and use cases:

  • View — UI rendering (UIKit/SwiftUI)
  • Interactor — Business logic (like a use case)
  • Presenter — Presentation logic (same as MVP)
  • Entity — Data models
  • Router — Navigation logic

VIPER is more granular than MVP but follows the same principle: keep the View passive and logic testable.


Choosing the Right Pattern

Decision Matrix

ScenarioRecommended PatternWhy
Web backend (Spring, Express, Rails)MVCRequest/response maps naturally to MVC
REST API (no server-side rendering)MVC (thin)Controllers handle routing; services handle logic
Angular / Vue.js applicationMVVMBuilt-in data binding supports MVVM naturally
React applicationMVVM-ish (hooks as ViewModel)Custom hooks + component = ViewModel + View
Android app (legacy View system)MVP or MVITestability without Android dependencies
Android app (Jetpack Compose)MVVM or MVICompose's reactive model supports data binding
iOS app (SwiftUI)MVVMSwiftUI is built for MVVM with @Observable
iOS app (UIKit)MVP or VIPERUIKit's imperative API suits MVP
Complex state with debugging needsMVIUnidirectional flow, immutable state, predictable
Simple CRUD formMVCDon't over-engineer — MVC is enough

Rules of Thumb

  1. If your framework has data binding, use MVVM. Angular, Vue, SwiftUI, and Jetpack Compose are designed for it.

  2. If your View is hard to test (Android Views, WinForms), use MVP. The Presenter interface lets you test all presentation logic without the UI framework.

  3. If you're building a web backend with server-side rendering, use MVC. The HTTP request/response cycle is a natural Controller.

  4. If you need predictable state debugging, use MVI. The unidirectional flow and immutable state make every state transition traceable.

  5. If you're building a REST API, use MVC (controllers + services). You don't need MVP or MVVM — there's no View to bind to.

  6. Don't mix patterns in the same layer. Pick one and stick with it. Having some screens use MVP and others use MVVM creates cognitive overhead for the team.


Common Mistakes

Mistake 1: Fat Controllers / Fat Presenters / Fat ViewModels

The mediator layer (Controller, Presenter, or ViewModel) accumulates too much logic:

// ❌ Fat Controller — business logic in the controller
@Controller
public class OrderController {
    @PostMapping("/orders")
    public String createOrder(@ModelAttribute OrderForm form, Model model) {
        // Business rules in the 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";
        }
 
        // ... save order
    }
}
// ✅ Thin Controller — delegates to 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";
        }
    }
}

The same applies to Presenters and ViewModels — they should coordinate, not contain all the logic.

Mistake 2: Treating MVC/MVP/MVVM as Application Architecture

These are presentation layer patterns, not full application architectures. They define how UI code is organized — not how your entire system is structured.

A real application needs:

MVC, MVP, and MVVM organize the top layer. You still need layered architecture, hexagonal architecture, or similar patterns for the rest.

Mistake 3: Skipping the Model

Putting business logic in the Controller/Presenter/ViewModel because "it's simpler":

// ❌ Business logic in ViewModel
const useOrderForm = () => {
  const [items, setItems] = useState<CartItem[]>([]);
 
  // This is business logic, not 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 in 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 only handles 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 ? 'Free' : `$${summary.shippingCost}`,
    canCheckout: summary.canCheckout,
    // ...
  };
};

Summary

MVC, MVP, and MVVM are presentation layer patterns that solve the same fundamental problem — separating UI from business logic — with different trade-offs:

MVC (Model-View-Controller):

  • Best for web request/response (Spring MVC, Express, Rails)
  • Controller handles input, updates Model, selects View
  • Simple, well-understood, great ecosystem
  • Views are harder to unit test

MVP (Model-View-Presenter):

  • Best when the View is hard to test (Android Views, desktop apps)
  • Presenter contains all presentation logic, communicates via View interface
  • Excellent testability — Presenter is a plain class
  • More boilerplate than MVC or MVVM

MVVM (Model-View-ViewModel):

  • Best with data binding frameworks (Angular, Vue, SwiftUI, Compose)
  • ViewModel exposes observable state, View binds automatically
  • Less boilerplate than MVP, declarative UI updates
  • Data binding can make debugging harder

Key takeaways:

  • These are presentation layer patterns, not full application architectures
  • The Model should contain business logic — not the Controller, Presenter, or ViewModel
  • Choose based on your platform and framework, not personal preference
  • Keep the mediator layer (Controller/Presenter/ViewModel) thin — it coordinates, not computes
  • Don't overthink it — pick the pattern your framework supports best and focus on keeping business logic in the Model

What's Next in the Software Architecture Series

This is post 4 of 12 in the Software Architecture Patterns series:

Related posts:

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.