Panel de administración de un catálogo de productos electrónicos. Permite dar de alta un producto con su foto, listarlo, corregirlo y darlo de baja. Los datos viven en MongoDB y las imágenes en el sistema de archivos del servidor.
Aplicación web con renderizado en el servidor: Java 21 · Spring Boot 3.3 · Spring Data MongoDB · Thymeleaf, sin frameworks de CSS ni de JavaScript.
- Características
- Stack
- Arquitectura
- Puesta en marcha
- Configuración
- Rutas
- Modelo de datos
- Validaciones
- Capturas
- Identidad visual
- Correcciones aplicadas
- Próximos pasos
CRUD completo sobre MongoDB. Alta, listado, edición y baja de productos contra una colección
productos, usando MongoRepository con ObjectId como identificador. El listado se ordena del
más reciente al más antiguo.
Subida de imágenes con el archivo saneado. Cada foto se guarda en disco con un nombre
compuesto por marca de tiempo y nombre original limpio. El nombre se normaliza y la ruta de
destino se verifica contra el directorio de subidas, de modo que un ../ en el nombre no puede
escribir fuera de él.
Ciclo de vida de la imagen atado al del producto. Al reemplazar la foto se borra la anterior; al eliminar el producto se borra su archivo. Si el borrado del archivo falla, la operación no se interrumpe: queda registrado en el log.
Validación en servidor con mensajes por campo. Bean Validation sobre el DTO del formulario. Cuando algo no pasa, el formulario se vuelve a mostrar con los valores cargados y el error debajo del campo que lo causó.
Etiqueta de estante en vivo. Mientras se completa el formulario, un panel lateral arma la etiqueta del producto —foto, categoría, nombre, marca y precio— tal como se verá en el listado, antes de guardar nada.
Avisos de resultado. Cada alta, edición o baja redirige al listado con un mensaje que confirma lo que pasó. Un identificador inexistente o mal formado no rompe: vuelve al listado con un aviso.
Interfaz propia, sin framework de CSS. Hoja de estilos escrita a mano (~790 líneas), adaptada
a teléfono, con foco de teclado visible y respeto por prefers-reduced-motion.
| Capa | Tecnología |
|---|---|
| Lenguaje | Java 21 |
| Framework | Spring Boot 3.3.4 (Web, Validation, DevTools) |
| Persistencia | Spring Data MongoDB · driver 5.0 |
| Vistas | Thymeleaf 3 con fragmentos |
| Estilos | CSS propio, sin dependencias |
| Interacción | JavaScript sin librerías |
| Build | Maven (con wrapper mvnw) |
Tipografías servidas desde Google Fonts: Archivo (titulares), Instrument Sans (texto), IBM Plex Mono (datos y precios).
Tres capas, con el controlador reducido a coordinar HTTP y la lógica concentrada en el servicio.
Navegador
│
▼
ControladorProductos ──── rutas, binding del formulario, validación, avisos
│
▼
ProductoService ───────── reglas de negocio, guardado y borrado de imágenes
│
▼
ProductosRepository ───── MongoRepository<Productos, ObjectId>
│
▼
MongoDB (colección "productos") + public/images/ (archivos)
src/main/
├── java/com/boostmyfool/beastore/
│ ├── BeastoreApplication.java
│ ├── config/
│ │ └── ConfiguracionWeb.java Publica public/images en la ruta /images/**
│ ├── controllers/
│ │ └── ControladorProductos.java Rutas, validación y manejo de errores
│ ├── models/
│ │ ├── Productos.java Documento de MongoDB
│ │ └── ProductosDTO.java Datos del formulario y sus restricciones
│ ├── repositories/
│ │ └── ProductosRepository.java
│ └── services/
│ ├── ProductoService.java Reglas de negocio y archivos
│ └── ProductoNoEncontradoException.java
└── resources/
├── application.properties
├── static/
│ ├── index.html Portada
│ ├── css/beastore.css Sistema visual completo
│ ├── img/logo.svg Marca, también usada como favicon
│ └── js/{etiqueta,catalogo}.js
└── templates/
├── fragments/base.html Cabecera, barra y avisos compartidos
└── productos/{tablaProductos,crearProducto,editarProducto}.html
- JDK 21 o superior (con
javac; en Fedora es el paquetejava-21-openjdk-devel) - MongoDB 6 o superior corriendo en
localhost:27017 - No hace falta instalar Maven: el repositorio incluye el wrapper
Con el servicio del sistema:
sudo systemctl start mongodO con un contenedor:
podman run -d --name mongo-beastore -p 27017:27017 mongo:7 # o docker run …git clone <url-del-repositorio>
cd store-mongodb
./mvnw spring-boot:runQueda disponible en http://localhost:8080.
./mvnw package
java -jar target/beastore-0.0.1-SNAPSHOT.jarLas imágenes se guardan en
public/images/, una ruta relativa al directorio desde el que se ejecuta la aplicación. Si se corre el.jardesde otra carpeta, conviene fijarUPLOAD_DIRcon una ruta absoluta.
Todo se resuelve con valores por defecto; las dos variables de entorno son opcionales.
| Variable | Valor por defecto | Para qué sirve |
|---|---|---|
MONGODB_URI |
mongodb://127.0.0.1:27017/beastore |
Cadena de conexión a MongoDB |
UPLOAD_DIR |
public/images |
Carpeta donde se guardan las fotos |
Ejemplo con una instancia autenticada y una carpeta fija:
export MONGODB_URI="mongodb://usuario:clave@127.0.0.1:27017/beastore?authSource=admin"
export UPLOAD_DIR="/var/lib/beastore/imagenes"
./mvnw spring-boot:runOtros ajustes en application.properties: tamaño máximo por archivo (5 MB) y por petición
(10 MB), y caché de Thymeleaf desactivada para desarrollo.
| Método | Ruta | Qué hace |
|---|---|---|
GET |
/ |
Portada |
GET |
/productos |
Listado completo, del más reciente al más antiguo |
GET |
/productos/crear |
Formulario de alta |
POST |
/productos/crear |
Valida, guarda la foto y crea el documento |
GET |
/productos/edit/{id} |
Formulario de edición con los datos actuales |
POST |
/productos/edit/{id} |
Valida y actualiza; reemplaza la foto solo si se envió una |
POST |
/productos/delete/{id} |
Elimina el documento y su imagen |
GET |
/images/{archivo} |
Sirve una foto subida |
Colección productos:
{
"_id": ObjectId("6a7674f89005981c9d98d6ea"),
"nombre": "Galaxy S21 FE",
"marca": "Samsung",
"categoria": "Telefonos",
"precio": 489999.0,
"descripcion": "Telefono de 6,4 pulgadas con pantalla de 120 Hz…",
"fechaCreado": ISODate("2026-08-07T21:14:48.342Z"),
"imagenArchivo": "1786148088342_galaxy_s21_fe.jpg"
}imagenArchivo guarda solo el nombre del archivo. La ruta pública se arma en la vista como
/images/{imagenArchivo}, así el directorio de subidas puede moverse sin tocar los documentos.
Categorías disponibles: Notebooks, Telefonos, PCs, Accesorios, Camaras, Impresoras, Otros. Están definidas en el controlador y se inyectan en los formularios, de modo que agregar una implica cambiar una sola línea.
| Campo | Regla | Mensaje |
|---|---|---|
nombre |
obligatorio | El nombre es requerido |
marca |
obligatorio | La marca es requerida |
categoria |
obligatoria | La categoria es requerida |
precio |
mayor o igual a 0 | El precio no puede ser negativo |
descripcion |
entre 10 y 2000 caracteres | La descripcion debe contener por lo menos 10 caracteres |
imagenArchivo |
obligatorio al crear, opcional al editar | El archivo de imagen es necesario. |
Se validan en el servidor con Bean Validation, de modo que la regla se cumple aunque el navegador no coopere.
Cada producto es una fila con su foto, su taxonomía y el precio compuesto como etiqueta de góndola, en cifras monoespaciadas y alineadas.
El panel derecho muestra cómo va a quedar el producto mientras se lo carga, incluida la foto elegida.
Al fallar, el formulario conserva lo cargado y marca cada campo con su motivo.
Los datos llegan cargados, con el identificador y la fecha de alta en solo lectura. La foto se conserva salvo que se elija una nueva.
Pide confirmación nombrando el producto y vuelve al listado con el resultado.
La marca es la etiqueta de estante: la esquina troquelada, la banda ámbar superior y las líneas de precio. Es el mismo elemento que aparece en cada fila del listado y que se arma en vivo en los formularios, así que el logo no decora el producto, lo cita.
Un solo SVG de geometría pura (static/img/logo.svg, 4 formas, sin tipografías incrustadas)
sirve para la barra superior y para el favicon, y se mantiene legible tanto sobre el papel claro
como sobre el azul tinta.
| Rol | Color | Uso |
|---|---|---|
| Tinta | #101c31 |
Barra superior, portada, titulares |
| Papel | #eff0ec |
Fondo de las pantallas de trabajo |
| Ámbar | #f0a202 |
Banda de la etiqueta, acción principal, foco |
| Crema | #fffdf6 |
Cuerpo de la etiqueta de precio |
Tipografías: Archivo para titulares, Instrument Sans para texto e IBM Plex Mono para identificadores, fechas y precios, con cifras tabulares para que las columnas alineen.
Estado del proyecto al retomarlo y qué se hizo:
Las fotos nunca se veían. Las imágenes se guardaban en public/images/, en el sistema de
archivos, pero Spring Boot solo sirve estáticos desde el classpath: cada <img src="/images/…">
respondía 404. Se agregó ConfiguracionWeb, que publica ese directorio en /images/**.
Las validaciones no se ejecutaban. El DTO tenía sus anotaciones, pero a los métodos del
controlador les faltaba @Valid, así que Spring nunca las evaluaba y se guardaban productos sin
nombre o con precio negativo.
Excepción de puntero nulo al enviar el formulario sin archivo. Se llamaba a
getImagenArchivo().isEmpty() sin comprobar antes que hubiera archivo.
Credenciales de MongoDB fijas en el código. La cadena de conexión traía usuario y contraseña
escritos en application.properties, apuntando además a la base admin, lo que anulaba el
spring.data.mongodb.database declarado más abajo. Ahora es una variable de entorno con un valor
por defecto para desarrollo.
Nombre de archivo sin sanear. El nombre original subido se concatenaba a la ruta tal cual. Se normaliza y se verifica que el destino quede dentro del directorio de subidas.
El formulario de edición se rompía al fallar la validación. La vista esperaba el atributo
productoDTO y el controlador publicaba productosDTO; además el producto no volvía al modelo,
así que el reintento fallaba al pintar la foto. Se unificaron los nombres y se repone el modelo.
Errores que devolvían un 500. Un identificador inexistente o mal formado terminaba en una
RuntimeException. Ahora hay manejadores que redirigen al listado con un aviso, incluido el caso
de una imagen que supera el tamaño permitido.
ProductoService estaba vacío. El último commit del repositorio ("Translado a servicios")
había creado el archivo sin contenido, con toda la lógica todavía en el controlador. Se completó
la capa: el controlador quedó en rutas y validación, y el servicio concentra persistencia y
archivos.
Fecha sin formato y sin protección ante nulos. El listado hacía
fechaCreado.toString().substring(0,10). Se reemplazó por formato localizado con verificación
previa.
Codificación rota en application.properties. Los comentarios tenían caracteres ? en lugar
de acentos.
Interfaz. Se reemplazó Bootstrap por una hoja de estilos propia, se rehicieron las cuatro pantallas sobre fragmentos de Thymeleaf compartidos, se agregaron avisos de resultado, estado vacío y la vista previa de la etiqueta. El proyecto tampoco tenía marca ni favicon: la pestaña del navegador mostraba el ícono genérico.
- Buscador y filtro por categoría, con paginación en el listado
- Verificación del tipo real de la imagen, más allá de la extensión
- Pruebas de integración con Testcontainers sobre una instancia real de MongoDB
- Autenticación con Spring Security para separar lectura de administración
- Almacenamiento de imágenes en un servicio externo, para poder escalar a más de una instancia
Proyecto personal de práctica, sin licencia definida.







