1. La ruta como contrato del servicio
Con el directorio de exportación ya hemos puesto orden: el servicio lo recibe desde fuera y ha dejado de adivinar dónde escribir. Pero con esto el escenario de archivos solo deja de ser aleatorio: aún no se vuelve fiable. Cuando un desarrollador hace una exportación a un archivo por primera vez, normalmente quiere simplemente «escribir rápido una cadena en catalog.csv y disfrutar de la vida». Y esto es normal, mientras vivamos en el mundo de un solo SO, una sola máquina y una sola ejecución. Pero en cuanto aparecen Docker y los montajes, la ruta se convierte de repente en un contrato: dónde aparecerá exactamente el archivo, quién lo verá, qué caracteres son válidos, y si el proceso podrá siquiera escribir allí.
La parte más engañosa es que los errores de rutas y permisos parecen «se ha roto Java» o «se ha roto Spring». En realidad, a menudo no se rompe el código de lógica de negocio, sino el entorno circundante: la ruta del contenedor no existe, el bind mount ha cubierto otro directorio, el nombre del archivo contiene : (y hola Windows), el directorio es solo de lectura, y la aplicación cae honestamente porque no sabe escribir en piedra.
Fijemos una idea que ahorra nervios: en la aplicación existe la «ruta del contenedor» (target path), y en Docker existe la «ruta del host» (source path). Son entidades diferentes, y mezclarlas es como confundir la dirección de entrega con la dirección de la tienda.
Para que sea visual, aquí un esquema simple:
flowchart TD Host["Sistema operativo host (Windows/macOS/Linux) directorio del proyecto: ./data/exports"] -->|"bind mount (source)"| Container["FS del contenedor /app/exports (target)"] Container -->|"APP_EXPORT_DIR=/app/exports"| App["App Spring Boot escribe archivo CSV"] App --> File["/app/exports/catalog-2026-03-21T10-15-00.csv"]
La aplicación solo conoce la ruta target dentro del contenedor (por ejemplo, /app/exports). Docker sabe a qué está vinculado en el host (por ejemplo, ./data/exports). Si intentas darle a la aplicación una ruta del host como C:\Users\me\project\data\exports, y se ejecuta en un contenedor Linux — entonces empieza un monólogo muy triste, pero sin risas.
2. Path y resolve en lugar de cadenas
Concatenar rutas con cadenas es uno de esos «pecados de juventud» que en teoría no están prohibidos, pero luego te muerden. La versión típica parece inocente: "dir" + "/" + "file". El problema es que asumes manualmente la responsabilidad por los separadores (/ vs \), barras sobrantes, .. en medio, y por la legibilidad del código. Además, en algún lugar aparecerá inevitablemente dir + file sin barra y tendrás un misterioso error «por qué la ruta es incorrecta».
En Java existe un modelo adecuado para rutas: java.nio.file.Path. Sabe unir segmentos de ruta, entiende los separadores de la plataforma actual, y lo más importante, hace el código legible. Es como dejar de llevar agua en un colador: en teoría se puede, pero ¿para qué?
Ejemplo «cómo NO hacerlo»
// Directorio de exportación (como cadena)
String exportDir = "/app/exports";
// Nombre de archivo (como cadena)
String fileName = "catalog.csv";
// Concatenación manual: fácil olvidar la barra, obtener doble barra o fallar en otro SO
String path = exportDir + "/" + fileName;
System.out.println(path); // /app/exports/catalog.csv
Esto funciona en entornos tipo Linux, pero ya aquí estás gestionando «manualmente» el separador y no controlas si exportDir puede llegar con una barra final /.
Ejemplo «cómo hacerlo» (Path.of + resolve)
import java.nio.file.Path;
// Directorio como Path (ya es un modelo de ruta, no solo una cadena)
Path exportDir = Path.of("/app/exports");
// Añadimos el nombre del archivo como «segmento» de la ruta (sin barras manuales)
Path file = exportDir.resolve("catalog.csv");
System.out.println(file); // /app/exports/catalog.csv
Observa: resolve se lee literalmente como «resuelve el archivo relativo al directorio». Es un lenguaje muy humano — y ahí reside la mitad del éxito.
Una pequeña tabla para fijar conceptos:
| Tarea | Cadenas | Path |
|---|---|---|
| Unir directorio y nombre de archivo | fácil cometer error con / | exportDir.resolve(fileName) |
| Multiplataforma | hay que recordar \ | Path lo gestiona solo |
| Normalización (.., .) | hay que implementar manualmente | path.normalize() |
| Verificaciones (¿existe? ¿directorio?) | a través de File/cadenas, tedioso | Files.* + |
Y sí: puedes usar File.separator, pero es más un «parche» que una solución. Path es un hábito arquitectónico normal.
3. Rutas relativas y absolutas
Cuando escribes Path.of("data", "exports"), Java crea una ruta relativa. Relativa significa «desde el directorio de trabajo actual del proceso». En un inicio local desde el IDE suele ser la raíz del proyecto (pero no siempre), y en un contenedor suele ser el valor de WORKDIR del Dockerfile (por ejemplo, /app). Y aquí es donde los principiantes encuentran su primera «paradoja de archivos»: el mismo código escribe repentinamente en lugares diferentes, porque el directorio de trabajo es distinto.
Por eso la regla básica es muy simple: una ruta relativa está bien, si controlas conscientemente desde dónde se calcula. En un proyecto de aprendizaje a menudo queremos que al iniciar localmente la exportación vaya al repositorio (por ejemplo, ./data/exports), y en el contenedor — al directorio del contenedor (por ejemplo, /app/exports) con la posibilidad de montarlo en el host.
Un matiz importante: APP_EXPORT_DIR para la aplicación es una ruta dentro del proceso, es decir, dentro del contenedor (cuando estamos en el contenedor). No intentes convertir APP_EXPORT_DIR en «la ruta de tu portátil». Eso es nivel de Docker, no de código Java.
Mira el ejemplo que muestra bien la diferencia:
import java.nio.file.Path;
// Ruta relativa: se calcula desde el directorio de trabajo actual del proceso
Path local = Path.of("data", "exports");
System.out.println(local.toString()); // data/exports (o data\exports en Windows)
// Ruta absoluta (estilo Linux): a menudo se usa como target estable dentro del contenedor
Path container = Path.of("/app/exports");
System.out.println(container.toString()); // /app/exports
Ambas rutas son válidas, solo que corresponden a modos de ejecución diferentes. En modo local, la ruta relativa es conveniente porque no está atada a tu C:\Users\... o /Users/.... En modo contenedor, la ruta absoluta dentro del contenedor es conveniente porque es estable y no depende de dónde ejecutes docker run.
Y un pequeño truco útil: dentro del código casi siempre es útil llevar la ruta a una forma normalizada, para que al menos en los registros (logs) veas «qué ha resultado».
import java.nio.file.Path;
// toAbsolutePath(): para ver la ruta completa en los logs y no preguntarse «desde dónde se calcula»
// normalize(): para eliminar "./" y "dir/.." superfluos
Path dir = Path.of("data/exports").toAbsolutePath().normalize();
System.out.println(dir); // /.../project/data/exports
Si alguna vez has pillado «por qué el archivo no está donde debería», apreciarás cuánto reduce el tiempo de adivinanza.
4. Nombres de archivos seguros
A menudo los nombres de archivo se perciben como «bueno, es una cadena y ya». Pero en la vida real son datos de entrada (a veces incluso del usuario), y los sistemas de archivos tienen carácter. Y su carácter es diferente: Linux suele ser más tolerante con los caracteres, Windows es mucho más estricto, macOS tiene sus peculiaridades. Por lo tanto, «funciona en mi contenedor» no garantiza «funciona en el Windows de un estudiante que quiere ver la exportación en el host».
La sorpresa más frecuente: tomas LocalDateTime.now().toString() y obtienes una cadena con dos puntos en la hora, por ejemplo 2026-03-21T10:15:00. En Linux, los dos puntos en un nombre de archivo suelen ser aceptables, en Windows no. Resultado: la exportación «de repente» falla, aunque el código es «absolutamente correcto».
Opción de nombre mala (pero muy popular)
import java.time.LocalDateTime;
// LocalDateTime.toString() contiene ":" en la parte de la hora — en Windows es un carácter prohibido en nombres de archivo
String fileName = "catalog-" + LocalDateTime.now() + ".csv";
System.out.println(fileName); // catalog-2026-03-21T10:15:00.csv
Desde el punto de vista de Java es solo una cadena. Desde el punto de vista de Windows es un intento de insertar un carácter prohibido.
Opción buena: DateTimeFormatter sin caracteres prohibidos
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
// Formato especialmente sin ":" (lo reemplazamos por "-") para que el nombre sea multiplataforma
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH-mm-ss");
String fileName = "catalog-" + LocalDateTime.now().format(fmt) + ".csv";
System.out.println(fileName); // catalog-2026-03-21T10-15-00.csv
Aquí hemos reemplazado : por - precisamente en el formato de hora. Queda legible, ordenable, multiplataforma.
Limpieza (sanitización) del «nombre proveniente de algún lugar externo»
Incluso si generamos el nombre nosotros mismos, es útil tener una pequeña función que elimine los problemas obvios. No es necesario convertirla en una «biblioteca de nombres de archivo de 600 líneas», basta con un mínimo didáctico y honesto.
public class FileNameSanitizer {
public String sanitize(String raw) {
// «Limpieza» mínima: eliminamos los caracteres problemáticos más comunes
return raw
.replace(":", "-")
.replace("/", "_")
.replace("\\", "_");
}
}
Sí, es bastante simple. Y sí, en un proyecto real se podría discutir la normalización Unicode y otras alegrías. Pero en un proyecto de aprendizaje la tarea es otra: eliminar las sorpresas más frecuentes para que las exportaciones dejen de fallar «simplemente por un carácter».
Y un detalle más que muchos olvidan: el nombre del archivo y el directorio son entidades diferentes. Primero eliges el directorio, luego añades el nombre mediante resolve. Si lo mantienes en una sola cadena como "data/exports/catalog.csv", inevitablemente aparecerán errores donde duplicas accidentalmente parte de la ruta o sustituyes el directorio.
5. normalize() y protección contra ../
Ahora viene la parte donde te sentirás un poco más ingeniero adulto: incluso si el curso no tiene un bloque de seguridad separado, los archivos son el lugar donde la seguridad y la «simple meticulosidad» se encuentran sin previo aviso.
El problema es así: crees que te han dado un nombre de archivo catalog.csv, pero te han dado (accidental o intencionadamente) ../../oops.txt. Si simplemente haces exportDir.resolve(fileName) — obtendrás una ruta fuera del directorio de exportación. A veces es solo un error del usuario, a veces un intento desagradable de escribir un archivo «en otro lugar». En cualquier caso, es mejor que el servicio sepa detectarlo.
La solución es simple y muy ilustrativa: resolve + normalize, y luego comprobar con startsWith(exportDir).
import java.nio.file.Path;
// Directorio base de exportación (lo que consideramos «zona permitida»)
Path exportDir = Path.of("/app/exports").normalize();
// «Nombre de archivo» que de repente resultó ser una ruta que sube
String rawName = "../oops.txt";
// resolve() concatenará la ruta, normalize() intentará colapsar ".." y "."
Path candidate = exportDir.resolve(rawName).normalize();
// Verificamos que después de normalizar la ruta sigue empezando con exportDir
if (!candidate.startsWith(exportDir)) {
throw new IllegalArgumentException("Nombre de archivo de exportación no válido: " + rawName);
}
Es importante entender la mecánica: normalize() elimina .. y . a nivel de ruta (donde sea posible). Y startsWith(exportDir) comprueba que la ruta final sigue dentro del directorio deseado. Es un buen «cinturón de seguridad» incluso para un proyecto didáctico: protege contra imprevistos y hace el comportamiento predecible.
Se puede encapsular elegantemente en una pequeña clase para que el código de negocio no se disperse:
import java.nio.file.Path;
public class ExportPathResolver {
private final Path exportDir;
public ExportPathResolver(Path exportDir) {
this.exportDir = exportDir.normalize();
}
public Path resolve(String fileName) {
Path file = exportDir.resolve(fileName).normalize();
if (!file.startsWith(exportDir)) {
throw new IllegalArgumentException("El archivo escapa del directorio de exportación: " + fileName);
}
return file;
}
}
Código corto, lógica en un solo lugar, y ahora CatalogExportService puede pensar en la exportación, no en cómo no salirse al directorio vecino.
6. Permisos de escritura: cómo distinguir «no puedo escribir» de «se rompió la exportación»
Cuando la aplicación no puede escribir un archivo, el desarrollador novato a menudo empieza a sospechar de todo: la generación CSV, la codificación, «el perfil de Spring equivocado», «Docker otra vez con magia». En la práctica, a menudo es más trivial: el directorio no existe, el directorio existe pero es un archivo, el directorio existe pero es de solo lectura, el directorio está montado pero el contenedor no tiene permisos de escritura, o el propio sistema de archivos dentro del entorno no permite la escritura.
Un buen hábito de ingeniería aquí es validar el directorio de antemano y fallar con un mensaje claro antes de comenzar a «hacer la exportación». Así el error se verá como «el directorio de exportación no tiene permisos de escritura», no como «algo, en algún lugar, no está bien en medio».
Aquí tienes un validador mínimo de directorio (sin exagerar):
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
public class ExportDirectoryValidator {
public Path prepare(String configuredDir) throws IOException {
// Llevamos la ruta a forma absoluta y normalizada para que sea clara en los logs
Path dir = Path.of(configuredDir).toAbsolutePath().normalize();
// Creamos los directorios si es necesario (operación idempotente)
Files.createDirectories(dir);
// Verificamos el permiso de escritura por adelantado, para que el error sea «humano» y temprano
if (!Files.isWritable(dir)) {
throw new IllegalStateException("El directorio de exportación no tiene permisos de escritura: " + dir);
}
return dir;
}
}
Files.createDirectories(dir) es conveniente porque es idempotente: si el directorio ya existe, no actúa como si todo estuviera perdido. Y Files.isWritable(dir) da una señal rápida «¿realmente se puede escribir ahí?».
Qué excepciones verás con más frecuencia
En Java, cuando hay problemas con el sistema de archivos, a menudo se encuentran AccessDeniedException, NoSuchFileException, a veces FileSystemException. Para un principiante es útil ver al menos una vez que son clases de error diferentes: la primera es sobre permisos, la segunda sobre ausencia de ruta, la tercera es un contenedor general para «el sistema de archivos dijo “no”».
Se puede escribir un manejo simple que convierta el «error aterrador» en un texto comprensible, sin revelar detalles innecesarios:
import java.nio.file.AccessDeniedException;
import java.nio.file.NoSuchFileException;
public class ExportErrorMapper {
public String toHumanMessage(Exception e) {
if (e instanceof AccessDeniedException) {
return "No hay permisos de escritura en el directorio de exportación";
}
if (e instanceof NoSuchFileException) {
return "No se encuentra el directorio o archivo (revisa la ruta y el mount)";
}
return "Error del sistema de archivos: " + e.getClass().getSimpleName();
}
}
No es una «gran arquitectura», pero como hábito didáctico es excelente. Especialmente cuando la aplicación está en un contenedor y estás tratando de entender si es un error del código o del entorno.
7. Bind mount: matices multiplataforma
Los escenarios de archivos no se rompen solo por Java. El bind mount puede añadir sus propias sorpresas, especialmente si el equipo trabaja en diferentes SO. Aquí es importante conocer exactamente algunas cosas: lo suficiente como para no perder medio día en «por qué no funciona», pero no tanto como para que el curso se convierta repentinamente en un curso de Linux.
Primer matiz: Docker distingue entre source y target. target es la ruta dentro del contenedor (casi siempre estilo Linux, por ejemplo /app/exports). source es la ruta en el host (puede ser ruta de Windows, ruta de macOS, ruta relativa del proyecto). Y entonces APP_EXPORT_DIR debe coincidir exactamente con el target, de lo contrario la aplicación escribirá en un lugar diferente al que montaste.
Segundo matiz: el --mount type=bind explícito normalmente espera que la ruta source ya exista. Si indicas un directorio inexistente, Docker no siempre será «cortés y lo creará» por ti. Esto es bueno: el error es temprano y claro, en lugar de que aparezca algo «en otro sitio y de otra forma». (La sintaxis corta -v en algunos entornos puede comportarse de manera diferente, pero en el contexto didáctico es más útil acostumbrarse a la explicitación.)
Tercer matiz: la ruta de Windows con C: entra en conflicto con los «dos puntos» en la sintaxis antigua del mapeo de volúmenes. Por eso en Windows a menudo es más simple usar --mount ..., o escapar/comillar las cadenas con cuidado. No nos adentraremos en el zoo de shells, pero es útil recordar: si ves un error donde Docker «no entendió» la ruta, no siempre es culpa tuya — a veces es solo la sintaxis de la línea de comandos.
Para no tener que escribir tres cursos separados «Bash / PowerShell / CMD», basta con recordar la idea: la ruta source pásala de forma que sea correcta en tu shell, y la ruta target dentro del contenedor hazla siempre absoluta al estilo Linux.
Por ejemplo, la lógica de montaje en sí podría verse así (conceptualmente):
docker run --rm \
--mount type=bind,source=./data/exports,target=/app/exports \
-e APP_EXPORT_DIR=/app/exports \
your-image:tag
Aquí no hay nada «de Windows» ni «de Linux» en target — es estable. Toda la «plataformidad» reside en source, y eso es normal: el host siempre es diferente.
8. Mini refactorización del servicio de exportación
El nombre de archivo seguro, normalize() y la comprobación de escritura son útiles exactamente en el lugar donde el servicio realmente escribe el archivo. El directorio de exportación ya viene de app.export.dir, así que no se necesita una nueva forma de configuración. Aquí nos importan dos cosas locales: un nombre de archivo seguro y la garantía de que la ruta final no se salga del directorio de exportación.
El nombre del archivo sigue siendo conveniente generarlo por separado — por ejemplo, mediante DateTimeFormatter sin :. Y dentro del ya existente CatalogExportService, que recibe ExportProperties props, la escritura se puede reforzar así:
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
public Path writeCsv(String fileName, String csv) throws IOException {
Path exportDir = ExportDirectoryValidator.prepare(props.dir().toAbsolutePath().normalize());
Path file = exportDir.resolve(fileName).normalize();
if (!file.startsWith(exportDir)) {
throw new IllegalArgumentException("Nombre de archivo no válido: " + fileName);
}
return Files.writeString(file, csv, StandardCharsets.UTF_8);
}
Se consigue algo importante: el directorio sigue viniendo de un único punto de verdad, y el propio servicio se vuelve más resistente a nombres torcidos, rutas relativas extrañas y problemas inesperados de escritura.
Cuando el directorio, el nombre del archivo y los permisos dejan de ser aleatorios, queda verificar lo principal: si el resultado sobrevive al reemplazo del contenedor, o si todavía vive solo hasta el primer docker rm.
9. Errores típicos al trabajar con rutas
Error n.º 1: concatenar rutas con cadenas y «barras según el humor».
Normalmente comienza con dir + "/" + name, y termina con que en un lugar escribieron dir + name, en otro dos barras consecutivas, y en Windows aparece de repente \ y parte del código empieza a vivir su propia vida. Path.of(...) y resolve(...) eliminan casi por completo esta clase de problemas, y además hacen que el código sea más fácil de leer.
Error n.º 2: confundir la ruta del host (source) con la ruta del contenedor (target) e intentar alimentar a la aplicación con C:\....
Dentro del contenedor casi siempre hay un sistema de archivos Linux, y la cadena C:\temp\exports dentro de ese entorno no significa «disco C». Es simplemente un extraño nombre de directorio con dos puntos y barras invertidas, que tú mismo crearás (si tienes suerte con los permisos). La aplicación debe recibir una ruta dentro del contenedor, por ejemplo /app/exports, y la ruta del host vive solo en el comando de Docker como source.
Error n.º 3: generar el nombre de archivo mediante LocalDateTime.toString() y encontrarse con caracteres prohibidos.
En Linux los dos puntos suelen funcionar, en Windows no. Como resultado, la exportación «se rompió solo en una parte del grupo», y comienza la caza de brujas. Un DateTimeFormatter estable con formato HH-mm-ss elimina el problema, y el nombre sigue siendo ordenable y legible.
Error n.º 4: no normalizar la ruta y no comprobar startsWith(exportDir).
Si en el nombre del archivo aparecen de repente ../ (por error o desde entrada externa), resolve(fileName) puede sacarte fuera del directorio de exportación. Esto es una mala imprevisibilidad incluso para un servicio didáctico. normalize() + startsWith() — una pequeña protección que hace que el resultado sea obvio y manejable.
Error n.º 5: pensar que «si el directorio existe, entonces se puede escribir».
Los permisos son una capa separada de la realidad. El directorio puede existir pero ser de solo lectura, puede estar montado sin permisos de escritura, puede ser un sistema de archivos que prohíbe cambios, o simplemente pertenecer a un usuario bajo el cual el proceso no tiene acceso. Una validación rápida mediante Files.createDirectories(...) y Files.isWritable(...) da un error claro antes de que te sumerjas en la investigación.
GO TO FULL VERSION