Apuntes DAM

Patrón MVC (Modelo-Vista-Controlador)

Separa una aplicación en el modelo (los datos y sus reglas), la vista (lo que se pinta) y el controlador (recibe lo que pide el usuario y los conecta). Es la base de Spring MVC, Laravel y ASP.NET.

nivel intermedioTambién: modelo vista controlador, model view controller, MVVM, MVP

Visualízalo paso a paso

Cambia los datos, dale a reproducir y sigue cada paso en el dibujo, en la línea de Java que se ejecuta y en sus variables.

MVC

Escribe las órdenes del usuario separadas por comas: nueva texto o hecha número.

Paso 1

El usuario escribe «nueva Leer el tema». Lo recibe el controlador, que no guarda datos ni pinta nada: decide a quién llamar.

1void orden(String linea) {  // tareas = 0, pendientes = 0
2    try {
3        if (linea.startsWith("nueva ")) modelo.anadir(linea.substring(6));
4        else modelo.completar(Integer.parseInt(linea.substring(6)));
5        vista.mostrar(modelo);
6    } catch (IllegalArgumentException e) {
7        vista.error(e.getMessage());
8    }
9}

Variables

tareas
0
pendientes
0

Atajos con el foco dentro del visualizador: ← → paso a paso, Espacio reproducir o pausar, Inicio/Fin ir al principio o al final.

La idea

En una aplicación pequeña es tentador hacerlo todo en el mismo sitio: el método que lee el formulario también valida, guarda en la base de datos y escribe el HTML. Funciona hasta que hay que cambiar el diseño sin romper las reglas, añadir una versión para el móvil o probar las reglas sin abrir el navegador.

El patrón MVC reparte el trabajo. El modelo guarda los datos y hace cumplir sus reglas (una tarea no puede estar vacía), sin saber nada de pantallas. La vista solo pinta lo que le dan. El controlador recibe cada orden del usuario (una petición HTTP, un clic, una línea de texto), le pide al modelo que cambie y elige qué vista mostrar.

Gracias a esa separación se pueden tener varias vistas del mismo modelo (texto, barras, una API JSON), cambiar el diseño sin tocar las reglas y probar el modelo sin interfaz. Spring MVC, Laravel, ASP.NET MVC, Django y Ruby on Rails siguen esta idea; en Android y en los frameworks de JavaScript se usan variantes como MVVM, donde la vista se actualiza sola cuando cambia el modelo.

Cuándo usarlo

  • Aplicaciones con interfaz (web, escritorio, móvil) que van a crecer o que mantiene más de una persona.
  • El mismo dato se muestra de varias formas (una página, un PDF, una API) o en varios dispositivos.
  • Quieres probar las reglas del negocio sin depender de la interfaz.

Cuándo no

  • Un script o una página de un solo uso: la separación añade archivos sin aportar nada.
  • Si el framework ya impone otra organización (por ejemplo, componentes con estado en React): hay que seguir la suya.

Participantes

  1. Modelo. Los datos y las reglas del negocio (ModeloTareas). No conoce ni la vista ni el controlador.
  2. Vista. Pinta el modelo (VistaConsola): consola, HTML, una pantalla de Android. No decide nada.
  3. Controlador. Recibe las órdenes del usuario (ControladorTareas), actualiza el modelo y elige qué vista mostrar.

Diagrama de clases

órdenes1modelo1vistaleeModeloTareas-tareas : List<String>-hechas : Set<Integer>+anadir(t : String) : void+completar(n : int) : void+pendientes() : intVistaConsola+mostrar(m : ModeloTareas) : void+error(mensaje : String) : voidControladorTareas-modelo : ModeloTareas-vista : VistaConsola+orden(linea : String) : voidUsuario
Arrastra las clases para colocarlas a tu gusto.
Ver el diagrama en PlantUML
text
1@startuml
2class ModeloTareas {
3  -tareas : List<String>
4  -hechas : Set<Integer>
5  +anadir(t : String) : void
6  +completar(n : int) : void
7  +pendientes() : int
8}
9class VistaConsola {
10  +mostrar(m : ModeloTareas) : void
11  +error(mensaje : String) : void
12}
13class ControladorTareas {
14  -modelo : ModeloTareas
15  -vista : VistaConsola
16  +orden(linea : String) : void
17}
18class Usuario
19Usuario ..> ControladorTareas : órdenes
20ControladorTareas --> "1" ModeloTareas : modelo
21ControladorTareas --> "1" VistaConsola : vista
22VistaConsola ..> ModeloTareas : lee
23@enduml

