Capítulo 03 · Base

El backend del restaurante

El backend es el corazón del sistema: guarda la verdad, aplica las reglas y coordina en tiempo real a la caja, los meseros, la cocina y los repartidores. Aquí diseñamos su arquitectura, su contrato REST completo, su hub de WebSocket y su cola de impresión.

3.1 Arquitectura en capas

Sea Spring o Gin, organiza el backend en tres capas con responsabilidades separadas. La regla es que cada capa solo habla con la de abajo:

Controller / Handler HTTP puro: deserializa DTOs, valida forma, elige código de estado Service reglas de negocio: precios, estados válidos, "difunde a cocina e imprime" Repository persistencia: consultas SQL / JPA, transacciones, nada de negocio Hub WebSocket y cola de impresión notifica
El service es el único que conoce las reglas; controller y repository son "tontos" a propósito.

El beneficio se ve con el caso central del sistema, crear un pedido. Todo lo interesante vive en un solo método del service, y ni el controller ni el repository saben nada de reglas:

@Service
public class PedidoService {

    private final PedidoRepository pedidos;
    private final PlatilloRepository platillos;
    private final CanalCocina canalCocina;       // hub WebSocket (3.3)
    private final ColaImpresion colaImpresion;   // impresión (3.4)
    // constructor…

    @Transactional
    public PedidoResponse crear(NuevoPedidoDTO dto, Long meseroId) {
        Pedido pedido = new Pedido(dto.mesaId(), dto.tipo(), meseroId);

        for (ItemPedidoDTO item : dto.items()) {
            // REGLA: el precio sale de la BD, jamás del cliente
            Platillo platillo = platillos.findById(item.platilloId())
                .orElseThrow(() -> new NegocioException(
                    "el platillo " + item.platilloId() + " no existe"));
            if (!platillo.isDisponible()) {
                throw new NegocioException(platillo.getNombre() + " está agotado");
            }
            pedido.agregarItem(platillo, item.cantidad(), item.notas());
        }

        Pedido guardado = pedidos.save(pedido);

        // efectos colaterales DESPUÉS de persistir:
        canalCocina.difundirPedidoNuevo(guardado);          // pantalla KDS
        colaImpresion.encolarComanda(guardado);             // impresora cocina

        return PedidoResponse.desde(guardado);              // entidad → DTO
    }
}
type PedidoService struct {
    repo       *PedidoRepo
    platillos  *PlatilloRepo
    hub        *ws.Hub            // hub WebSocket (3.3)
    impresion  *impresion.Cola    // impresión (3.4)
}

func (s *PedidoService) Crear(dto NuevoPedidoDTO, meseroID int64) (*Pedido, error) {
    pedido := NuevoPedido(dto.MesaID, dto.Tipo, meseroID)

    for _, item := range dto.Items {
        // REGLA: el precio sale de la BD, jamás del cliente
        platillo, err := s.platillos.PorID(item.PlatilloID)
        if err != nil {
            return nil, ErrNegocio{fmt.Sprintf(
                "el platillo %d no existe", item.PlatilloID)}
        }
        if !platillo.Disponible {
            return nil, ErrNegocio{platillo.Nombre + " está agotado"}
        }
        pedido.AgregarItem(platillo, item.Cantidad, item.Notas)
    }

    if err := s.repo.Guardar(pedido); err != nil {
        return nil, err
    }

    // efectos colaterales DESPUÉS de persistir:
    s.hub.Difundir("cocina", ws.Mensaje{
        Type: "PEDIDO_NUEVO", Datos: pedido})   // pantalla KDS
    s.impresion.EncolarComanda(pedido)          // impresora cocina

    return pedido, nil
}

3.2 El contrato completo de la API

Este es el contrato REST de todo el sistema. Cada fila indica quién puede llamarla (la autorización del capítulo 2.5 hecha endpoint):

