# Conociendo Spring Batch: Guía Completa para Procesos por Lotes!!!
Tabla de Contenidos
Conociendo Spring Batch: Guía Completa para Procesos por Lotes!!!
Muchachones, los sistemas empresariales modernos no viven solo de interacciones en tiempo real. Detrás de escena, en
nuestro querido Backend, se ejecutan procesos masivos que son la columna vertebral de la operación: conciliaciones
bancarias nocturnas, generación de reportes complejos, migración de datos, ETL (Extracción, Transformación y Carga), y
un largo etc!!! Ejecutar estas tareas de forma ingenua, con un simple bucle for en un servicio, es un desastre:
problemas de memoria, falta de transaccionalidad, y nula capacidad de recuperación ante fallos.
Aquí es donde Spring Batch brilla. No es solo un ejecutor de tareas, es un framework completo y resiliente, diseñado para gobernar el procesamiento de grandes volúmenes de datos de manera robusta, eficiente y sobretodo gestionable.
Asi que bros, en esta guía, construiremos desde cero un caso de uso, explicando no solo el “cómo”, sino el “porqué” de cada decisión. Al final, tendrás la arquitectura mental para construir tus propias soluciones de batch a prueba de balas.
El Escenario: Reporte de Comisiones de Ventas Parametrizado
Construiremos un proceso que:
-
Lee las ventas de una base de datos para una fecha específica, proporcionada como parámetro.
-
Procesa cada venta para calcular una comisión basada en la región y la transforma en un DTO optimizado para el reporte.
-
Escribe el resultado en un archivo CSV con un nombre dinámico (ej:
reporte-cesarlead-ventas-2025-09-01.csv).
Arquitectura Fundamental de Spring Batch
Antes de escribir una sola línea de código, es crucial entender la jerarquía:
-
Job: Es el proceso batch completo. Un$Job$está compuesto por uno o más$Steps$. -
Step: Es una fase independiente dentro del$Job$. Un$Step$simple contiene un$ItemReader$, un$ItemProcessor$y un$ItemWriter$. -
ItemReader: Se encarga de leer los datos de una fuente (base de datos, archivo, API, etc.), un elemento a la vez. -
ItemProcessor: (Opcional) Realiza la lógica de negocio o transformación sobre el elemento leído. Puede filtrar elementos devolviendonull. -
ItemWriter: Escribe los elementos procesados (generalmente en lotes o “chunks”) a un destino (archivo, base de datos, etc.). -
JobRepository: Proporciona el mecanismo de persistencia para el estado del framework (metadatos de las ejecuciones, estados de los steps, etc.). Spring Boot lo configura automáticamente si usas una base de datos. -
JobLauncher: Es la interfaz simple para lanzar un$Job$con un conjunto de$JobParameters$.
El patrón Reader-Processor-Writer es la esencia de Spring Batch, ya que promueve una separación de conceptos clara, facilita las pruebas unitarias de cada componente y optimiza el uso de recursos a través del **procesamiento por chunks **.
Qué es el Procesamiento por Chunks (Lotes)???
En lugar de leer, procesar y escribir cada elemento individualmente en una transacción, Spring Batch agrupa las
operaciones. Un $Step$ orientado a chunks leerá y procesará elementos uno por uno hasta alcanzar un tamaño de chunk
predefinido (ej. 100 elementos). Luego, entregará esa lista de 100 elementos procesados al $ItemWriter$ para que los
escriba todos juntos en una única transacción.
Ventajas:
-
Eficiencia: Reduce drásticamente la sobrecarga de transacciones.
-
Resiliencia: Si la escritura del chunk 5 falla, la transacción se revierte solo para ese chunk. El
$Job$puede reiniciarse desde el final del chunk 4, sin procesar de nuevo miles de registros. -
Gestión de Memoria: Mantiene una huella de memoria baja y predecible.
Implementación Paso a Paso
1. Dependencias (pom.xml)
La base de nuestro proyecto. Necesitamos el starter de Batch, JPA para el acceso a datos, el driver de la base de datos (usaremos H2 en memoria para simplicidad) y Lombok para reducir el código repetitivo.
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-batch</artifactId> </dependency>
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency>
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>
<dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency>
<dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency></dependencies>2. Configuración de la Aplicación (application.yml)
Configuramos la base de datos H2, deshabilitamos la ejecución automática de jobs al inicio y definimos una ruta base para nuestros reportes.
spring: datasource: url: jdbc:h2:mem:cesarleadb driverClassName: org.h2.Driver username: cesarlead password: cesarlead h2: console: enabled: true path: /h2-console jpa: hibernate: ddl-auto: update show-sql: true batch: # 'always' para que Spring Batch cree sus tablas de metadatos al iniciar. # Usar 'embedded' en producción con H2/HSQLDB o 'never' con BBDD estándar. jdbc: initialize-schema: always job: # Deshabilitamos la ejecución de todos los jobs al levantar la app. # Queremos control total sobre cuándo se ejecutan. enabled: false
# Configuración personalizada para nuestra aplicaciónapp: reports: base-path: ./reports/3. Modelo de Dominio (Entity) y DTO
Separamos claramente la entidad persistida (Sale) del objeto de transferencia de datos que usaremos para el CSV (
SaleReportDto). Esta es una práctica fundamental para desacoplar la capa de persistencia de la de presentación.
Entidad Sale.java
import jakarta.persistence.Entity;import jakarta.persistence.Id;import lombok.AllArgsConstructor;import lombok.Data;import lombok.NoArgsConstructor;
import java.math.BigDecimal;import java.time.LocalDate;
@Data@NoArgsConstructor@AllArgsConstructor@Entitypublic class Sale { @Id private Long id; private String productName; private BigDecimal amount; private LocalDate saleDate; private String customerName; private String region;}DTO SaleReportDto.java
Este DTO contendrá los datos ya transformados, listos para ser escritos.
import lombok.AllArgsConstructor;import lombok.Data;import lombok.NoArgsConstructor;
import java.math.BigDecimal;
@Data@NoArgsConstructor@AllArgsConstructorpublic class SaleReportDto { private String productName; private String customerName; private BigDecimal saleAmount; private String region; private String salesPerson; private BigDecimal commission; // Campo calculado}4. La Configuración Maestra del Batch (BatchConfig.java)
Este es el corazón de nuestra solución. Aquí definimos el $Job$ y sus componentes.
import com.example.batchdemo.model.Sale;import com.example.batchdemo.model.dto.SaleReportDto;import jakarta.persistence.EntityManagerFactory;import org.springframework.batch.core.Job;import org.springframework.batch.core.Step;import org.springframework.batch.core.configuration.annotation.StepScope;import org.springframework.batch.core.job.builder.JobBuilder;import org.springframework.batch.core.repository.JobRepository;import org.springframework.batch.core.step.builder.StepBuilder;import org.springframework.batch.item.ItemProcessor;import org.springframework.batch.item.database.JpaPagingItemReader;import org.springframework.batch.item.database.builder.JpaPagingItemReaderBuilder;import org.springframework.batch.item.file.FlatFileItemWriter;import org.springframework.batch.item.file.builder.FlatFileItemWriterBuilder;import org.springframework.batch.item.file.transform.BeanWrapperFieldExtractor;import org.springframework.batch.item.file.transform.DelimitedLineAggregator;import org.springframework.beans.factory.annotation.Value;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.core.io.FileSystemResource;import org.springframework.transaction.PlatformTransactionManager;
import java.math.BigDecimal;import java.time.LocalDate;import java.time.format.DateTimeFormatter;import java.util.Map;
@Configurationpublic class BatchConfig {
private final JobRepository jobRepository; private final PlatformTransactionManager transactionManager; private final EntityManagerFactory entityManagerFactory;
public BatchConfig(JobRepository jobRepository, PlatformTransactionManager transactionManager, EntityManagerFactory entityManagerFactory) { this.jobRepository = jobRepository; this.transactionManager = transactionManager; this.entityManagerFactory = entityManagerFactory; }
@Bean @StepScope // Esencial para la inyección tardía de parámetros del job public JpaPagingItemReader<Sale> reader(@Value("#{jobParameters['reportDate']}") String reportDateStr) { LocalDate reportDate = LocalDate.parse(reportDateStr, DateTimeFormatter.ISO_LOCAL_DATE);
return new JpaPagingItemReaderBuilder<Sale>() .name("saleReader") .entityManagerFactory(entityManagerFactory) .queryString("SELECT s FROM Sale s WHERE s.saleDate = :reportDate ORDER BY s.id") .parameterValues(Map.of("reportDate", reportDate)) .pageSize(100) // Tamaño del chunk de lectura .build(); }
@Bean public ItemProcessor<Sale, SaleReportDto> processor() { return sale -> { // Lógica de negocio: asignar vendedor y calcular comisión String salesPerson = switch (sale.getRegion()) { case "North" -> "John Doe"; case "South" -> "Jane Smith"; case "East" -> "Mike Johnson"; case "West" -> "Sarah Williams"; default -> "N/A"; };
BigDecimal commissionRate = switch (sale.getRegion()) { case "North", "East" -> new BigDecimal("0.10"); // 10% case "South", "West" -> new BigDecimal("0.12"); // 12% default -> BigDecimal.ZERO; };
BigDecimal commission = sale.getAmount().multiply(commissionRate);
return new SaleReportDto( sale.getProductName(), sale.getCustomerName(), sale.getAmount(), sale.getRegion(), salesPerson, commission ); }; }
@Bean @StepScope public FlatFileItemWriter<SaleReportDto> writer(@Value("#{jobParameters['outputPath']}") String outputPath) { BeanWrapperFieldExtractor<SaleReportDto> fieldExtractor = new BeanWrapperFieldExtractor<>(); fieldExtractor.setNames(new String[]{"productName", "customerName", "saleAmount", "region", "salesPerson", "commission"});
DelimitedLineAggregator<SaleReportDto> lineAggregator = new DelimitedLineAggregator<>(); lineAggregator.setDelimiter(","); lineAggregator.setFieldExtractor(fieldExtractor);
return new FlatFileItemWriterBuilder<SaleReportDto>() .name("saleReportWriter") .resource(new FileSystemResource(outputPath)) .lineAggregator(lineAggregator) .headerCallback(writer -> writer.write("Producto,Cliente,Monto,Region,Vendedor,Comision")) .build(); }
@Bean public Step generateSaleReportStep() { return new StepBuilder("generateSaleReportStep", jobRepository) .<Sale, SaleReportDto>chunk(10, transactionManager) // Tamaño del chunk de procesamiento y escritura .reader(reader(null)) // Spring inyectará el valor real en tiempo de ejecución .processor(processor()) .writer(writer(null)) // Igual aquí .build(); }
@Bean public Job generateSaleReportJob() { return new JobBuilder("generateSaleReportJob", jobRepository) .start(generateSaleReportStep()) .build(); }}Puntos Clave de la Configuración:
-
@StepScope: Esta anotación es crucial. Permite que los beans (ItemReaderyItemWriter) se creen justo cuando el$Step$va a ejecutarse. Esto nos permite inyectar$jobParameters$usando@Value("#{jobParameters['key']}"). Sin@StepScope, los parámetros no estarían disponibles durante la creación del bean. -
JpaPagingItemReaderBuilder: Usamos el constructorBuilderpara una configuración más fluida y legible. El paginado (pageSize) previene cargar toda la tabla en memoria. -
FlatFileItemWriterBuilder: Similarmente, el builder simplifica la creación del writer. Definimos elheaderCallbackpara escribir la cabecera del CSV y ellineAggregatorpara formatear cada línea. -
Paso de Parámetros: El
readery elwriterrecibennullen la definición del$Step$. No te preocupes, esto es intencional. Gracias a@StepScope, Spring Batch gestionará el ciclo de vida y proporcionará las instancias correctas con los parámetros inyectados cuando el$Step$se ejecute.
5. Servicio y Controlador para Lanzar el Job
Exponemos un endpoint REST para poder lanzar nuestro $Job$ de forma programática.
JobLauncherService.java
import org.springframework.batch.core.Job;import org.springframework.batch.core.JobParameters;import org.springframework.batch.core.JobParametersBuilder;import org.springframework.batch.core.JobParametersInvalidException;import org.springframework.batch.core.launch.JobLauncher;import org.springframework.batch.core.repository.JobExecutionAlreadyRunningException;import org.springframework.batch.core.repository.JobInstanceAlreadyCompleteException;import org.springframework.batch.core.repository.JobRestartException;import org.springframework.beans.factory.annotation.Qualifier;import org.springframework.beans.factory.annotation.Value;import org.springframework.stereotype.Service;
import java.io.IOException;import java.nio.file.Files;import java.nio.file.Path;import java.nio.file.Paths;import java.time.LocalDate;import java.time.format.DateTimeFormatter;
@Servicepublic class JobLauncherService {
private final JobLauncher jobLauncher; private final Job generateSaleReportJob;
@Value("${app.reports.base-path}") private String reportsBasePath;
public JobLauncherService(JobLauncher jobLauncher, @Qualifier("generateSaleReportJob") Job generateSaleReportJob) { this.jobLauncher = jobLauncher; this.generateSaleReportJob = generateSaleReportJob; }
public void launchJob(LocalDate reportDate) throws JobInstanceAlreadyCompleteException, JobExecutionAlreadyRunningException, JobParametersInvalidException, JobRestartException, IOException { String formattedDate = reportDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")); String outputPath = reportsBasePath + "reporte-cesarlead-ventas-" + formattedDate + ".csv";
// Crear directorio si no existe (buena práctica) Path reportPath = Paths.get(reportsBasePath); Files.createDirectories(reportPath);
JobParameters jobParameters = new JobParametersBuilder() .addString("reportDate", reportDate.format(DateTimeFormatter.ISO_LOCAL_DATE)) .addString("outputPath", outputPath) .addLong("timestamp", System.currentTimeMillis()) // Parámetro para asegurar unicidad .toJobParameters();
jobLauncher.run(generateSaleReportJob, jobParameters); }}ReportController.java
import org.springframework.format.annotation.DateTimeFormat;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
@RestController@RequestMapping("/api/v1/jobs")public class ReportController {
private final JobLauncherService jobLauncherService;
public ReportController(JobLauncherService jobLauncherService) { this.jobLauncherService = jobLauncherService; }
@PostMapping("/launch/sales-report") public ResponseEntity<String> launchSalesReportJob( @RequestParam("date") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) { try { jobLauncherService.launchJob(date); return ResponseEntity.ok("Job 'generateSaleReportJob' lanzado para la fecha: " + date); } catch (Exception e) { return ResponseEntity.internalServerError().body("Error al lanzar el job: " + e.getMessage()); } }}Puntos Clave del Lanzamiento:
-
JobParameters: Un$Job$se define por su nombre y sus parámetros. Una ejecución con los mismos parámetros no puede volver a correr si ya completó exitosamente. Por eso, agregamos untimestamppara garantizar que cada solicitud sea una ejecución única. -
@Qualifier: Le indicamos a Spring que inyecte específicamente el bean del$Job$con el nombregenerateSaleReportJob. -
Path Dinámico: Construimos la ruta de salida del archivo dinámicamente, asegurando que cada reporte tenga un nombre único basado en la fecha.
6. Datos de Prueba
Para probar nuestro sistema, podemos usar un CommandLineRunner que popule la base de datos con datos de prueba.
DataLoader.java
import com.example.batchdemo.model.Sale;import com.example.batchdemo.repository.SaleRepository;import org.springframework.boot.CommandLineRunner;import org.springframework.stereotype.Component;
import java.math.BigDecimal;import java.time.LocalDate;import java.util.List;
@Componentpublic class DataLoader implements CommandLineRunner {
private final SaleRepository saleRepository;
public DataLoader(SaleRepository saleRepository) { this.saleRepository = saleRepository; }
@Override public void run(String... args) throws Exception { saleRepository.deleteAll();
LocalDate today = LocalDate.now(); LocalDate yesterday = today.minusDays(1);
List<Sale> sales = List.of( new Sale(1L, "Laptop Pro", new BigDecimal("1200.50"), today, "Cliente A", "North"), new Sale(2L, "Smartphone X", new BigDecimal("899.99"), today, "Cliente B", "South"), new Sale(3L, "Monitor 4K", new BigDecimal("450.00"), yesterday, "Cliente C", "East"), new Sale(4L, "Teclado Mecánico", new BigDecimal("150.75"), today, "Cliente D", "West"), new Sale(5L, "Mouse Gamer", new BigDecimal("75.20"), yesterday, "Cliente A", "North") ); saleRepository.saveAll(sales); }}Cómo Probarlo Muchachones
-
Inicia la aplicación Spring Boot.
-
Lanza el job para la fecha actual usando un cliente API (como Postman o
curl):Terminal window # Reemplaza YYYY-MM-DD con la fecha actual, por ejemplo: 2025-09-01POST http://localhost:8080/api/v1/jobs/launch/sales-report?date=YYYY-MM-DD -
Busca en la raíz de tu proyecto la carpeta
reports. Dentro, encontrarás un archivo llamadoreporte-cesarlead-ventas-YYYY-MM-DD.csv. -
El contenido del archivo debería ser algo así:
Producto,Cliente,Monto,Region,Vendedor,ComisionLaptop Pro,Cliente A,1200.50,North,John Doe,120.050Smartphone X,Cliente B,899.99,South,Jane Smith,107.9988Teclado Mecánico,Cliente D,150.75,West,Sarah Williams,18.0900
Más Allá de lo Básico
Muchachones, consideren los siguientes aspectos:
-
Tolerancia a Fallos (
faultTolerant): Puedes configurar un$Step$para reintentar (retry) operaciones en caso de excepciones transitorias (ej. un timeout de red) o para saltarse (skip) registros corruptos que no se pueden procesar..chunk(10, transactionManager).reader(reader(null)).faultTolerant().skip(FlatFileParseException.class) // Ejemplo: saltar si hay un error de parseo en un reader de archivos.skipLimit(10) // Salta como máximo 10 registros erróneos antes de fallar el step.retry(OptimisticLockingFailureException.class).retryLimit(3) // Reintenta hasta 3 veces.build(); -
Listeners (
JobExecutionListener,StepExecutionListener): Permiten ejecutar código antes de que inicie un$Job$o$Step$y después de que termine (ya sea con éxito o con error). Son perfectos para tareas de limpieza, configuración o para enviar notificaciones. -
Escalabilidad (Paralelización): Para volúmenes de datos masivos, Spring Batch soporta la ejecución de steps en paralelo (
split) o la partición de un step (partitioning), donde un conjunto de datos se divide y se procesa en múltiples hilos o incluso en múltiples máquinas (escalado remoto).
Bueno muchachones, la clave para dominar Spring Batch es pensar en términos de resiliencia, transaccionalidad y gestión de recursos. El framework te proporciona los bloques de construcción; tu tarea como dev es ensamblarlos de forma inteligente para crear procesos robustos que puedan ejecutarse de forma desatendida y recuperarse de fallos sin intervención manual.
Spring Batch es una herramienta increíblemente poderosa en el arsenal de cualquier desarrollador Java backend. Al internalizar sus conceptos fundamentales, estarás activo para el procesamiento de datos a gran escala que se te presente!!! Asi que activitos y a practicar!!! Hasta el proximo articulo!!!