Puedes copiarlo en el editor de diagramas UML y modificarlo.

El código

Una lista de tareas en tres capas

El controlador traduce cada orden en una llamada al modelo; si el modelo rechaza algo, el controlador se lo pasa a la vista para que lo muestre. La vista nunca cambia los datos.

Java
1import java.util.*;
2
3/** El modelo: los datos y sus reglas. No sabe nada de pantallas ni de teclados. */
4class ModeloTareas {
5    private final List<String> tareas = new ArrayList<>();
6    private final Set<Integer> hechas = new HashSet<>();
7
8    void anadir(String t) {
9        if (t.isBlank()) throw new IllegalArgumentException("la tarea no puede estar vacía");
10        tareas.add(t.trim());
11    }
12
13    void completar(int n) {
14        if (n < 1 || n > tareas.size()) throw new IllegalArgumentException("no existe la tarea " + n);
15        hechas.add(n - 1);
16    }
17
18    List<String> tareas() { return Collections.unmodifiableList(tareas); }
19    boolean hecha(int i) { return hechas.contains(i); }
20    int pendientes() { return tareas.size() - hechas.size(); }
21}
22
23/** La vista: solo sabe pintar el modelo. Cambiarla por una web o una app no toca lo demás. */
24class VistaConsola {
25    void mostrar(ModeloTareas m) {
26        List<String> t = m.tareas();
27        for (int i = 0; i < t.size(); i++) System.out.println("  " + (m.hecha(i) ? "[x] " : "[ ] ") + (i + 1) + ". " + t.get(i));
28        System.out.println("  Pendientes: " + m.pendientes());
29    }
30
31    void error(String mensaje) { System.out.println("  Error: " + mensaje); }
32}
33
34/** El controlador: recibe lo que pide el usuario, actualiza el modelo y le dice a la vista qué mostrar. */
35class ControladorTareas {
36    private final ModeloTareas modelo;
37    private final VistaConsola vista;
38
39    ControladorTareas(ModeloTareas modelo, VistaConsola vista) {
40        this.modelo = modelo;
41        this.vista = vista;
42    }
43
44    void orden(String linea) {
45        try {
46            if (linea.startsWith("nueva ")) modelo.anadir(linea.substring(6));
47            else if (linea.startsWith("hecha ")) modelo.completar(Integer.parseInt(linea.substring(6).trim()));
48            else throw new IllegalArgumentException("orden desconocida");
49            vista.mostrar(modelo);
50        } catch (NumberFormatException e) {
51            vista.error("número no válido");
52        } catch (IllegalArgumentException e) {
53            vista.error(e.getMessage());
54        }
55    }
56}
57
58public class Main {
59    public static void main(String[] args) {
60        ControladorTareas c = new ControladorTareas(new ModeloTareas(), new VistaConsola());
61        for (String orden : List.of("nueva Leer el tema de MVC", "nueva Hacer la práctica", "hecha 1", "hecha 7", "borrar 1")) {
62            System.out.println("> " + orden);
63            c.orden(orden);                      // en una web, esto sería una petición HTTP
64        }
65    }
66}

Salida al ejecutarlo (la misma en los 5 lenguajes)

> nueva Leer el tema de MVC
  [ ] 1. Leer el tema de MVC
  Pendientes: 1
> nueva Hacer la práctica
  [ ] 1. Leer el tema de MVC
  [ ] 2. Hacer la práctica
  Pendientes: 2
> hecha 1
  [x] 1. Leer el tema de MVC
  [ ] 2. Hacer la práctica
  Pendientes: 1
> hecha 7
  Error: no existe la tarea 7
> borrar 1
  Error: orden desconocida

MVC en un framework web

En Spring MVC el controlador recibe la petición HTTP, añade los datos al modelo y devuelve el nombre de la vista que los pinta.