EndpointRolesQué hace
POST /api/auth/loginpúblicoCredenciales → access + refresh token
POST /api/auth/refreshpúblicoRefresh token → access token nuevo
GET /api/platillostodosEl menú, con precios y disponibilidad
POST /api/platillosADMINAlta de platillo
PUT /api/platillos/{id}ADMINEditar platillo completo
DELETE /api/platillos/{id}ADMINRetirar del menú
GET /api/mesasMESERO, ADMINMesas con su estado (libre/ocupada) y pedido activo
POST /api/pedidosMESERO, ADMINCrear pedido (el flujo estrella)
GET /api/pedidos?estado=…&fecha=…todosListar/filtrar pedidos
GET /api/pedidos/{id}todosDetalle con items
PATCH /api/pedidos/{id}/estadosegún transiciónAvanzar el estado (ver diagrama en 5.5)
POST /api/pedidos/{id}/ticketMESERO, COCINA, ADMINEncolar impresión del ticket de cuenta
GET /api/repartos/activosADMINRepartos en curso con última ubicación conocida
GET /api/reportes/ventas?desde=…&hasta=…ADMINDatos agregados para las gráficas del cap. 4.5
GET /descargas/app.apkred localEl APK de la app móvil (cap. 4.6)

Y el contrato de tiempo real. Nota la simetría: REST para escribir, WebSocket para enterarse:

CanalQuién se conectaMensajes (campo type)
WS /ws/cocina?token=…pantalla KDS, escritoriorecibe PEDIDO_NUEVO, PEDIDO_ACTUALIZADO
WS /ws/ubicaciones?token=…repartidores (envían, al backend de la nube); escritorio admin (recibe, del local vía túnel — ver 3.7)envía/recibe UBICACION {repartidorId, lat, lng, ts}
WS /ws/notificaciones?token=…escritoriorecibe LLAMADA_ENTRANTE, REPARTO_COMPLETADO, TICKET_IMPRESO

Escribe este contrato antes de programar cualquier app, y mantenlo en un solo lugar (un archivo API.md en el repo, o una especificación OpenAPI si quieres generar documentación interactiva con Swagger UI). Cuando el equipo de móvil y el de escritorio programan contra el mismo contrato escrito, se pueden desarrollar en paralelo sin esperarse: cada quien "mockea" al servidor mientras tanto.

3.3 El hub de WebSocket

Un hub es el objeto del backend que mantiene la lista de conexiones vivas, agrupadas por canal, y ofrece una operación: difundir este mensaje a todos los suscriptores de tal canal. Toda la comunicación en tiempo real del sistema pasa por aquí.

Flutter repartidor envía UBICACION Flutter mesero POST /api/pedidos Hub canales: cocina, ubicaciones, notif. Escritorio: mapa recibe UBICACION Pantalla cocina recibe PEDIDO_NUEVO vía service
Entra por un lado (WS o REST), el hub difunde al canal correcto.

Spring no te hace escribir el hub: su soporte de STOMP (un mini-protocolo de mensajería sobre WebSocket) trae suscripciones por "topic" ya resueltas. Los clientes usan la librería @stomp/stompjs en JS o stomp_dart_client en Flutter.

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        // los clientes conectan a ws://servidor:8080/ws
        registry.addEndpoint("/ws").setAllowedOriginPatterns("*");
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.enableSimpleBroker("/topic");      // servidor → clientes
        registry.setApplicationDestinationPrefixes("/app"); // clientes → servidor
    }
}
@Component
public class CanalCocina {
    private final SimpMessagingTemplate plantilla;
    public CanalCocina(SimpMessagingTemplate plantilla) { this.plantilla = plantilla; }

    /** Lo llama PedidoService al crear un pedido. Llega a todo
        cliente suscrito a /topic/cocina. */
    public void difundirPedidoNuevo(Pedido pedido) {
        plantilla.convertAndSend("/topic/cocina",
            Map.of("type", "PEDIDO_NUEVO", "pedido", PedidoResponse.desde(pedido)));
    }
}

/* Y para mensajes que llegan DE los clientes (la ubicación del
   repartidor), un controller anotado: */
@Controller
public class UbicacionController {
    private final SimpMessagingTemplate plantilla;
    // constructor…

    @MessageMapping("/ubicacion")            // cliente publica a /app/ubicacion
    public void recibir(UbicacionDTO dto) {
        // reenviamos al mapa del escritorio:
        plantilla.convertAndSend("/topic/ubicaciones",
            Map.of("type", "UBICACION", "datos", dto));
    }
}

En Go el hub se escribe a mano con gorilla/websocket — son ~60 líneas y entenderlas te enseña exactamente qué hace STOMP por ti. El patrón canónico: un goroutine dueño del estado, y canales de Go para registrar, quitar y difundir (así no necesitas mutexes).

