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:
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):
| Endpoint | Roles | Qué hace |
|---|---|---|
POST /api/auth/login | público | Credenciales → access + refresh token |
POST /api/auth/refresh | público | Refresh token → access token nuevo |
GET /api/platillos | todos | El menú, con precios y disponibilidad |
POST /api/platillos | ADMIN | Alta de platillo |
PUT /api/platillos/{id} | ADMIN | Editar platillo completo |
DELETE /api/platillos/{id} | ADMIN | Retirar del menú |
GET /api/mesas | MESERO, ADMIN | Mesas con su estado (libre/ocupada) y pedido activo |
POST /api/pedidos | MESERO, ADMIN | Crear pedido (el flujo estrella) |
GET /api/pedidos?estado=…&fecha=… | todos | Listar/filtrar pedidos |
GET /api/pedidos/{id} | todos | Detalle con items |
PATCH /api/pedidos/{id}/estado | según transición | Avanzar el estado (ver diagrama en 5.5) |
POST /api/pedidos/{id}/ticket | MESERO, COCINA, ADMIN | Encolar impresión del ticket de cuenta |
GET /api/repartos/activos | ADMIN | Repartos en curso con última ubicación conocida |
GET /api/reportes/ventas?desde=…&hasta=… | ADMIN | Datos agregados para las gráficas del cap. 4.5 |
GET /descargas/app.apk | red local | El 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:
| Canal | Quién se conecta | Mensajes (campo type) |
|---|---|---|
WS /ws/cocina?token=… | pantalla KDS, escritorio | recibe 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=… | escritorio | recibe 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í.
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?
- Una sola configuración: las IPs de las impresoras (caja:
192.168.1.61, cocina:192.168.1.62) viven en el servidor, no en cada teléfono. - Cola con reintentos: si la impresora está sin papel u ocupada, el trabajo espera y se reintenta; el mesero no ve un error ni imprime doble.
- Cualquier cliente puede pedir impresión con el mismo endpoint:
POST /api/pedidos/341/ticketfunciona igual desde Flutter que desde Tauri.
// 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 Boot | Go + Gin | |
|---|---|---|
| Filosofía | Framework completo: seguridad, JPA, STOMP, validación… ya integrados y opinados | Librería mínima de rutas; tú eliges y ensamblas cada pieza (JWT, WS, SQL) |
| Curva de aprendizaje | Más larga: anotaciones, inversión de control, "magia" que hay que entender | Corta: lo que ves es lo que corre; ideal para entender los fundamentos |
| Código que escribirás | Menos (el framework hace mucho) | Más (el hub, los middlewares… los escribes tú) |
| Consumo de recursos | JVM: cientos de MB de RAM | Binario único de ~15 MB; arranca en milisegundos |
| Despliegue en una PC del restaurante | Requiere JVM instalada (o imagen Docker) | Copias un .exe y lo corres; ideal para servidor local |
| Mercado laboral (LATAM) | Enorme en banca y empresa | Creciente 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 habla | App central Tauri, meseros por WiFi, impresoras, pantalla de cocina | Repartidores en 4G/5G (y tú, si algún día quieres ver ventas desde casa) |
| Qué resuelve | Pedidos, cocina, tickets, menú: toda la operación diaria | WS de ubicaciones, reenvío al local, copia de seguridad |
| Base de datos | La principal (PostgreSQL): la fuente de verdad | La 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:
- En la LAN del restaurante, GraphQL no hace nada más rápido. El viaje local cuesta ~1 ms; ahorrar una vuelta o unos campos es invisible. Un REST bien diseñado con DTOs por vista (capítulo 1.6) resuelve lo mismo.
- Donde sí brilla: pantallas tipo dashboard que combinan muchas fuentes (el panel admin: ventas + repartos + top platillos en una consulta), redes lentas (el móvil en 4G), y equipos donde el frontend itera más rápido que el backend — el cliente cambia su consulta sin pedir un endpoint nuevo.
- Su costo: otra capa que aprender (esquema, resolvers, caché propia), la autorización se vuelve por-campo en lugar de por-ruta, y pierdes la caché HTTP simple.
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.