Observer dejó una pregunta abierta: nada impide hoy cambiarEstado(ENTREGADO) sobre un pedido recién creado y sin pagar. Y el problema es más hondo que validar transiciones: casi todo lo que se puede hacer con un pedido depende de dónde esté en su ciclo de vida. ¿Se puede cancelar? Gratis si está creado, con penalización si está en preparación, imposible en reparto. ¿Se puede modificar? Solo antes de pagar. El código actual responde con un switch sobre el estado... repetido en cada método, creciendo con cada estado nuevo. State propone la reificación que ya esperas a estas alturas del módulo: cada estado es un objeto, con el comportamiento de ese estado dentro, y el pedido delega en su estado actual — que además decide a cuál se transita.

Contenido

  1. El problema en PideYa: el ciclo de vida del pedido
  2. El primer intento: switch sobre un enum
  3. Intención y estructura del patrón
  4. El diagrama de estados
  5. Implementación Java completa
  6. Variantes de diseño
  7. State vs. switch: la comparación honesta
  8. Cuándo usarlo y cuándo no
  9. Relación con otros patrones
  10. Errores comunes
  11. Ejercicios y conclusión

El problema en PideYa: el ciclo de vida del pedido

Las reglas de negocio, tal como las dicta operaciones:

Estado ¿Pagar? ¿Modificar líneas? ¿Cancelar? Transiciones salientes
CREADO Sí Sí Sí, gratis PAGADO, CANCELADO
PAGADO No (ya está) No Sí, con devolución total EN_PREPARACION, CANCELADO
EN_PREPARACION No No Sí, con penalización del 20% EN_REPARTO, CANCELADO
EN_REPARTO No No No (ya va de camino) ENTREGADO
ENTREGADO No No No — (final)
CANCELADO No No No — (final)

Fíjate en la naturaleza del problema: no es un comportamiento con variantes (eso sería otra cosa, y tiene su lección justo después); es el mismo objeto comportándose como seis objetos distintos según el momento. La palabra técnica es que Pedido es una máquina de estados finitos: estados, transiciones permitidas y comportamiento por estado.

El primer intento: switch sobre un enum

public class Pedido {

    private EstadoPedidoEnum estado = EstadoPedidoEnum.CREADO;

    public void pagar() {
        switch (estado) {
            case CREADO -> { cobrar(); estado = EstadoPedidoEnum.PAGADO; }
            default -> throw new TransicionInvalidaException("No se puede pagar en " + estado);
        }
    }

    public void cancelar() {
        switch (estado) {
            case CREADO -> estado = EstadoPedidoEnum.CANCELADO;
            case PAGADO -> { devolverTodo(); estado = EstadoPedidoEnum.CANCELADO; }
            case EN_PREPARACION -> { devolverConPenalizacion(); estado = EstadoPedidoEnum.CANCELADO; }
            default -> throw new TransicionInvalidaException("No se puede cancelar en " + estado);
        }
    }

    public void modificarLinea(...) { switch (estado) { /* ... */ } }
    public void marcarEnReparto()  { switch (estado) { /* ... */ } }
    // ... un switch por operación
}

Es honesto decirlo: para seis estados y cuatro operaciones estables, esto funciona y es legible. Los problemas aparecen con la evolución:

  • La lógica de cada estado está esparcida por todos los métodos: para saber "qué se puede hacer EN_PREPARACION" hay que leer un case en cada switch de la clase. El estado no existe como concepto en el código; existe como etiqueta repetida.
  • Añadir un estado (llega EN_ESPERA_REPARTIDOR, cortesía de la CentralReparto) significa tocar todos los switches — y olvidarse de uno no da error de compilación si hay default.
  • El comportamiento por estado crece: penalizaciones que dependen del tiempo en preparación, notificaciones distintas por estado... cada case engorda hasta que Pedido es un archivador de condicionales.
  • La combinación estado × operación es propensa al caso olvidado: el bug de Observer (entregar sin pagar) era exactamente un caso sin default defensivo.

Intención y estructura del patrón

Intención (GoF): permitir que un objeto altere su comportamiento cuando su estado interno cambia. El objeto parecerá cambiar de clase.