package ws

import "github.com/gorilla/websocket"

type Mensaje struct {
    Type  string `json:"type"`
    Datos any    `json:"datos,omitempty"`
    Canal string `json:"-"`      // no viaja: solo enruta internamente
}

type cliente struct {
    conn  *websocket.Conn
    canal string
    salida chan []byte          // buffer de envío por cliente
}

type Hub struct {
    clientes  map[*cliente]bool
    registrar chan *cliente
    quitar    chan *cliente
    difundir  chan Mensaje
}

func NuevoHub() *Hub {
    h := &Hub{
        clientes:  map[*cliente]bool{},
        registrar: make(chan *cliente),
        quitar:    make(chan *cliente),
        difundir:  make(chan Mensaje, 64),
    }
    go h.correr()
    return h
}

// correr es el ÚNICO goroutine que toca h.clientes → sin mutexes.
func (h *Hub) correr() {
    for {
        select {
        case c := <-h.registrar:
            h.clientes[c] = true
        case c := <-h.quitar:
            if h.clientes[c] {
                delete(h.clientes, c)
                close(c.salida)
            }
        case msg := <-h.difundir:
            datos, _ := json.Marshal(msg)
            for c := range h.clientes {
                if c.canal != msg.Canal {
                    continue
                }
                select {
                case c.salida <- datos:
                default:            // cliente lento: lo desconectamos
                    delete(h.clientes, c)
                    close(c.salida)
                }
            }
        }
    }
}

func (h *Hub) Difundir(canal string, msg Mensaje) {
    msg.Canal = canal
    h.difundir <- msg
}
var upgrader = websocket.Upgrader{
    // en la red local del restaurante aceptamos cualquier origen:
    CheckOrigin: func(r *http.Request) bool { return true },
}

func (h *Hub) Conectar(canal string) gin.HandlerFunc {
    return func(c *gin.Context) {
        // el token ya fue validado por el middleware (cap. 2.7)
        conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
        if err != nil {
            return
        }
        cl := &cliente{conn: conn, canal: canal, salida: make(chan []byte, 16)}
        h.registrar <- cl

        // goroutine de escritura: drena cl.salida hacia el socket
        go func() {
            for datos := range cl.salida {
                conn.WriteMessage(websocket.TextMessage, datos)
            }
            conn.Close()
        }()

        // goroutine de lectura: mensajes del cliente (p.ej. UBICACION)
        go func() {
            defer func() { h.quitar <- cl }()
            for {
                _, datos, err := conn.ReadMessage()
                if err != nil {
                    return              // cliente desconectado
                }
                var msg Mensaje
                if json.Unmarshal(datos, &msg) == nil && msg.Type == "UBICACION" {
                    // ubicación del repartidor → reenviar al mapa admin
                    h.Difundir("ubicaciones", msg)
                }
            }
        }()
    }
}

// registro de rutas en main.go:
//   hub := ws.NuevoHub()
//   r.GET("/ws/cocina",      auth.WsAuth("COCINA","ADMIN"), hub.Conectar("cocina"))
//   r.GET("/ws/ubicaciones", auth.WsAuth("REPARTIDOR","ADMIN"), hub.Conectar("ubicaciones"))
//   r.GET("/ws/notificaciones", auth.WsAuth("ADMIN"), hub.Conectar("notificaciones"))

Los WebSockets se caen. Wifi que parpadea, teléfonos que duermen, laptops que hibernan. Diseña asumiéndolo: (1) los clientes reconectan solos con reintentos y espera creciente (backoff); (2) al reconectar, el cliente re-pide por REST lo que se perdió (GET /api/pedidos?estado=PENDIENTE reconstruye la pantalla de cocina); (3) el servidor manda un ping periódico y cierra conexiones que no responden (heartbeat). El punto 2 es la razón profunda de "escribir por REST, escuchar por WS": la base de datos siempre puede reconstruir el presente.

3.4 La cola de impresión

Las impresoras térmicas de red escuchan en el puerto TCP 9100: les abres un socket, les escribes bytes ESC/POS y ellas imprimen. (El lenguaje ESC/POS en sí — comandos, acentos, corte de papel — se explica a fondo en el capítulo 4.3; aquí nos ocupa la arquitectura.) ¿Por qué imprime el backend y no cada app?

