Un proyecto Spring Boot que empieza como un solo módulo tiende a crecer hasta que nadie sabe bien dónde va cada cosa. La estructura multi-módulo de Maven resuelve eso de raíz: separa las responsabilidades en módulos independientes, cada uno con su propio ciclo de compilación, y el despliegue en la nube se vuelve más predecible. Aquí veremos cómo montar esa estructura, contenerizar la aplicación y llevarla a producción con PostgreSQL, Redis y Flyway.
Estructura del proyecto Maven multi-módulo
El POM raíz declara los módulos hijos y las dependencias comunes. Una división habitual para una API REST:
mi-app/
??? pom.xml (POM raíz)
??? mi-app-api/ (controladores REST, DTOs)
? ??? pom.xml
??? mi-app-dominio/ (lógica de negocio, servicios, puertos)
? ??? pom.xml
??? mi-app-persistencia/ (entidades JPA, repositorios, migraciones)
??? pom.xml
El módulo api depende de dominio; el módulo dominio depende de persistencia. Nunca al revés. Así evitas dependencias circulares y puedes testear la lógica de negocio sin levantar la base de datos.
El POM raíz:
<modules>
<module>mi-app-api</module>
<module>mi-app-dominio</module>
<module>mi-app-persistencia</module>
</modules>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>3.3.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
Dockerfile multi-stage
Un Dockerfile en una sola etapa copia todo el JDK a la imagen final: unos 300 MB innecesarios. Con multi-stage, la primera etapa compila y empaqueta; la segunda solo copia el JAR.
# Etapa 1: compilación
FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /app
COPY . .
RUN ./mvnw package -DskipTests
# Etapa 2: imagen final
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /app/mi-app-api/target/mi-app-api-*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
La imagen resultante ronda los 180-200 MB frente a los 500+ MB de una imagen sin multi-stage. En producción, eso se traduce en arranques más rápidos y menos coste de transferencia.
Docker Compose para desarrollo local
En local no quieres depender de servicios externos. Un docker-compose.yml levanta PostgreSQL, Redis y la propia aplicación con un solo comando.
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: miapp
POSTGRES_USER: miapp
POSTGRES_PASSWORD: secreto
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
ports:
- "6379:6379"
app:
build: .
ports:
- "8080:8080"
environment:
SPRING_PROFILES_ACTIVE: dev
DB_URL: jdbc:postgresql://postgres:5432/miapp
DB_USER: miapp
DB_PASSWORD: secreto
REDIS_HOST: redis
depends_on:
- postgres
- redis
volumes:
postgres_data:
Con docker compose up --build tienes todo corriendo. El volumen de PostgreSQL persiste los datos entre reinicios.
Flyway: migraciones versionadas
Flyway aplica scripts SQL en orden, una sola vez, y registra en una tabla interna qué scripts ya se han ejecutado. La convención de nombres es estricta y es mejor respetarla desde el principio.
src/main/resources/db/migration/
??? V1__init_schema.sql
??? V2__add_indice_email.sql
??? V3__tabla_eventos.sql
El prefijo V, el número de versión, dos guiones bajos y una descripción. Al arrancar la aplicación, Flyway detecta automáticamente qué scripts no se han ejecutado y los aplica en orden.
-- V1__init_schema.sql
CREATE TABLE usuario (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
nombre VARCHAR(100) NOT NULL,
creado_en TIMESTAMPTZ DEFAULT NOW()
);
Nunca modifiques un script que ya está en producción. Si necesitas cambiar algo, crea un nuevo script. Flyway comprueba el checksum de los scripts aplicados y lanzará un error si detecta que han cambiado.
Spring profiles: dev vs prod
Los perfiles de Spring permiten tener configuraciones distintas para cada entorno sin tocar el código.
application.yml (valores por defecto y estructura común):
spring:
application:
name: mi-app
datasource:
url: ${DB_URL}
username: ${DB_USER}
password: ${DB_PASSWORD}
flyway:
enabled: true
locations: classpath:db/migration
application-dev.yml:
spring:
jpa:
show-sql: true
cache:
type: redis
data:
redis:
host: localhost
port: 6379
logging:
level:
root: DEBUG
application-prod.yml:
spring:
jpa:
show-sql: false
data:
redis:
host: ${REDIS_HOST}
port: ${REDIS_PORT:6379}
logging:
level:
root: WARN
com.miapp: INFO
Credenciales: nunca hardcodeadas
Las contraseñas y tokens van en variables de entorno, siempre. En el YAML usas ${NOMBRE_VARIABLE} y la plataforma de despliegue se encarga de inyectarlas. Nunca metas credenciales en el repositorio, aunque sea privado.
Un archivo .env para desarrollo local (añádelo al .gitignore):
DB_URL=jdbc:postgresql://localhost:5432/miapp
DB_USER=miapp
DB_PASSWORD=secreto_local
REDIS_HOST=localhost
Despliegue en Render.com o Railway
Tanto Render como Railway leen el Dockerfile del repositorio y construyen la imagen automáticamente en cada push a la rama principal.
Pasos en Render:
- Crea un servicio web y conecta el repositorio de GitHub.
- Render detecta el Dockerfile y lo usa como método de build.
- En la sección de variables de entorno, añade
DB_URL,DB_USER,DB_PASSWORD,REDIS_HOSTySPRING_PROFILES_ACTIVE=prod. - Crea una base de datos PostgreSQL desde el panel de Render y copia la URL de conexión interna.
- Para Redis, Render ofrece servicio gestionado o puedes usar Upstash.
En Railway el proceso es similar: conectas el repositorio, defines las variables y Railway gestiona el build y el despliegue. Ambas plataformas ofrecen tier gratuito suficiente para un entorno de staging.
Health checks y readiness probes
Spring Boot Actuator expone endpoints de salud que Render, Railway o Kubernetes pueden consultar para saber si la aplicación está lista para recibir tráfico.
# En application.yml
management:
endpoints:
web:
exposure:
include: health, info
endpoint:
health:
show-details: when-authorized
El endpoint /actuator/health devuelve el estado de la base de datos, Redis y cualquier otro componente registrado. Si alguno falla, el health check responde con HTTP 503 y la plataforma no envía tráfico a esa instancia hasta que se recupere.
Redis para sesiones y caché
En este contexto, Redis cumple dos funciones. Como caché, evita consultas repetidas a la base de datos para datos que cambian poco. Como almacén de sesiones, permite que varias instancias de la aplicación compartan las sesiones de usuario sin sesiones pegajosas (sticky sessions) en el balanceador de carga.
@Configuration
@EnableCaching
public class CacheConfig {
// Spring Boot autoconfgura RedisCacheManager si spring-boot-starter-data-redis está en el classpath
}
@Service
public class ProductoService {
@Cacheable(value = "productos", key = "#id")
public Producto buscarPorId(Long id) {
return productoRepository.findById(id).orElseThrow();
}
@CacheEvict(value = "productos", key = "#producto.id")
public Producto actualizar(Producto producto) {
return productoRepository.save(producto);
}
}
Para sesiones, basta añadir spring-session-data-redis al POM y configurar spring.session.store-type=redis. Spring Boot hace el resto.
Aplicar los patrones de diseño en Java para estructurar mejor los módulos es especialmente útil aquí: el patrón Repository en el módulo de persistencia, el patrón Facade en el de dominio y los DTOs en el de API mantienen las capas bien separadas y hacen que el conjunto sea testeable de forma independiente.
Con esta estructura tienes un proyecto que escala bien, un proceso de despliegue reproducible y migraciones controladas. El siguiente paso natural es añadir un pipeline de CI/CD que ejecute los tests antes de cada despliegue y active el rollback automático si el health check falla tras el deploy.
Referencias: documentación oficial de Spring Boot, documentación de Docker.
Imagen: Pexels / panumas nikhomkhai