Java
1// Spring MVC: el controlador recibe la petición, pide los datos al modelo (servicio) y elige la vista
2@Controller
3public class TareasController {
4    private final TareasService servicio;
5
6    public TareasController(TareasService servicio) { this.servicio = servicio; }
7
8    @GetMapping("/tareas")
9    public String listar(Model model) {
10        model.addAttribute("tareas", servicio.todas());   // los datos para la vista
11        return "tareas";                                   // el nombre de la vista: tareas.html (Thymeleaf)
12    }
13
14    @PostMapping("/tareas")
15    public String crear(@RequestParam String texto) {
16        servicio.anadir(texto);
17        return "redirect:/tareas";
18    }
19}
20
21// tareas.html (la vista) solo pinta: <li th:each="t : ${tareas}" th:text="${t.texto}">…</li>

En la práctica

  • Spring MVC (@Controller + Thymeleaf), Laravel (rutas, controladores y vistas Blade), ASP.NET MVC y Django (que lo llama MTV).
  • JavaFX con FXML: la vista en el .fxml, el controlador en su clase y el modelo aparte.
  • Android con MVVM: el ViewModel expone los datos y la actividad o la pantalla de Compose los pinta.
  • Los proyectos de 2º de DAW y DAM y el proyecto intermodular: casi todos se organizan así.

Errores típicos

  • Poner reglas del negocio en el controlador o en la vista (validar en el HTML y no en el modelo): otra vista podría saltárselas.
  • Que el modelo escriba en pantalla o lea del teclado: deja de poder usarse con otra interfaz.
  • Controladores gordos que hacen consultas, cálculos y formato: el controlador solo coordina.
  • Que la vista consulte la base de datos directamente: los datos le llegan del controlador.

Ejercicios

Cada ejercicio se corrige solo con sus pruebas (algunas ocultas). Escribe tu solución en el editor y pulsa Ejecutar o Comprobar; la solución explicada está debajo, por si te atascas.

1. Un conversor de unidades con MVC

El modelo (Conversor) ya sabe convertir y guardar el historial, y la vista (Vista) ya sabe escribir resultados, historiales y errores. Falta el controlador: escribe Controlador, que recibe cada línea, llama al modelo y le dice a la vista qué mostrar.

  • Órdenes: convertir valor de a (por ejemplo convertir 10 km millas o convertir 100 c f; el valor puede llevar coma) e historial.
  • Todo lo que se escribe pasa por la vista: el resultado, el historial o Error: mensaje. Errores: número no válido, el mensaje del modelo si no sabe convertir esas unidades, y orden no válida para lo demás.
JavaUn conversor de unidades con MVCFácil

Ejemplo