// La cola es un ejecutor de un solo hilo: los trabajos se imprimen
// en orden y nunca dos a la vez sobre la misma impresora.
@Component
public class ColaImpresion {

    private final ExecutorService cola = Executors.newSingleThreadExecutor();
    @Value("${impresoras.cocina}") String ipCocina;   // 192.168.1.62
    @Value("${impresoras.caja}")   String ipCaja;     // 192.168.1.61

    public void encolarComanda(Pedido pedido) {
        encolar(ipCocina, TicketBuilder.comandaCocina(pedido));
    }
    public void encolarTicketCuenta(Pedido pedido) {
        encolar(ipCaja, TicketBuilder.cuentaCliente(pedido));
    }

    private void encolar(String ip, byte[] datosEscPos) {
        cola.submit(() -> {
            for (int intento = 1; intento <= 3; intento++) {
                try (Socket s = new Socket()) {
                    s.connect(new InetSocketAddress(ip, 9100), 3000);
                    s.getOutputStream().write(datosEscPos);
                    s.getOutputStream().flush();
                    return;                        // impreso ✔
                } catch (IOException e) {
                    log.warn("impresora {} intento {}: {}", ip, intento, e.getMessage());
                    try { Thread.sleep(2000L * intento); }
                    catch (InterruptedException ie) { return; }
                }
            }
            log.error("impresora {} inaccesible; trabajo descartado", ip);
            // aquí puedes difundir un aviso por /ws/notificaciones
        });
    }
}

Para no armar los bytes ESC/POS a mano en Java, la librería com.github.anastaciocintra:escpos-coffee ofrece una API fluida (escpos.writeLF("..."), estilos, corte). El capítulo 4.3 muestra los bytes crudos para que sepas qué genera por dentro.

// La cola es un canal + un goroutine consumidor: los trabajos se
// imprimen en orden y nunca dos a la vez sobre la misma impresora.
package impresion

type Trabajo struct {
    IP    string   // impresora destino
    Datos []byte   // bytes ESC/POS ya armados
}

type Cola struct{ trabajos chan Trabajo }

func NuevaCola() *Cola {
    c := &Cola{trabajos: make(chan Trabajo, 100)}
    go c.consumir()
    return c
}

func (c *Cola) consumir() {
    for t := range c.trabajos {
        var ok bool
        for intento := 1; intento <= 3; intento++ {
            if err := imprimir(t); err == nil {
                ok = true
                break
            }
            time.Sleep(time.Duration(intento) * 2 * time.Second)
        }
        if !ok {
            log.Printf("impresora %s inaccesible; trabajo descartado", t.IP)
            // aquí puedes difundir un aviso por /ws/notificaciones
        }
    }
}

func imprimir(t Trabajo) error {
    conn, err := net.DialTimeout("tcp", t.IP+":9100", 3*time.Second)
    if err != nil {
        return err
    }
    defer conn.Close()
    _, err = conn.Write(t.Datos)
    return err
}

func (c *Cola) EncolarComanda(p *Pedido) {
    c.trabajos <- Trabajo{IP: cfg.ImpresoraCocina,
        Datos: ComandaCocina(p)}   // arma los bytes (cap. 4.3)
}

3.5 Spring Boot o Gin: cómo decidir

Ambos resuelven este proyecto de sobra. La decisión es sobre ti y tu contexto, no sobre benchmarks:

Java + Spring BootGo + Gin
FilosofíaFramework completo: seguridad, JPA, STOMP, validación… ya integrados y opinadosLibrería mínima de rutas; tú eliges y ensamblas cada pieza (JWT, WS, SQL)
Curva de aprendizajeMás larga: anotaciones, inversión de control, "magia" que hay que entenderCorta: lo que ves es lo que corre; ideal para entender los fundamentos
Código que escribirásMenos (el framework hace mucho)Más (el hub, los middlewares… los escribes tú)
Consumo de recursosJVM: cientos de MB de RAMBinario único de ~15 MB; arranca en milisegundos
Despliegue en una PC del restauranteRequiere JVM instalada (o imagen Docker)Copias un .exe y lo corres; ideal para servidor local
Mercado laboral (LATAM)Enorme en banca y empresaCreciente en startups e infraestructura