"Parecerá cambiar de clase" es la frase clave: un Pedido en reparto se comporta como otra clase que uno creado — porque, por debajo, su comportamiento vive en otra clase.

classDiagram
    class Pedido {
        -estado: EstadoPedido
        +pagar()
        +cancelar()
        +modificarLinea(...)
        +marcarEnReparto()
        ~transitarA(nuevo: EstadoPedido)
    }
    class EstadoPedido {
        <<interface>>
        +pagar(p: Pedido)
        +cancelar(p: Pedido)
        +modificarLinea(p: Pedido, ...)
        +marcarEnReparto(p: Pedido)
        +nombre() String
    }
    class EstadoCreado
    class EstadoPagado
    class EstadoEnPreparacion
    class EstadoEnReparto
    class EstadoEntregado
    class EstadoCancelado

    EstadoPedido <|.. EstadoCreado
    EstadoPedido <|.. EstadoPagado
    EstadoPedido <|.. EstadoEnPreparacion
    EstadoPedido <|.. EstadoEnReparto
    EstadoPedido <|.. EstadoEntregado
    EstadoPedido <|.. EstadoCancelado
    Pedido o-- EstadoPedido : delega en el actual
Rol GoF En PideYa
Context (mantiene el estado actual y delega) Pedido
State (interfaz con las operaciones dependientes del estado) EstadoPedido
ConcreteState EstadoCreado, EstadoPagado, EstadoEnPreparacion, EstadoEnReparto, EstadoEntregado, EstadoCancelado

El diagrama de estados

La máquina completa, en el diagrama UML pensado exactamente para esto (lo presentamos en la lección de UML; aquí rinde al máximo):

stateDiagram-v2
    [*] --> CREADO
    CREADO --> PAGADO : pagar()
    CREADO --> CANCELADO : cancelar() / gratis
    PAGADO --> EN_PREPARACION : cocina acepta
    PAGADO --> CANCELADO : cancelar() / devolución total
    EN_PREPARACION --> EN_REPARTO : repartidor recoge
    EN_PREPARACION --> CANCELADO : cancelar() / penalización 20%
    EN_REPARTO --> ENTREGADO : entrega confirmada
    ENTREGADO --> [*]
    CANCELADO --> [*]

Consejo de oficio: dibuja este diagrama antes de escribir una línea del patrón. Es el contrato con negocio (¿de verdad no se puede cancelar en reparto?), la lista exacta de clases a escribir, y la fuente de los tests (cada flecha, un test que pasa; cada no-flecha, un test que espera excepción).

Implementación Java completa

La interfaz State, con implementaciones por defecto que rechazan: así cada estado concreto solo escribe lo que permite, y lo no escrito queda prohibido automáticamente — el caso olvidado se vuelve imposible:

public interface EstadoPedido {

    default void pagar(Pedido p)              { rechazar("pagar", p); }
    default void cancelar(Pedido p)           { rechazar("cancelar", p); }
    default void modificarLinea(Pedido p, LineaPedido l) { rechazar("modificar", p); }
    default void marcarEnPreparacion(Pedido p){ rechazar("preparar", p); }
    default void marcarEnReparto(Pedido p)    { rechazar("repartir", p); }
    default void marcarEntregado(Pedido p)    { rechazar("entregar", p); }

    String nombre();

    private void rechazar(String operacion, Pedido p) {
        throw new TransicionInvalidaException(
            "No se puede " + operacion + " un pedido en estado " + nombre());
    }
}

Los estados concretos. Cada clase es la columna de la tabla de negocio hecha código — compacta, cohesionada, con nombre propio:

public class EstadoCreado implements EstadoPedido {

    @Override
    public void pagar(Pedido p) {
        p.cobrarTotal();                            // trabajo del contexto
        p.transitarA(new EstadoPagado());           // transición decidida AQUÍ
    }

    @Override
    public void cancelar(Pedido p) {
        p.transitarA(new EstadoCancelado());        // gratis: sin devolución
    }

    @Override
    public void modificarLinea(Pedido p, LineaPedido linea) {
        p.aplicarModificacion(linea);               // único estado que lo permite
    }