Entrada (lo que se escribe por teclado)
convertir 10 km millas
convertir 100 c f
convertir 5,5 kg libras
historial
Salida esperada
10 km = 6,21 millas
100 c = 212 f
5,5 kg = 12,13 libras
Historial: 10 km → millas · 100 c → f · 5,5 kg → libras
Test oculto #3
Test oculto #4
0/4 tests pasados · pulsa un test para ver su entrada y su salida esperada
Ver la solución explicada
java
1import java.math.*;
2import java.util.*;
3
4/** El modelo: sabe convertir unidades y recuerda las conversiones hechas. */
5class Conversor {
6    private static final Map<String, Double> FACTOR = Map.of(
7            "km-millas", 0.621371, "millas-km", 1.609344,
8            "kg-libras", 2.20462, "libras-kg", 0.453592,
9            "m-pies", 3.28084, "pies-m", 0.3048);
10    private final List<String> historial = new ArrayList<>();
11
12    double convertir(double valor, String de, String a) {
13        double r;
14        if (de.equals("c") && a.equals("f")) r = valor * 9 / 5 + 32;
15        else if (de.equals("f") && a.equals("c")) r = (valor - 32) * 5 / 9;
16        else {
17            Double f = FACTOR.get(de + "-" + a);
18            if (f == null) throw new IllegalArgumentException("no sé convertir de " + de + " a " + a);
19            r = valor * f;
20        }
21        historial.add(formato(valor) + " " + de + " → " + a);
22        return r;
23    }
24
25    /** El modelo tiene su propio formato para el historial: no depende de la vista. */
26    private static String formato(double x) {
27        return BigDecimal.valueOf(x).stripTrailingZeros().toPlainString().replace('.', ',');
28    }
29
30    List<String> historial() { return Collections.unmodifiableList(historial); }
31}
32
33/** La vista: solo escribe. No calcula ni decide nada. */
34class Vista {
35    static String num(double x) {
36        return BigDecimal.valueOf(x).setScale(2, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString().replace('.', ',');
37    }
38
39    void resultado(double valor, String de, double r, String a) {
40        System.out.println(num(valor) + " " + de + " = " + num(r) + " " + a);
41    }
42
43    void historial(List<String> h) {
44        System.out.println(h.isEmpty() ? "Historial vacío" : "Historial: " + String.join(" · ", h));
45    }
46
47    void error(String mensaje) { System.out.println("Error: " + mensaje); }
48}
49
50/** El controlador: traduce cada orden en llamadas al modelo y a la vista. */
51class Controlador {
52    private final Conversor modelo;
53    private final Vista vista;
54
55    Controlador(Conversor modelo, Vista vista) {
56        this.modelo = modelo;
57        this.vista = vista;
58    }
59
60    void procesar(String linea) {
61        String[] p = linea.trim().split("\\s+");
62        try {
63            if (p[0].equals("convertir") && p.length == 4) {
64                double v = Double.parseDouble(p[1].replace(',', '.'));
65                double r = modelo.convertir(v, p[2], p[3]);
66                vista.resultado(v, p[2], r, p[3]);
67            } else if (p[0].equals("historial") && p.length == 1) {
68                vista.historial(modelo.historial());
69            } else {
70                vista.error("orden no válida");
71            }
72        } catch (NumberFormatException e) {
73            vista.error("número no válido");
74        } catch (IllegalArgumentException e) {
75            vista.error(e.getMessage());
76        }
77    }
78}
79
80public class Main {
81    public static void main(String[] args) {
82        Controlador c = new Controlador(new Conversor(), new Vista());
83        Scanner sc = new Scanner(System.in);
84        while (sc.hasNextLine()) {
85            String linea = sc.nextLine();
86            if (!linea.isBlank()) c.procesar(linea);
87        }
88    }
89}

Cada parte hace una cosa: el modelo calcula, la vista escribe y el controlador decide a quién llamar.

Si mañana el conversor es una página web, el modelo se reutiliza entero y solo cambian la vista y el controlador.

2. Una encuesta con dos vistas

La encuesta tiene un controlador y una vista de texto. Completa el modelo Encuesta (votar, total, porcentaje y ganador) y escribe una segunda vista, VistaBarras, que pinte el mismo modelo con barras. El controlador cambia de vista sin tocar el modelo.

  • La primera línea es opciones A,B,C. Después: votar opción (si no existe, No existe la opción X), vista texto|barras y resultado.
  • El porcentaje se redondea al entero más cercano (0 si no hay votos). El ganador es Gana X, Empate entre X y Y (en el orden de las opciones) o Sin votos todavía.
  • Barras: una línea por opción con el nombre alineado a la izquierda al ancho del más largo, |, una # por cada 5 % (redondeado) rellenado con espacios hasta 20, | y el porcentaje en 3 caracteres con %; al final, N votos · ganador.
JavaUna encuesta con dos vistasMedio

Ejemplo

Entrada (lo que se escribe por teclado)
opciones Java,Python,PHP
votar Java
votar Python
votar Java
votar PHP
votar Java
resultado
vista barras
resultado
Salida esperada
Java: 3 votos
Python: 1 voto
PHP: 1 voto
Gana Java
Java   |############        |  60 %
Python |####                |  20 %
PHP    |####                |  20 %
5 votos · Gana Java
Test oculto #3
Test oculto #4
0/4 tests pasados · pulsa un test para ver su entrada y su salida esperada
Ver la solución explicada
java
1import java.util.*;
2
3/** La vista: una interfaz, para poder tener varias que pintan el mismo modelo. */
4interface Vista {
5    void mostrar(Encuesta e);
6}
7
8class VistaTexto implements Vista {
9    public void mostrar(Encuesta e) {
10        for (String o : e.opciones()) System.out.println(o + ": " + e.votos(o) + (e.votos(o) == 1 ? " voto" : " votos"));
11        System.out.println(e.ganador());
12    }
13}
14
15/** El modelo: las opciones, los votos y las reglas (votar, porcentajes, quién gana). */
16class Encuesta {
17    private final Map<String, Integer> votos = new LinkedHashMap<>();
18
19    Encuesta(List<String> opciones) {
20        for (String o : opciones) votos.put(o, 0);
21    }
22
23    void votar(String opcion) {
24        if (!votos.containsKey(opcion)) throw new IllegalArgumentException("No existe la opción " + opcion);
25        votos.merge(opcion, 1, Integer::sum);
26    }
27
28    List<String> opciones() { return new ArrayList<>(votos.keySet()); }
29    int votos(String opcion) { return votos.get(opcion); }
30    int total() { return votos.values().stream().mapToInt(Integer::intValue).sum(); }
31
32    /** Porcentaje redondeado al entero más cercano (0 si no hay votos). */
33    int porcentaje(String opcion) {
34        return total() == 0 ? 0 : (int) Math.round(100.0 * votos(opcion) / total());
35    }
36
37    String ganador() {
38        if (total() == 0) return "Sin votos todavía";
39        int max = Collections.max(votos.values());
40        List<String> primeros = new ArrayList<>();
41        for (var e : votos.entrySet()) if (e.getValue() == max) primeros.add(e.getKey());
42        return primeros.size() == 1 ? "Gana " + primeros.get(0) : "Empate entre " + String.join(" y ", primeros);
43    }
44}
45
46/** Otra vista del mismo modelo: barras de # (una por cada 5 %). */
47class VistaBarras implements Vista {
48    public void mostrar(Encuesta e) {
49        int ancho = 0;
50        for (String o : e.opciones()) ancho = Math.max(ancho, o.length());
51        for (String o : e.opciones()) {
52            int pct = e.porcentaje(o);
53            String barra = "#".repeat((int) Math.round(pct / 5.0));
54            System.out.println(String.format("%-" + ancho + "s |%-20s| %3d %%", o, barra, pct));
55        }
56        System.out.println(e.total() + (e.total() == 1 ? " voto" : " votos") + " · " + e.ganador());
57    }
58}
59
60/** El controlador: recibe las órdenes, cambia el modelo y elige la vista. */
61class Controlador {
62    private final Encuesta modelo;
63    private final Map<String, Vista> vistas = Map.of("texto", new VistaTexto(), "barras", new VistaBarras());
64    private Vista vista = vistas.get("texto");
65
66    Controlador(Encuesta modelo) { this.modelo = modelo; }
67
68    void procesar(String linea) {
69        String[] p = linea.trim().split("\\s+", 2);
70        try {
71            switch (p[0]) {
72                case "votar" -> modelo.votar(p.length > 1 ? p[1] : "");
73                case "vista" -> {
74                    Vista v = vistas.get(p.length > 1 ? p[1] : "");
75                    if (v == null) System.out.println("No existe la vista " + (p.length > 1 ? p[1] : ""));
76                    else vista = v;
77                }
78                case "resultado" -> vista.mostrar(modelo);
79                default -> System.out.println("Orden no válida: " + linea.trim());
80            }
81        } catch (IllegalArgumentException e) {
82            System.out.println(e.getMessage());
83        }
84    }
85}
86
87public class Main {
88    public static void main(String[] args) {
89        Scanner sc = new Scanner(System.in);
90        String primera = sc.hasNextLine() ? sc.nextLine().trim() : "";
91        if (!primera.startsWith("opciones ")) {
92            System.out.println("Faltan las opciones");
93            return;
94        }
95        Controlador c = new Controlador(new Encuesta(List.of(primera.substring(9).split(","))));
96        while (sc.hasNextLine()) {
97            String linea = sc.nextLine();
98            if (!linea.isBlank()) c.procesar(linea);
99        }
100    }
101}

Todas las reglas (votos válidos, porcentajes, empates) están en el modelo: las dos vistas muestran siempre lo mismo.

Añadir una tercera vista (un gráfico, un JSON) no exige tocar ni el modelo ni el controlador, salvo para registrarla.

Test

Test: MVC (Modelo-Vista-Controlador)

0/5 respondidas · 0 aciertos

Elige una respuesta en cada pregunta: verás al momento si es correcta y por qué. Con un 80 % de aciertos se da por superada.

  1. 1.¿Qué parte de MVC contiene las reglas del negocio?

  2. 2.¿Qué hace el controlador?

  3. 3.¿Qué ventaja da separar la vista del modelo?

  4. 4.En Spring MVC, ¿qué devuelve normalmente un método de un @Controller?

  5. 5.¿Dónde no debería estar la validación de que una tarea no está vacía?

Relacionado