Si tu meta principal es aprender los conceptos a fondo, Go + Gin te obliga a construir (y por tanto entender) el hub, los middlewares y la cola. Si tu meta es perfil profesional Java o esperas que el sistema crezca con muchos módulos, Spring Boot es la inversión correcta. Elige uno y termina el proyecto: cambiar de framework a medio camino es la forma más segura de no terminar nunca.

3.6 Estructura del proyecto

src/main/java/com/turestaurante/
├── auth/            AuthController, JwtService, JwtFilter, RefreshTokenService
├── platillos/       PlatilloController, PlatilloService, PlatilloRepository, Platillo
├── pedidos/         PedidoController, PedidoService, PedidoRepository,
│                    Pedido, ItemPedido, dto/ (NuevoPedidoDTO, PedidoResponse…)
├── repartos/        RepartoController, UbicacionController (STOMP)
├── impresion/       ColaImpresion, TicketBuilder
├── ws/              WebSocketConfig, CanalCocina
└── config/          SecurityConfig, propiedades

src/main/resources/
└── application.yml  puerto, datasource, jwt.secret (desde env), IPs de impresoras
restaurante-api/
├── main.go            arma todo: config, hub, cola, rutas
├── auth/              jwt.go, middleware.go, login.go
├── platillos/         handler.go, service.go, repo.go, modelo.go
├── pedidos/           handler.go, service.go, repo.go, modelo.go, dto.go
├── repartos/          handler.go
├── impresion/         cola.go, tickets.go (arma bytes ESC/POS)
├── ws/                hub.go, handler.go
└── config/            config.go (lee env: puerto, BD, JWT_SECRET, IPs impresoras)

En ambos casos la organización es por feature (pedidos, platillos, auth) y no por tipo técnico (controllers/, services/, models/). Cuando toques "pedidos", todo lo de pedidos está en una carpeta. Esta misma idea reaparece en el frontend en el capítulo 6.4.

3.7 Local + nube: la arquitectura híbrida (y GraphQL)

Todo lo anterior describe un backend, pero el sistema real tiene dos instancias con papeles distintos (el diagrama está en la portada): una corre en una PC dentro del restaurante y otra en un servidor de internet (un VPS). El código es el mismo proyecto con configuración distinta — no dos códigos que mantener:

Backend local (en el restaurante)Backend en la nube (VPS)
Quién le hablaApp central Tauri, meseros por WiFi, impresoras, pantalla de cocinaRepartidores en 4G/5G (y tú, si algún día quieres ver ventas desde casa)
Qué resuelvePedidos, cocina, tickets, menú: toda la operación diariaWS de ubicaciones, reenvío al local, copia de seguridad
Base de datosLa principal (PostgreSQL): la fuente de verdadLa de respaldo: recibe eventos en vivo + copia diaria
¿Sin internet?Sigue funcionando completo (esa es la gracia)Los repartidores quedan "a ciegas" hasta que vuelva; el local ni se entera

¿Por qué no todo en la nube, que es "lo normal"? Porque un restaurante no puede dejar de cobrar ni de mandar comandas a cocina cuando el internet parpadea, y porque un viaje a un servidor remoto (50–200 ms) se siente en cada tecla del punto de venta frente a los ~1 ms de la LAN. Y ¿por qué no todo local? Porque un teléfono en 4G no puede entrar a tu red local sin abrir puertos en el módem (frágil e inseguro). La división híbrida toma lo mejor de cada lado.

El túnel: cómo se conectan los dos backends sin abrir puertos

El problema clásico: la nube no puede iniciar una conexión hacia el restaurante, porque el módem (NAT) bloquea todo lo entrante. La solución estándar invierte la dirección: el backend local abre una conexión WebSocket saliente hacia la nube y la mantiene viva. Las conexiones salientes siempre pasan; y una vez abierto el canal, es bidireccional (capítulo 1.7): la nube empuja las ubicaciones hacia abajo y el local sube los eventos de respaldo — sin IP fija, sin puertos abiertos, sin tocar el módem.