    @Override
    public String nombre() { return "CREADO"; }
}

public class EstadoEnPreparacion implements EstadoPedido {

    @Override
    public void cancelar(Pedido p) {
        p.devolverConPenalizacion(new BigDecimal("0.20"));
        p.transitarA(new EstadoCancelado());
    }

    @Override
    public void marcarEnReparto(Pedido p) {
        p.transitarA(new EstadoEnReparto());
    }

    @Override
    public String nombre() { return "EN_PREPARACION"; }
}

public class EstadoEntregado implements EstadoPedido {
    // Estado final: no sobrescribe nada — todo rechazado por defecto
    @Override
    public String nombre() { return "ENTREGADO"; }
}

El contexto. Pedido queda limpio de condicionales: expone las operaciones y delega; y transitarA es el único punto por donde cambia el estado — el lugar perfecto para conectar con Observer:

public class Pedido {

    private EstadoPedido estado = new EstadoCreado();

    public void pagar()                    { estado.pagar(this); }
    public void cancelar()                 { estado.cancelar(this); }
    public void modificarLinea(LineaPedido l) { estado.modificarLinea(this, l); }
    public void marcarEnReparto()          { estado.marcarEnReparto(this); }
    public void marcarEntregado()          { estado.marcarEntregado(this); }

    /** Única puerta de cambio de estado; visibilidad de paquete: solo los estados la usan. */
    void transitarA(EstadoPedido nuevo) {
        EstadoPedido anterior = this.estado;
        this.estado = nuevo;
        RegistroEventos.INSTANCIA.info("Pedido " + id + ": " + anterior.nombre() + " → " + nuevo.nombre());
        notificarObservadores(anterior);   // Observer difunde lo que State autoriza
    }

    // cobrarTotal(), devolverConPenalizacion(...), aplicarModificacion(...): trabajo real,
    // invocado por los estados; los datos siguen encapsulados aquí.
}

Las dos piezas del módulo encajan como estaban destinadas: State decide qué transiciones son legales; Observer difunde las que ocurren. El bug de "entregar sin pagar" ya no puede existir — EstadoCreado no sobrescribe marcarEntregado, así que la llamada muere en una TransicionInvalidaException antes de notificar nada.

Y el reparto de responsabilidades importa: los estados deciden qué pasa y a dónde se va; el contexto ejecuta el trabajo (cobrarTotal) y custodia sus datos. Estados que manosean los campos del pedido son el primer paso hacia el barro.

Variantes de diseño

  • ¿Estados con o sin datos propios? Los nuestros son sin estado propio (todo el dato vive en Pedido): pueden ser compartidos — constantes reutilizadas en vez de new en cada transición (EstadoPagado.INSTANCIA, espíritu Flyweight). Si un estado necesita datos propios (EN_REPARTO guardando el repartidor asignado), se instancia por transición y los lleva dentro.
  • ¿Quién decide la transición? Aquí, cada estado (lo habitual: la regla "de CREADO se va a PAGADO" es conocimiento del estado). Alternativa: el contexto o una tabla de transiciones externa deciden, y los estados solo ejecutan comportamiento — más declarativo, menos orientado a objetos.
  • Acciones de entrada/salida: métodos alEntrar(Pedido)/alSalir(Pedido) en la interfaz, invocados por transitarA — sitio idóneo para efectos por estado (al entrar en EN_REPARTO, avisar a la CentralReparto).
  • State con enum: en Java, un enum puede implementar métodos por constante (CREADO { void pagar(...) {...} }, PAGADO {...}) — el patrón State completo con la garantía de exhaustividad del enum. Excelente para máquinas sin datos por estado; se queda corto cuando los estados los necesitan (los enum no tienen instancias por transición).

State vs. switch: la comparación honesta

