11.2 MVC, MVP, and MVVM: Who Owns the Interaction State
MVC, MVP, and MVVM all address the same set of questions: Who interprets input, who owns the interface state, who triggers business actions, and how the view gets updated.
They primarily organize the presentation boundary. Placing Spring MVC controllers, domain models, and database repositories within a single "MVC triangle" conflates two different scales of architecture.
MVC: Separating Input, State, and Presentation
These Java 21 / Spring MVC fragments assume LoadRanking, RankingViewData, RankingRow, and RankingUnavailable. Component scanning registers the controller, and the project supplies a template and view resolver. In server-side Web MVC:
- The Controller translates HTTP requests into application calls;
- Model is the data provided for page rendering and does not equate to a complete domain model;
- The View generates HTML from the Model.
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@Controller
final class RankingPageController {
private final LoadRanking loadRanking;
RankingPageController(LoadRanking loadRanking) {
this.loadRanking = java.util.Objects.requireNonNull(loadRanking);
}
@GetMapping("/rankings/{season}")
String show(@PathVariable("season") String season, Model model) {
RankingViewData ranking = loadRanking.forSeason(season);
model.addAttribute("ranking", ranking);
return "ranking";
}
}The controller does not calculate rankings; it selects a use case and a view. RankingViewData contains just the data the page needs, without exposing domain objects to templates.
The event loop and state synchronization in client-side MVC may differ, so don't judge the pattern solely by class name. Instead, diagram the actual control flow and ownership of state.
MVP: Presenter Actively Drives a Passive View
MVP often abstracts the View into an interface. The Presenter receives user intents, invokes use cases, and explicitly updates the View:
import java.util.List;
interface RankingView {
void showLoading();
void showRanking(List<RankingRow> rows);
void showError(String message);
}
final class RankingPresenter {
private final RankingView view;
private final LoadRanking loadRanking;
RankingPresenter(RankingView view, LoadRanking loadRanking) {
this.view = java.util.Objects.requireNonNull(view);
this.loadRanking = java.util.Objects.requireNonNull(loadRanking);
}
void onSeasonSelected(String season) {
view.showLoading();
try {
view.showRanking(loadRanking.forSeason(season).rows());
} catch (RankingUnavailable ex) {
view.showError("Ranking temporarily unavailable");
}
}
}This is the Passive View variant of MVP; not every MVP design requires a fully passive view. Loading is synchronous, and showRanking and showError should end the loading display. Slow I/O must not block a desktop UI thread. An asynchronous version needs to return updates to the UI thread and reject stale responses.
A passive view is easy to test with test doubles and suits scenarios where UI frameworks struggle with direct testing or require centralized orchestration of interaction flows. The downside is that the Presenter can easily grow into a "God object" containing excessive view-specific details.
MVVM: View Observes Bindable States
In MVVM, the ViewModel exposes the state and commands needed by the interface, and the View reflects this state through binding or declarative rendering.
type RankingRow = Readonly<{ playerId: string; points: number }>
type RankingState =
| { kind: 'idle' }
| { kind: 'loading' }
| { kind: 'ready'; rows: ReadonlyArray<RankingRow> }
| { kind: 'failed'; message: string }
type RankingApi = {
loadRanking(season: string): Promise<ReadonlyArray<RankingRow>>
}
class RankingViewModel {
private current: RankingState = { kind: 'idle' }
private generation = 0
private readonly api: RankingApi
private readonly onChange: (state: RankingState) => void
constructor(api: RankingApi, onChange: (state: RankingState) => void) {
this.api = api
this.onChange = onChange
}
get state(): RankingState { return this.current }
private publish(state: RankingState): void {
this.current = state
this.onChange(state)
}
async load(season: string): Promise<void> {
const request = ++this.generation
this.publish({ kind: 'loading' })
let rows: ReadonlyArray<RankingRow>
try {
rows = await this.api.loadRanking(season)
} catch {
if (request === this.generation) {
this.publish({ kind: 'failed', message: 'Ranking temporarily unavailable' })
}
return
}
if (request !== this.generation) return
const snapshot = Object.freeze(rows.map(row => Object.freeze({ ...row })))
this.publish({ kind: 'ready', rows: snapshot })
}
cancel(): void {
++this.generation
this.publish({ kind: 'idle' })
}
}The onChange callback explicitly notifies the binding layer. Assigning an ordinary class field does not itself trigger React or another framework to render; connect the callback to the framework’s state mechanism during assembly. The injected api validates and maps remote DTOs, and the state contains no DOM references.
Each load gets an increasing generation number, and only the latest request may publish its result. Select A and then B, and a late A response cannot overwrite B. cancel() invalidates pending results but does not terminate network requests. On leaving the page, cancel before disconnecting the UI subscription; integrate AbortSignal or another mechanism if network work must stop. Copying and freezing these flat rows prevents later mutation of the API’s array from changing displayed data.
An explicit discriminated union avoids contradictory combinations of loading=true, error!=null, and rows!=null. The kind field models mutually exclusive states; it does not automatically solve concurrency or runtime input validation.
Selection Criteria
| Question | MVC | MVP | MVVM / Declarative State |
|---|---|---|---|
| Who interprets input | Controller | Presenter | View invokes commands |
| Who updates the view | Controller/View collaboration | Presenter | Binding or rendering system |
| Where is the state primarily stored | Model or Session | Presenter | ViewModel / Store |
| Main Advantages | Clear request-response flow | Passive View is easy to replace and test | Direct mapping from state to interface |
| Common Risks | Controller Bloating | Presenter Bloating | Implicit reactive chains and uncontrolled global state |
A framework name can't replace choice. A React page can follow a unidirectional data flow or be written as hard-to-trace bidirectional synchronization; a Spring MVC project can also shove all business logic into the Controller.
Push side effects to the edge
The hardest part to test in logic isn't conditional branches; it's timers, networking, navigation, and local storage. With effects behind dependencies, state transitions can be extracted as pure functions and orchestration tested with Fakes. The asynchronous load() above is not itself a pure function.
Current state + User event + Use case result → New state + Pending effectFocus tests on:
- Loading, empty results, failure, and retry states;
- Repeated clicks and stale responses;
- Handling results when canceling or leaving the page;
- Translation of errors into suitable user messages;
- Stable presentation models without exposed backend internals.
Connecting with Application Layering
Whichever presentation pattern is used, keep its boundary at the application use-case call:
View / HTTP
↓
Controller / Presenter / ViewModel
↓
Application Use Case
↓
Domain + PortsPresentation patterns can evolve with client technology; use cases and domain rules should not be rewritten as a result. The next chapter will further decouple external dependencies, keeping databases, messaging, and web frameworks outside the core as replaceable adapters.
References
Martin Fowler, GUI Architectures
Martin Fowler, Presentation Model
Microsoft, The Model-View-ViewModel Pattern