Skip to content

MVC and controller boundaries

Cocoa MVC assigns views to presentation, models to application meaning, and controllers to coordination between them. The controller becomes massive when every task that does not obviously fit a view or persistence model lands there.

Keep the controller at the framework boundary

A view controller has legitimate UIKit work: install views, respond to lifecycle callbacks, translate control events, present navigation, and render the latest state. Text normalization and note validation do not require UIKit.

struct NoteDraft {
var title: String
var body: String
}
struct NoteDraftValidator {
func validated(_ draft: NoteDraft) throws -> NoteDraft {
let title = draft.title.trimmingCharacters(in: .whitespacesAndNewlines)
guard !title.isEmpty else { throw ValidationError.missingTitle }
return NoteDraft(title: title, body: draft.body)
}
}

The controller gathers input, calls the validator, and renders success or failure. It does not need a new architectural layer merely to call this value.

Extract by reason to change

Move behavior when it has a distinct reason to change or a cheaper proof surface:

  • formatting rules belong in a formatter or presentation value
  • business invariants belong in domain values or use cases
  • storage and networking belong behind application contracts
  • view hierarchy, focus, and presentation remain in the controller

An extension can organize a long file, but it does not create a boundary. A renamed controller with the same dependencies is still massive.

Test the extracted rule directly

NoteDraftValidator can be tested without a scene, view hierarchy, or main run loop. Controller tests should then cover the smaller contract: intent enters, a dependency is called, and the correct state is rendered.

Series navigation

References