// El local se conecta a la nube como un cliente WebSocket más,
// autenticado con un token de servicio (no de usuario).
func MantenerTunel(hub *ws.Hub, cola *respaldo.Cola) {
    for {   // si se cae, reconecta para siempre
        conn, _, err := websocket.DefaultDialer.Dial(
            "wss://api.turestaurante.com/ws/tunel?token="+os.Getenv("TOKEN_SERVICIO"),
            nil)
        if err != nil {
            time.Sleep(5 * time.Second)
            continue
        }

        // ↑ subir eventos de respaldo pendientes (patrón outbox)
        go cola.EnviarPendientes(conn)

        // ↓ bajar lo que la nube reenvía (ubicaciones de repartidores)
        for {
            var msg ws.Mensaje
            if err := conn.ReadJSON(&msg); err != nil {
                break   // túnel caído → reintentar el Dial
            }
            if msg.Type == "UBICACION" {
                // se difunde al hub local: el mapa del escritorio
                // lo recibe SIN saber que vino de la nube
                hub.Difundir("ubicaciones", msg)
            }
        }
        conn.Close()
    }
}
// Spring puede ser cliente WebSocket, no solo servidor:
@Component
public class ConectorNube {

    private final CanalCocina canalLocal;      // difunde al hub local
    private final ColaRespaldo colaRespaldo;   // eventos pendientes
    // constructor…

    @Scheduled(fixedDelay = 5000)   // si no hay sesión viva, reintenta
    public void mantenerTunel() {
        if (sesionActiva()) return;
        var cliente = new StandardWebSocketClient();
        cliente.execute(new TextWebSocketHandler() {
            @Override
            public void handleTextMessage(WebSocketSession s, TextMessage m)
                    throws IOException {
                var msg = json.readTree(m.getPayload());
                if ("UBICACION".equals(msg.get("type").asText())) {
                    // el mapa del escritorio lo recibe del hub local
                    // sin saber que vino de la nube
                    canalLocal.difundirUbicacion(msg);
                }
            }
            @Override
            public void afterConnectionEstablished(WebSocketSession s) {
                guardarSesion(s);
                colaRespaldo.enviarPendientes(s);   // ↑ outbox
            }
        }, "wss://api.turestaurante.com/ws/tunel?token={t}", tokenServicio);
    }
}

El respaldo: patrón outbox

"Subir los datos a la nube" falla exactamente cuando más importa: sin internet. El patrón outbox lo resuelve con una tabla-buzón en la BD local: cada operación importante, además de guardarse normal, deja un evento pendiente; un worker los envía por el túnel y los marca enviados. Si no hay internet, se acumulan; al volver, se ponen al día solos. La nube los aplica a su BD de respaldo, y una copia binaria nocturna (pg_dump subido al VPS) completa el seguro: si el disco de la PC local muere, restauras el dump y re-aplicas los eventos del día.

create table eventos_salientes (
  id         bigserial primary key,
  tipo       text not null,        -- 'PEDIDO_CREADO', 'PEDIDO_PAGADO', …
  payload    jsonb not null,       -- el DTO del evento, tal cual
  creado_en  timestamptz not null default now(),
  enviado_en timestamptz           -- null = pendiente de subir
);

-- el worker del túnel corre esto en ciclo:
--   select * from eventos_salientes where enviado_en is null order by id
--   → los envía por el WebSocket → update … set enviado_en = now()

¿Y GraphQL? Qué es y cuándo conviene aquí

GraphQL es una alternativa al estilo REST para la parte de lectura y escritura de datos: en lugar de muchos endpoints fijos, expones un solo POST /graphql con un esquema tipado, y cada cliente pide exactamente los campos que necesita:

REST (el servidor decide la forma):        GraphQL (el cliente decide la forma):
GET /api/pedidos/341                        POST /graphql
→ te llega TODO el PedidoResponse           query {
                                              pedido(id: 341) {
GET /api/pedidos/341/items                      estado
→ segunda vuelta para los items                 total
                                                items { nombre cantidad }
                                              }
                                            }
                                            → una sola vuelta, solo esos campos

Seamos precisos con la palabra "rápido", porque es el argumento habitual y solo a veces es cierto:

Recomendación concreta para tu sistema: construye primero el contrato REST + WebSocket de este capítulo — es la base conceptual de todo lo demás y te sobra para operar. Si después el panel de administración crece en consultas combinadas, agrega GraphQL solo para lecturas del dashboard, conviviendo con REST: spring-boot-starter-graphql en Spring, gqlgen en Go, y urql o Apollo Client en React. GraphQL y REST no son excluyentes; casi todos los sistemas maduros sirven ambos.