Criterio switch sobre enum Patrón State
Estados y operaciones pocos y estables Gana: menos piezas, todo a la vista Burocracia
Lógica por estado voluminosa/creciente Métodos-archivador Gana: una clase cohesionada por estado
Añadir un estado Tocar N switches (sin ayuda del compilador si hay default) Añadir una clase; lo demás intacto
Añadir una operación Un método nuevo con su switch Tocar la interfaz y (con defaults) solo los estados que la permiten
Ver la máquina completa de un vistazo Regular (esparcida por métodos) Mal (esparcida por clases) — mantén el stateDiagram como documentación
Datos por estado Incómodo (campos que solo valen a veces) Natural (campos del estado concreto)

La regla honesta: el switch no es el villano; es la solución correcta para máquinas pequeñas y estables. State se gana el pan cuando el comportamiento por estado pesa y la máquina evoluciona — exactamente el caso del pedido de PideYa, cuyo ciclo de vida es el corazón cambiante del negocio.

Cuándo usarlo y cuándo no

Úsalo cuando:

  • El comportamiento de un objeto depende de su estado y debe cambiar en ejecución al transitar.
  • Las operaciones tienen condicionales grandes y repetidos sobre el mismo discriminante de estado.
  • Los estados tienen comportamiento sustancial o datos propios, o la máquina crece con el negocio.

Evítalo cuando:

  • La máquina es pequeña y estable: enum + switch (o enum con métodos) es más directo.
  • El "estado" es solo un dato consultado, sin comportamiento asociado: un campo normal basta.
  • Las "variantes de comportamiento" no forman ciclo de vida (no hay transiciones): eso es Strategy — la distinción exacta, en una línea aquí y en detalle en la comparativa: State transita solo entre estados que se conocen; Strategy es elegida desde fuera e ignora a sus hermanas.

Relación con otros patrones

  • Strategy: estructuralmente gemelos (contexto que delega en un objeto intercambiable); la diferencia es de intención y de quién cambia el objeto — mención hecha, careo en la siguiente lección y en la comparativa.
  • Observer: State autoriza transiciones, Observer las difunde; conectados en transitarA.
  • Singleton/Flyweight: estados sin datos compartidos como instancias únicas.
  • Memento: para retroceder la máquina a un punto anterior, se fotografía el contexto (incluido qué estado era).
  • Command: los comandos del panel (ComandoCancelarPedido) invocan operaciones cuya legalidad decide ahora State — el deshacer() debe contar con la TransicionInvalidaException.

Errores comunes

  • Transiciones desde fuera: un pedido.setEstado(new EstadoEntregado()) público destruye la máquina — cualquiera salta las reglas. La puerta (transitarA) debe ser inaccesible al exterior (paquete/privada).
  • Estados que tocan los datos del contexto directamente: los estados deciden, el contexto ejecuta; si EstadoPagado manipula campos de Pedido, la encapsulación se rompe y los estados se acoplan a las tripas.
  • Olvidar el comportamiento por defecto: sin los default que rechazan, cada estado debe implementar todo, y el caso no contemplado vuelve como null o silencio en vez de excepción clara.
  • Explosión de estados por combinar dimensiones: si el pedido necesita "estado de pago" × "estado de cocina" independientes, no crees PagadoYEnHorno: son dos máquinas paralelas, cada una con su State.
  • Duplicar el discriminante: mantener además un enum estado que hay que sincronizar a mano con el objeto estado — dos fuentes de verdad. Si necesitas el nombre para persistir o mostrar, derívalo del objeto (estado.nombre()), no lo guardes aparte.

Ejercicios

Ejercicio 1: nuevo estado sin tocar los viejos

Operaciones introduce EN_ESPERA_REPARTIDOR: tras EN_PREPARACION, si no hay repartidor, el pedido espera; cuando la CentralReparto asigna uno, pasa a EN_REPARTO; se puede cancelar con devolución total (el retraso es culpa nuestra). Implementa EstadoEnEsperaRepartidor e indica qué clases existentes cambian.

Ejercicio 2: dibujar antes de programar

Modela con un stateDiagram-v2 de mermaid la máquina de estados de una incidencia de soporte: ABIERTA → EN_CURSO (cuando un agente la toma) → RESUELTA (con solución) → CERRADA (el cliente confirma); desde RESUELTA puede volver a EN_CURSO (el cliente reclama); desde ABIERTA y EN_CURSO puede pasar a CERRADA directamente si el cliente la retira.

