1. Disciplina de configuración
En este punto, ya tenemos todos los detalles: los parámetros de ejecución están fuera del código, el application.yml base se ha vuelto legible, app.attachments.* llega al almacenamiento a través de AttachmentProperties, y la sobreescritura local se aplica sobre la configuración base. Ahora queda reunir todo esto en una línea base reproducible del proyecto y verificar rápidamente tres cosas: si hay código fijo, si los límites de multipart entran en conflicto entre sí, y si ha aparecido una segunda fuente de verdad para storage-dir. Así que comencemos.
Cuando un proyecto es pequeño, es muy fácil caer en el mito: «lo importante es que se ejecute, ya limpiaremos la configuración después». Es como decir: «lo importante es que la bombilla se encienda, ya colocaremos el cableado con cuidado después». Técnicamente, la luz funcionará, pero un día toparás con el cable sin querer y descubrirás lo que significa «¿por qué funcionaba ayer?».
Ahora no se trata de volver a debatir qué se debe externalizar al YAML. Esa cuestión ya la hemos cerrado. Aquí la tarea es más práctica: hacer que Task Tracker API se ejecute de la misma manera en todo el equipo y que la configuración no oculte una segunda copia de la lógica. La configuración del framework vive bajo spring.*, la configuración de la aplicación bajo app.*, los parámetros de ejecución se modifican mediante la configuración base, el perfil y las sobreescrituras, mientras que el contrato de la API y las reglas del dominio permanecen en el código.
2. Claves de inicio obligatorias Task Tracker API
Si vemos la configuración como un pequeño contrato entre el «código» y el «entorno», este contrato debe tener un conjunto mínimo obligatorio de puntos. De lo contrario, se da la situación: la aplicación se inicia «de alguna manera», los errores parecen aleatorios y uno termina depurando no la tarea, sino su propio entorno. En un proyecto educativo no necesitamos universalidad, sino predecibilidad, por lo que es mejor tener algunas claves obligatorias, pero mantenerlas en un lugar claro y comprensible.
En nuestro proyecto existe una buena separación natural: todo lo relacionado con Spring MVC y la infraestructura de servlets está bajo spring.*, y todo lo que se refiere a las reglas de inicio específicas de nuestra aplicación está bajo app.*. Esto no es una «regla por la regla», sino una forma de hacer que el archivo sea legible: incluso una persona que abra application.yml por primera vez entenderá rápidamente dónde están los «ajustes de la plataforma» y dónde los «ajustes del proyecto».
A continuación se muestra una tabla con las claves mínimas obligatorias que conviene mantener en la configuración base (no en un perfil) para que Task Tracker API se ejecute igual para todos.
| Clave | Ejemplo de valor | Para qué sirve | Nivel |
|---|---|---|---|
| spring.mvc.problemdetails.enabled | true | Activa Problem Details como formato base de errores (application/problem+json) | nivel framework (spring.*) |
| spring.servlet.multipart.max-file-size | 10MB | Limita el tamaño de un solo archivo en la subida | nivel framework (spring.*) |
| spring.servlet.multipart.max-request-size | 12MB | Limita el tamaño total de la solicitud multipart (archivo + metadatos + partes auxiliares) | nivel framework (spring.*) |
| app.attachments.storage-dir | ./data/attachments | Indica a la capa de almacenamiento dónde guardar el contenido de los archivos | nivel aplicación (app.*) |
Este conjunto es suficiente para que la línea base de ejecución del proyecto sea explícita y no esté distribuida en la memoria de los desarrolladores.
3. Límites de multipart armonizados
Los límites de multipart son ese caso en el que la configuración puede ser «correcta sintácticamente», pero «incorrecta semánticamente». Y esto es especialmente desagradable porque el error no se manifiesta donde uno espera: la solicitud ni siquiera llega a tu controlador, y tú terminas mirando el registro preguntándote «¿por qué no se llama a mi endpoint? ¡Si estoy seguro de que lo escribí!».
En multipart tenemos al menos dos magnitudes: el tamaño máximo de un solo archivo y el tamaño máximo de toda la solicitud. Si lo simplificamos con una analogía doméstica, max-file-size es el límite del «tamaño de una maleta», y max-request-size es el límite del «peso total del equipaje». Es lógico que el equipaje total no debe ser menor que una maleta, de lo contrario permites una maleta de 20 kg, pero el maletero solo admite 10 kg. Formalmente no has violado nada, pero el viaje terminará en el primer control.
Ejemplo malo, que parece «normal», pero en la práctica genera imprevisibilidad:
spring:
servlet:
multipart:
# Contradicción: un solo archivo puede tener 20MB...
max-file-size: 20MB
# ...pero toda la solicitud completa solo 10MB
max-request-size: 10MB
Ahora te contradices a ti mismo: un solo archivo puede tener 20MB, pero toda la solicitud solo 10MB. Para el usuario, esto se manifestará como «o bien un 413 Payload Too Large, o algún error extraño de carga», y para ti como «¿por qué no funciona si permití 20MB?».
Una buena estrategia base para nuestro proyecto es simple: dado que subimos un solo archivo y un pequeño bloque de metadatos JSON, entonces max-request-size debe ser ligeramente mayor que max-file-size. Por ejemplo, 10MB y 12MB son un par claro. Además, se lee bien a simple vista: se ve que la solicitud incluye no solo el archivo.
spring:
servlet:
multipart:
# Un solo archivo
max-file-size: 10MB
# Toda la solicitud (archivo + metadatos)
max-request-size: 12MB
Otro matiz: es muy fácil poner valores enormes «por si acaso» para que «no estorben». Pero en un proyecto educativo esto es contraproducente: es más importante aprender a controlar los límites que hacer una «subida infinita». Los límites grandes aumentan el riesgo de empezar a almacenar en memoria o registros lo que no se debe almacenar, y en general dificultan la depuración.
4. Valores por defecto explícitos
Los valores por defecto en la configuración son el punto donde los principiantes suelen caer en una trampa: o no ponen ningún valor por defecto («que sea como salga»), o lo ponen de tal manera que el proyecto queda vinculado a una sola máquina («en mi disco D queda todo bonito»). La verdad aquí es simple: el valor base debe ser portable y debe ser visible directamente en la configuración base.
Para app.attachments.storage-dir, ese valor en nuestro proyecto es ./data/attachments. No es un «fallback oculto en una clase Java», sino el valor por defecto explícito del proyecto. Es visible inmediatamente después de abrir application.yml, y por lo tanto cualquier desarrollador entiende a dónde irán los archivos por defecto.
app:
attachments:
# Ruta base para el repositorio; las diferencias locales se sobrescriben con el perfil o la variable de entorno
storage-dir: ./data/attachments
Por eso AttachmentProperties no debe inventar un segundo fallback dentro del código. Si storage-dir es una propiedad crítica, la configuración base la define explícitamente, y @Validated y @NotBlank protegen contra un valor vacío al inicio. De lo contrario, aparecen dos verdades: el YAML dice una cosa, y la clase Java silenciosamente proporciona otra.
5. Eliminamos el código fijo en el almacenamiento
Se considera que la configuración está hecha no cuando has añadido un bonito YAML, sino cuando has eliminado del código las últimas «cadenas mágicas» que contradicen la idea de ajustes externos. Una situación muy típica: añadiste app.attachments.storage-dir, pero en algún lugar del almacenamiento todavía hay un Path.of("/tmp/...") porque «solo estaba probando». Al cabo de una semana ya no recuerdas que era una prueba y empiezas a buscar «por qué los archivos no están ahí».
Después de AttachmentProperties, al almacenamiento le queda una única tarea honesta: tomar un objeto de configuración tipificado y trabajar con él. No debe elegir la ruta por sí mismo, mantener una constante de respaldo ni discutir con el YAML.
package com.example.tasktracker.infrastructure.storage;
import com.example.tasktracker.config.AttachmentProperties;
import java.nio.file.Path;
import org.springframework.stereotype.Service;
@Service
public class LocalAttachmentStorage {
private final Path root;
public LocalAttachmentStorage(AttachmentProperties props) {
// La única fuente de verdad sobre el directorio es el objeto de configuración tipificado
this.root = Path.of(props.storageDir());
}
}
Sí, aquí se podría añadir normalización de la ruta (normalize()), conversión a absoluta (toAbsolutePath()), creación del directorio (Files.createDirectories(root)). Pero eso ya son detalles de la implementación del almacenamiento de archivos. En el contexto de la lección de hoy, es importante fijar la disciplina: el almacenamiento no elige la ruta por sí mismo ni la almacena en los fuentes.
6. Validación de la configuración
La validación de la configuración es un «seguro contra el factor humano». Y es especialmente útil allí donde, sin la configuración, el subsistema no puede funcionar físicamente. Para storage-dir es exactamente nuestro caso: una ruta vacía o ausente debe romper el inicio de inmediato, no aparecer en la primera subida como Path.of(null) o un extraño 500.
Este nodo ya lo tenemos cubierto con @Validated y @NotBlank en AttachmentProperties. Para la línea base final, es importante una idea: storage-dir sigue siendo una clave de nivel de aplicación obligatoria, el application.yml base le da un valor explícito, y el fail-fast al inicio evita que la aplicación entre en un estado semí-funcional.
Otro límite útil: no mezcles en tu mente la «validación de la configuración» con la «validación de las solicitudes». Cuando un usuario envía un JSON incorrecto, eso es un 400 Bad Request y nuestro contrato de error. Cuando la aplicación tiene una configuración rota, es un problema de inicio, y el comportamiento correcto suele ser uno solo: la aplicación no debe arrancar.
7. Línea base application.yml
En la etapa final, queremos hacer dos cosas a la vez: mantener el archivo breve y hacerlo lo suficientemente explícito para no depender de los «valores por defecto de Spring Boot que no recuerdo». Esto no es una contradicción si mantenemos la disciplina: fijas exactamente los ajustes que afectan el comportamiento de tu escenario de aplicación, sin intentar «configurar todo Spring».
A continuación se muestra un ejemplo del application.yml base que se adapta bien al estado actual de nuestro proyecto. Define Problem Details, los límites de multipart y storage-dir. Todo lo demás se deja con los valores por defecto, porque o no afecta al escenario o requiere una disciplina aparte (que en este curso deliberadamente no ampliamos).
spring:
mvc:
problemdetails:
# Formato de errores unificado para la API
enabled: true
servlet:
multipart:
# Un solo archivo a subir
max-file-size: 10MB
# Toda la solicitud multipart completa (archivo + metadatos)
max-request-size: 12MB
app:
attachments:
# Directorio base de almacenamiento; el perfil y las variables de entorno pueden sobrescribirlo
storage-dir: ./data/attachments
En este fragmento, no solo es importante el valor concreto, sino que ahora es fácil «leerlo como un documento». Directamente en el YAML se ve lo que el proyecto espera: errores en Problem Details, subida de archivos de hasta 10MB y almacenamiento de adjuntos en ./data/attachments. Ese es el objetivo de la disciplina: que la configuración deje de ser un «conocimiento secreto del autor del proyecto» y se convierta en una parte explícita del repositorio.
8. Errores típicos en la configuración
Error n.º 1: dejar parte del código fijo «por si acaso».
Muy a menudo, después de implementar @ConfigurationProperties, queda en el código una constante antigua, por ejemplo Path.of("/tmp/task-tracker/uploads"), o un fallback silencioso dentro de AttachmentProperties, y empieza a contradecir al YAML. El resultado es que la configuración existe, el objeto tipificado existe, pero los archivos siguen yendo al lugar antiguo. En cuanto la ruta se ha externalizado a la configuración, no debe quedar una segunda verdad oculta sobre el directorio ni en la implementación del almacenamiento ni en la clase de configuración.
Error n.º 2: hacer que max-request-size sea menor que max-file-size.
Esta configuración es formalmente correcta, pero lógicamente contradictoria. Hace que la subida comience a fallar «antes del controlador», y tú intentas arreglar el lugar equivocado. Una buena regla: si el multipart lleva un solo archivo y una pequeña parte de metadatos, entonces max-request-size debe ser ligeramente mayor que max-file-size, para que no surja la sensación de «hoy se subió, mañana no».
Error n.º 3: externalizar al YAML lo que es parte del contrato de la API.
Trasladar a la configuración restricciones de DTO, reglas de estados, transiciones permitidas, limitaciones en title, es un error de arquitectura. Estas cosas deben vivir en el código, porque describen el contrato de la API y el dominio de la aplicación, no el entorno de ejecución. De lo contrario, terminarás con una situación en la que un desarrollador «ajustó el YAML» y los clientes de repente vieron un comportamiento diferente sin que cambiara la versión de la API.
Error n.º 4: no distinguir entre spring.* y app.* y crear un «vertedero plano de claves».
Cuando las propiedades se mezclan en la raíz del archivo sin estructura, el YAML deja de ser un documento. Una persona lee un «saco de claves» y no entiende qué controla qué. La disciplina aquí es simple: todo lo que pertenece a la plataforma (MVC, multipart) permanece en spring.*, todo lo que pertenece a tu dominio de aplicación (attachments.storage-dir) vive en app.*.
Error n.º 5: no validar las propiedades críticas a nivel de aplicación.
Si storage-dir es obligatorio, la falta de validación significa que la aplicación puede arrancar y luego fallar en el momento de la subida de un archivo. Este es el peor escenario: el problema se descubre tarde, en un lugar impredecible y generalmente con un error menos claro. @Validated y @NotBlank en AttachmentProperties son una forma sencilla de lograr el comportamiento «o arrancamos correctamente, o no arrancamos en absoluto».
GO TO FULL VERSION