# Conociendo Spring Batch: Guía Completa para Procesos por Lotes!!!

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:

  1. Lee las ventas de una base de datos para una fecha específica, proporcionada como parámetro.

  2. Procesa cada venta para calcular una comisión basada en la región y la transforma en un DTO optimizado para el reporte.

  3. 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 devolviendo null.

  • 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ón
app:
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
@Entity
public 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
@AllArgsConstructor
public 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;
@Configuration
public 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 (ItemReader y ItemWriter) 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 constructor Builder para 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 el headerCallback para escribir la cabecera del CSV y el lineAggregator para formatear cada línea.

  • Paso de Parámetros: El reader y el writer reciben null en 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;
@Service
public 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 un timestamp para garantizar que cada solicitud sea una ejecución única.

  • @Qualifier: Le indicamos a Spring que inyecte específicamente el bean del $Job$ con el nombre generateSaleReportJob.

  • 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;
@Component
public 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

  1. Inicia la aplicación Spring Boot.

  2. 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-01
    POST http://localhost:8080/api/v1/jobs/launch/sales-report?date=YYYY-MM-DD
  3. Busca en la raíz de tu proyecto la carpeta reports. Dentro, encontrarás un archivo llamado reporte-cesarlead-ventas-YYYY-MM-DD.csv.

  4. El contenido del archivo debería ser algo así:

    Producto,Cliente,Monto,Region,Vendedor,Comision
    Laptop Pro,Cliente A,1200.50,North,John Doe,120.050
    Smartphone X,Cliente B,899.99,South,Jane Smith,107.9988
    Teclado 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!!!

Foto Cesar Fernandez

¿Lo rompiste? ¿Lo mejoraste?

Gracias por llegar hasta el final. Escribo estos posts para organizar mis propias ideas y, con suerte, para ahorrarle a alguien más el dolor de cabeza que yo ya pasé. Me encuentras en LinkedIn o puedes ver más de mi trabajo en GitHub.


Más Artículos