Ejercicio 3: ¿State, switch o Strategy?

Justifica la herramienta para cada caso: (a) el semáforo de disponibilidad de un repartidor: DISPONIBLE ↔ OCUPADO ↔ DESCANSO, sin comportamiento asociado más que el propio valor; (b) el documento de alta de un restaurante: BORRADOR / EN_REVISION / PUBLICADO / RETIRADO, con permisos de edición, validaciones y notificaciones distintas por estado, y estados nuevos previstos; (c) tres formas de calcular el reparto de la propina entre repartidores, elegible por país.

Soluciones

Solución 1:

public class EstadoEnEsperaRepartidor implements EstadoPedido {

    @Override
    public void cancelar(Pedido p) {
        p.devolverTodo();                       // culpa nuestra: sin penalización
        p.transitarA(new EstadoCancelado());
    }

    @Override
    public void marcarEnReparto(Pedido p) {     // lo invoca la asignación de la central
        p.transitarA(new EstadoEnReparto());
    }

    @Override
    public String nombre() { return "EN_ESPERA_REPARTIDOR"; }
}

Cambia solo EstadoEnPreparacion: su marcarEnReparto decide ahora entre transitar a EnReparto (hay repartidor) o a EnEsperaRepartidor (no lo hay) — es el estado origen de la flecha nueva, y las flechas son conocimiento de su origen. Pedido, la interfaz y los otros cuatro estados quedan intactos. Compara con el enfoque switch: habrían cambiado todos los métodos con switch.

Solución 2:

stateDiagram-v2
    [*] --> ABIERTA
    ABIERTA --> EN_CURSO : agente la toma
    ABIERTA --> CERRADA : cliente la retira
    EN_CURSO --> RESUELTA : solución propuesta
    EN_CURSO --> CERRADA : cliente la retira
    RESUELTA --> EN_CURSO : cliente reclama
    RESUELTA --> CERRADA : cliente confirma
    CERRADA --> [*]

(El valor del ejercicio está en las preguntas que el diagrama obliga a hacer: ¿puede reabrirse una CERRADA? Según lo enunciado, no — y ahora está escrito.)

Solución 3: (a) enum a secas (ni siquiera switch): tres valores sin comportamiento — aplicar State sería la sobreingeniería de la lección 01-06; (b) State: comportamiento sustancial por estado, transiciones con reglas y máquina en crecimiento — el caso de manual; (c) Strategy: son algoritmos alternativos elegidos por configuración (país), sin transiciones entre ellos — ningún cálculo de propina "transita" a otro. Que es, precisamente, la lección siguiente.

Conclusión

State reifica el ciclo de vida: cada etapa del pedido es ahora una clase que concentra su comportamiento y conoce sus salidas, Pedido delega sin un solo condicional, y las transiciones ilegales mueren en la puerta con nombre y apellidos — el hueco que Observer dejó abierto queda cerrado, y ambos patrones colaboran en transitarA: State autoriza, Observer difunde. Te llevas también el criterio honesto (switch para máquinas pequeñas y estables; State cuando el comportamiento pesa y la máquina evoluciona) y el hábito de oro: primero el diagrama de estados, después el código.

En el careo final del ejercicio asomó la frontera exacta con el siguiente patrón: cálculos alternativos que no transitan, elegidos desde fuera. PideYa tiene dos de libro esperando: los gastos de envío (por distancia, tarifa plana, gratis por promoción) y la asignación de repartidor que la CentralReparto dejó pendiente de extraer (más cercano, menos cargado, round-robin). Familias de algoritmos intercambiables tras una interfaz: nos vemos en Strategy.

Curso de Patrones de Diseño de Software

Módulo 1: Introducción a los Patrones de Diseño

Módulo 2: Patrones Creacionales

Módulo 3: Patrones Estructurales

Módulo 4: Patrones de Comportamiento

Módulo 5: Aplicación de Patrones de Diseño

Módulo 6: Patrones de Diseño Avanzados

Módulo 7: Recursos Adicionales y Conclusión

© Copyright 2026. Todos los derechos reservados