Resposta rápida: use Spring Batch com Kotlin quando seu backend precisa processar muitos registros de forma controlada, repetível e recuperável — por exemplo, importar um CSV, gerar cobranças, consolidar vendas ou migrar dados. Modele o fluxo como um Job composto por um ou mais Step. Para grandes volumes, prefira um step orientado a chunks: o ItemReader lê, o ItemProcessor valida ou transforma e o ItemWriter grava um grupo dentro de uma transação. Mantenha o repositório de metadados em banco persistente, passe parâmetros únicos para cada execução e torne a escrita idempotente. Assim, uma falha pode ser reiniciada sem reprocessar tudo nem duplicar efeitos.

Processamento em lote parece apenas um for até o primeiro incidente real. O arquivo chega incompleto, o banco cai no registro 80 mil, uma linha inválida interrompe a importação, o mesmo job roda duas vezes ou alguém precisa descobrir exatamente onde a execução parou. Um loop manual pode até processar dados; ele não entrega sozinho histórico, transações por bloco, restart, skip, retry, métricas e controle de concorrência.

O Spring Batch é a solução madura do ecossistema Spring para esse tipo de trabalho. Com Kotlin, você mantém a infraestrutura do framework e ganha modelos concisos, null safety e funções pequenas para transformação. Este guia mostra a arquitetura, um exemplo de importação CSV, configuração moderna com Spring Boot, tratamento de falhas, restart, testes e decisões para produção.

Consulte também a documentação oficial do Spring Batch antes de fixar versões e assinaturas: os exemplos abaixo seguem a API moderna da linha Spring Batch 5, mas seu projeto deve alinhar Spring Boot, Kotlin, Java e drivers pelo BOM usado pela aplicação.

Quando Spring Batch vale a pena?

Spring Batch é uma boa escolha quando o trabalho tem começo e fim definidos e precisa processar um conjunto de dados. Casos comuns incluem:

  • importar arquivos CSV, XML ou layouts de parceiros;
  • calcular fechamento diário, comissões ou consolidações;
  • migrar milhões de registros entre schemas;
  • gerar relatórios e arquivos de remessa;
  • reconciliar dados entre sistemas;
  • expirar cadastros ou atualizar classificações em massa;
  • ler registros de uma base e publicar resultados em outra;
  • executar rotinas operacionais com auditoria e possibilidade de restart.

Para uma API HTTP tradicional, comece pelo tutorial de Kotlin com Spring Boot. Para uma tarefa curta e independente, um método agendado pode bastar. Spring Batch entra quando volume, estado e recuperação deixam de ser detalhes.

Spring Batch não é um scheduler

O framework executa e acompanha jobs, mas não decide necessariamente quando eles começam. O disparo pode vir de:

  • @Scheduled em uma aplicação simples;
  • Kubernetes CronJob;
  • um orquestrador corporativo;
  • uma mensagem recebida;
  • uma chamada administrativa;
  • uma ferramenta como Airflow;
  • um processo manual com parâmetros controlados.

Separar execução de agendamento facilita mudar a operação sem reescrever a lógica do lote.

Modelo mental: Job, Step e JobRepository

Os conceitos centrais são pequenos:

ComponentePapel
Jobrepresenta o processo completo, como “importar clientes”
Steprepresenta uma etapa do job
JobLauncherinicia uma execução com parâmetros
JobRepositorypersiste metadados, status e checkpoints
JobParametersidentifica e parametriza uma instância do job
ItemReaderlê um item por vez
ItemProcessorvalida ou transforma um item
ItemWritergrava um chunk de itens

Um Job pode ter um step só ou um fluxo com várias etapas. Uma importação, por exemplo, pode:

  1. validar se o arquivo existe;
  2. importar registros em chunks;
  3. gerar um resumo;
  4. mover o arquivo para uma área de concluídos.

O JobRepository é parte essencial do desenho. Ele registra quais jobs existem, quais parâmetros foram usados, quando cada step começou, quantos itens foram lidos e escritos e se a execução terminou. Em produção, use um banco persistente compatível com sua stack. Um repositório apenas em memória perde justamente a informação necessária para investigar e reiniciar falhas.

Tasklet ou processamento em chunks?

Spring Batch oferece dois estilos principais.

Tasklet

Um tasklet executa uma ação por step. É adequado para operações indivisíveis ou administrativas:

  • apagar arquivos temporários;
  • chamar uma stored procedure;
  • verificar pré-condições;
  • mover um arquivo;
  • emitir um resumo final.
@Bean
fun limparTemporariosStep(
    jobRepository: JobRepository,
    transactionManager: PlatformTransactionManager,
): Step = StepBuilder("limparTemporariosStep", jobRepository)
    .tasklet({ _, _ ->
        Files.deleteIfExists(Path.of("/dados/entrada/clientes.tmp"))
        RepeatStatus.FINISHED
    }, transactionManager)
    .build()

Chunk

O modelo de chunks é indicado quando há muitos itens semelhantes. O framework repete este ciclo:

  1. lê até atingir o tamanho configurado;
  2. processa cada item;
  3. entrega a lista ao writer;
  4. confirma a transação;
  5. salva o checkpoint;
  6. começa o próximo chunk.

Se o chunk é 500, uma importação com 100 mil linhas não precisa ficar inteira em memória nem dentro de uma única transação. O tamanho não deve ser escolhido por superstição: meça custo de leitura, escrita, memória, lock e tempo de rollback.

Criando o projeto Kotlin

No Spring Initializr, escolha Kotlin e Gradle Kotlin DSL. Para o exemplo, use Spring Batch, JDBC, Validation e o driver do banco. As versões ficam sob o gerenciamento do Spring Boot:

plugins {
    kotlin("jvm") version "<versao-compativel>"
    kotlin("plugin.spring") version "<versao-compativel>"
    id("org.springframework.boot") version "<versao-estavel>"
    id("io.spring.dependency-management") version "<versao-compativel>"
}

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-batch")
    implementation("org.springframework.boot:spring-boot-starter-jdbc")
    implementation("org.springframework.boot:spring-boot-starter-validation")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")

    runtimeOnly("org.postgresql:postgresql")

    testImplementation("org.springframework.boot:spring-boot-starter-test")
    testImplementation("org.springframework.batch:spring-batch-test")
}

Não copie números de versão de um artigo antigo para produção. Deixe o BOM do Spring Boot coordenar os módulos Spring e valide a matriz oficial de compatibilidade. Para entender melhor esse papel, veja o guia de Gradle Version Catalog com Kotlin.

No ambiente local, configure o datasource e permita que o Boot inicialize as tabelas de metadados. Em produção, prefira aplicar o schema por migration controlada:

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/lotes
    username: app_batch
    password: ${DB_PASSWORD}
  batch:
    jdbc:
      initialize-schema: always
    job:
      enabled: false

enabled: false evita iniciar automaticamente todos os jobs descobertos ao subir a aplicação. Isso costuma ser mais seguro quando o disparo depende de arquivo, parâmetros ou orquestração. Para produção, troque initialize-schema: always por uma migration com Flyway ou Liquibase e uma política adequada ao ambiente.

Exemplo: importar clientes de um CSV

Imagine um arquivo com este formato:

externalId,nome,email
cli-001,Ana Souza,[email protected]
cli-002,Caio Lima,[email protected]

Crie um modelo de entrada e um comando de domínio:

data class ClienteCsv(
    var externalId: String = "",
    var nome: String = "",
    var email: String = "",
)

data class ClienteParaSalvar(
    val externalId: String,
    val nome: String,
    val email: String,
)

O bean mutável de entrada facilita o mapeamento do leitor de arquivo. Depois do processor, o restante do sistema pode usar objetos imutáveis.

ItemReader: lendo o arquivo sem carregá-lo inteiro

Use FlatFileItemReader para ler uma linha por vez. O caminho vem de JobParameters, então o bean precisa de @StepScope: seu valor só será resolvido durante a execução do step.

@Bean
@StepScope
fun clienteCsvReader(
    @Value("#{jobParameters['arquivo']}") arquivo: String,
): FlatFileItemReader<ClienteCsv> = FlatFileItemReaderBuilder<ClienteCsv>()
    .name("clienteCsvReader")
    .resource(FileSystemResource(arquivo))
    .linesToSkip(1)
    .delimited()
    .delimiter(",")
    .names("externalId", "nome", "email")
    .fieldSetMapper { fields ->
        ClienteCsv(
            externalId = fields.readString("externalId"),
            nome = fields.readString("nome"),
            email = fields.readString("email"),
        )
    }
    .build()

Em arquivos reais, confirme charset, delimitador, aspas, cabeçalho, colunas opcionais e política para linhas vazias. CSV não é tão simples quanto split(","): campos podem conter vírgulas escapadas e quebras de linha. Use componentes de parsing preparados para o formato.

Também não aceite um caminho arbitrário vindo de uma requisição pública. O launcher deve validar diretório permitido, extensão, tamanho e origem do arquivo antes de iniciar o job.

ItemProcessor: validando e normalizando

O processor transforma uma linha em um objeto pronto para persistência:

@Component
class ClienteProcessor : ItemProcessor<ClienteCsv, ClienteParaSalvar> {

    override fun process(item: ClienteCsv): ClienteParaSalvar {
        val externalId = item.externalId.trim()
        val nome = item.nome.trim()
        val email = item.email.trim().lowercase()

        require(externalId.isNotBlank()) {
            "externalId obrigatório"
        }
        require(nome.length >= 2) {
            "nome inválido para $externalId"
        }
        require(EMAIL_REGEX.matches(email)) {
            "e-mail inválido para $externalId"
        }

        return ClienteParaSalvar(
            externalId = externalId,
            nome = nome,
            email = email,
        )
    }

    private companion object {
        val EMAIL_REGEX = Regex("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$")
    }
}

Use validação compatível com o domínio; a regex acima é propositalmente simples e não pretende implementar toda a especificação de e-mail. Evite chamadas remotas por item dentro do processor. Uma API lenta transforma o job em milhares de requisições frágeis. Quando a consulta externa for inevitável, pense em cache, rate limit, retry específico e desenho por partição.

Um processor também pode retornar null para filtrar um item sem enviá-lo ao writer. Faça isso apenas quando “ignorar” for uma regra explícita e mensurável; não esconda registros inválidos silenciosamente.

ItemWriter: escrevendo de forma idempotente

Para banco relacional, JdbcBatchItemWriter executa escrita em lote:

@Bean
fun clienteWriter(
    dataSource: DataSource,
): JdbcBatchItemWriter<ClienteParaSalvar> =
    JdbcBatchItemWriterBuilder<ClienteParaSalvar>()
        .dataSource(dataSource)
        .sql(
            """
            INSERT INTO cliente (external_id, nome, email, atualizado_em)
            VALUES (:externalId, :nome, :email, CURRENT_TIMESTAMP)
            ON CONFLICT (external_id)
            DO UPDATE SET
                nome = EXCLUDED.nome,
                email = EXCLUDED.email,
                atualizado_em = CURRENT_TIMESTAMP
            """.trimIndent()
        )
        .beanMapped()
        .build()

O external_id precisa de uma restrição única. O upsert torna o efeito repetível para o mesmo identificador: se houver retry, restart ou reenvio controlado, o sistema atualiza em vez de criar uma duplicata.

Idempotência depende do efeito de negócio. Para cobrança, “fazer upsert” talvez não resolva; você pode precisar de chave de idempotência, ledger e transação com outbox. Não confunda restart técnico com permissão para repetir um efeito financeiro ou uma notificação.

Se o acesso a dados do projeto usa JPA, um writer customizado é possível, mas observe memória do persistence context e custo de flush. Para importações intensivas, JDBC em batch costuma ser mais previsível. Se você prefere uma API Kotlin-first em outros serviços, compare com Exposed ORM da JetBrains.

Montando Step e Job

Agora conecte reader, processor e writer:

@Configuration
class ImportacaoClientesJobConfig {

    @Bean
    fun importarClientesStep(
        jobRepository: JobRepository,
        transactionManager: PlatformTransactionManager,
        clienteCsvReader: FlatFileItemReader<ClienteCsv>,
        clienteProcessor: ClienteProcessor,
        clienteWriter: JdbcBatchItemWriter<ClienteParaSalvar>,
    ): Step = StepBuilder("importarClientesStep", jobRepository)
        .chunk<ClienteCsv, ClienteParaSalvar>(500, transactionManager)
        .reader(clienteCsvReader)
        .processor(clienteProcessor)
        .writer(clienteWriter)
        .build()

    @Bean
    fun importarClientesJob(
        jobRepository: JobRepository,
        importarClientesStep: Step,
    ): Job = JobBuilder("importarClientesJob", jobRepository)
        .start(importarClientesStep)
        .build()
}

O nome do job e o conjunto de parâmetros identificadores formam uma instância. Se você iniciar novamente um job já concluído com exatamente os mesmos parâmetros, o framework pode rejeitar a execução como já completa. Isso é proteção, não defeito.

Iniciando o job com parâmetros

Um launcher administrativo pode validar a entrada e criar parâmetros:

@Component
class ImportacaoClientesLauncher(
    private val jobLauncher: JobLauncher,
    @Qualifier("importarClientesJob")
    private val job: Job,
) {
    fun executar(arquivo: Path, referencia: LocalDate): JobExecution {
        require(Files.isRegularFile(arquivo)) {
            "Arquivo não encontrado: $arquivo"
        }

        val parameters = JobParametersBuilder()
            .addString("arquivo", arquivo.toAbsolutePath().toString())
            .addLocalDate("referencia", referencia)
            .addString("checksum", sha256(arquivo))
            .toJobParameters()

        return jobLauncher.run(job, parameters)
    }
}

Um timestamp aleatório torna toda chamada “nova”, mas também pode esconder reenvios acidentais. Para importação de arquivo, uma combinação como referência, nome normalizado e checksum costuma representar melhor a identidade do trabalho.

Antes de lançar, mova o arquivo para uma área de processamento ou use armazenamento que impeça alteração durante a leitura. Se o conteúdo muda depois do checksum, a rastreabilidade deixa de ser confiável.

Restart: como continuar depois de uma falha

No processamento em chunks, cada commit atualiza o contexto da execução. Se o job falha depois de gravar vários blocos, um restart com a mesma instância pode retomar do checkpoint em vez de começar do zero — desde que reader, writer e parâmetros suportem esse comportamento.

Para um restart seguro:

  • mantenha as tabelas de metadados do Spring Batch;
  • não apague execuções para “resolver” falhas;
  • preserve o arquivo original sem alterações;
  • use nomes estáveis para jobs, steps e streams;
  • torne a escrita idempotente;
  • não guarde objetos grandes no ExecutionContext;
  • teste a interrupção no meio do arquivo;
  • documente quando abandonar uma execução e criar uma nova instância.

O restart não significa que qualquer step será repetido automaticamente sem consequências. Um tasklet que envia um e-mail e falha antes de salvar o status pode enviar de novo. Efeitos externos precisam de deduplicação própria.

Skip, retry e no-rollback

Nem toda falha deve ter a mesma resposta.

  • Skip: ignora um item conhecido como inválido e continua.
  • Retry: tenta novamente uma operação temporariamente indisponível.
  • Fail: interrompe quando continuar pode corromper ou mascarar dados.

Uma configuração de tolerância pode ser:

@Bean
fun importarClientesStep(
    jobRepository: JobRepository,
    transactionManager: PlatformTransactionManager,
    clienteCsvReader: FlatFileItemReader<ClienteCsv>,
    clienteProcessor: ClienteProcessor,
    clienteWriter: JdbcBatchItemWriter<ClienteParaSalvar>,
    skipListener: ClienteSkipListener,
): Step = StepBuilder("importarClientesStep", jobRepository)
    .chunk<ClienteCsv, ClienteParaSalvar>(500, transactionManager)
    .reader(clienteCsvReader)
    .processor(clienteProcessor)
    .writer(clienteWriter)
    .faultTolerant()
    .skip(IllegalArgumentException::class.java)
    .skipLimit(100)
    .retry(TransientDataAccessException::class.java)
    .retryLimit(3)
    .listener(skipListener)
    .build()

Não use skip(Exception::class.java) para fazer o painel ficar verde. Defina exceções específicas e um limite. Cem linhas inválidas em um arquivo de 20 milhões podem ser aceitáveis; cem em um arquivo de 120 provavelmente indicam contrato quebrado.

Registre itens rejeitados em uma tabela ou arquivo de quarentena com número da linha, motivo e identificador seguro. Evite copiar dados pessoais completos para logs. O operador precisa corrigir o problema sem ampliar exposição de informação.

Listeners e observabilidade

Listeners permitem observar o ciclo sem misturar telemetria com regra de negócio:

@Component
class JobMetricsListener(
    private val meterRegistry: MeterRegistry,
) : JobExecutionListener {

    override fun afterJob(jobExecution: JobExecution) {
        meterRegistry.counter(
            "batch.job.completed",
            "job", jobExecution.jobInstance.jobName,
            "status", jobExecution.status.name,
        ).increment()
    }
}

Acompanhe pelo menos:

  • duração do job e de cada step;
  • status final;
  • quantidade lida, filtrada, escrita e ignorada;
  • retries e rollbacks;
  • horário do último sucesso;
  • atraso entre a chegada esperada e a conclusão;
  • identificação da instância sem dados sensíveis.

Métricas de sucesso não substituem alerta de ausência. Um job diário que não iniciou não gera erro por conta própria. Crie um alerta para “último sucesso mais antigo que o limite”. Para instrumentação mais ampla, veja OpenTelemetry com Kotlin, Spring e Ktor e o guia de observabilidade em Kotlin.

Transações, chunk size e performance

O chunk define a fronteira de commit. Valores maiores reduzem commits, mas aumentam:

  • tempo de lock;
  • memória;
  • custo do rollback;
  • quantidade reprocessada após falha;
  • pressão sobre banco e pool.

Valores pequenos aumentam overhead de transação e checkpoint. Comece com uma hipótese moderada, como 100 a 1.000 itens, e teste com volume e infraestrutura semelhantes aos de produção. O resultado depende do peso de cada registro, índices, latência, driver, SQL e concorrência.

Outras práticas importantes:

  • use batch real no driver e no writer;
  • selecione apenas colunas necessárias;
  • evite N+1 no processor;
  • garanta índices para chaves de busca e upsert;
  • limite a concorrência ao que o banco suporta;
  • faça paginação estável por chave, não por offset mutável, ao ler tabelas grandes;
  • meça GC, conexões, tempo de commit e throughput;
  • execute um teste de restart, não apenas um benchmark feliz.

Particionamento e steps paralelos podem acelerar o job, mas adicionam concorrência, ordenação e recuperação mais difíceis. Primeiro remova gargalos de SQL e I/O. Depois divida por uma chave que gere partições independentes, como faixa de IDs ou competência.

Testando um job Spring Batch com Kotlin

Adicione spring-batch-test e teste o fluxo com banco isolado. O projeto oferece utilitários para lançar jobs e steps.

@SpringBatchTest
@SpringBootTest
@ActiveProfiles("test")
class ImportacaoClientesJobTest(
    @Autowired private val jobLauncherTestUtils: JobLauncherTestUtils,
    @Autowired private val jdbcTemplate: JdbcTemplate,
) {

    @Test
    fun `importa clientes válidos`() {
        val arquivo = recursoTemporario(
            """
            externalId,nome,email
            cli-001,Ana Souza,[email protected]
            cli-002,Caio Lima,[email protected]
            """.trimIndent()
        )

        val parameters = JobParametersBuilder()
            .addString("arquivo", arquivo.toString())
            .addLocalDate("referencia", LocalDate.of(2026, 8, 30))
            .addString("checksum", sha256(arquivo))
            .toJobParameters()

        val execution = jobLauncherTestUtils.launchJob(parameters)

        assertEquals(BatchStatus.COMPLETED, execution.status)
        assertEquals(
            2,
            jdbcTemplate.queryForObject(
                "SELECT COUNT(*) FROM cliente",
                Int::class.java,
            )
        )
    }
}

Inclua casos para:

  • cabeçalho ausente;
  • linha inválida dentro do limite de skip;
  • limite de skip excedido;
  • falha no writer;
  • retry de erro transitório;
  • arquivo vazio;
  • execução repetida com os mesmos parâmetros;
  • restart após alguns chunks;
  • idempotência do upsert;
  • concorrência entre duas tentativas do mesmo lote.

Não mocke toda a infraestrutura e conclua que o job funciona. O valor do teste está em exercitar reader, transação, schema, writer e metadados juntos. Para PostgreSQL, use um banco efêmero com Testcontainers em Kotlin sempre que tipos e comportamento SQL importarem.

Deploy: aplicação contínua ou job efêmero?

Existem dois modelos comuns.

Aplicação sempre ativa

A aplicação recebe agendamento ou comando e usa JobLauncher. É conveniente quando há painel administrativo, vários jobs e infraestrutura Spring já em execução.

Processo efêmero

Um container sobe, executa um job e termina. Kubernetes CronJob e plataformas de jobs funcionam bem assim. O processo deve retornar código de saída coerente, gravar metadados em banco persistente e não iniciar um servidor web desnecessário.

O modelo efêmero reduz recursos ociosos e torna cada execução isolada. Em contrapartida, logs, segredos, parâmetros e arquivos precisam estar disponíveis no momento correto. Para empacotamento, leia o guia de Docker para Kotlin.

Erros comuns

Usar um for gigante em uma única transação

Uma falha perto do fim causa rollback enorme e torna o restart difícil. Prefira chunks com checkpoints.

Colocar timestamp aleatório em todo job sem pensar

Isso permite repetir uma importação por acidente. Modele parâmetros de negócio e checksum quando fizer sentido.

Executar automaticamente ao subir qualquer instância

Em um deployment com várias réplicas, mais de um pod pode tentar iniciar o mesmo trabalho. Desative inicialização automática ou use coordenação explícita.

Usar banco em memória para metadados em produção

Ao reiniciar a aplicação, você perde histórico e checkpoint. O repositório deve sobreviver ao processo.

Aplicar skip genérico

Ignorar qualquer exceção mascara indisponibilidade do banco, bugs e quebra de contrato. Faça allowlist de erros descartáveis e limite a quantidade.

Chamar APIs externas para cada item

O job fica lento e vulnerável a rate limit. Agrupe chamadas quando possível, use cache e separe erros transitórios de dados inválidos.

Reprocessar efeitos não idempotentes

E-mails, pagamentos e eventos podem duplicar após retry. Use chaves de idempotência, outbox ou registro de envio.

Alterar o arquivo durante a execução

Checkpoint deixa de representar o mesmo conteúdo. Imutabilidade e checksum são parte do contrato operacional.

Não testar restart

Um job que conclui no caminho feliz não prova que consegue se recuperar. Interrompa deliberadamente e valide a retomada.

Checklist para produção

  • job e steps têm nomes estáveis e descritivos;
  • parâmetros representam a identidade real da execução;
  • metadados usam banco persistente e migration versionada;
  • arquivo ou fonte permanece imutável durante o processamento;
  • writer é idempotente ou protege efeitos por chave própria;
  • chunk size foi medido com volume realista;
  • skip aceita somente erros específicos e tem limite;
  • retry aceita somente falhas transitórias e tem backoff adequado;
  • itens rejeitados vão para quarentena segura;
  • logs não expõem dados pessoais nem segredos;
  • métricas incluem duração, contagens, falhas e último sucesso;
  • existe alerta para job atrasado ou ausente;
  • concorrência entre instâncias foi controlada;
  • shutdown permite terminar ou interromper o chunk com segurança;
  • testes cobrem repetição, falha parcial e restart;
  • runbook explica como investigar, reiniciar e abandonar uma execução.

Perguntas frequentes

Spring Batch funciona bem com Kotlin?

Sim. Jobs, steps, readers, processors, writers e listeners são APIs JVM e funcionam normalmente com Kotlin. Use o plugin Spring para classes que precisam de proxy e cuide da interoperabilidade em builders e modelos mutáveis de leitura.

Qual a diferença entre @Scheduled e Spring Batch?

@Scheduled decide quando chamar um método. Spring Batch modela execução, steps, chunks, metadados, restart, skip e retry. Eles podem trabalhar juntos, mas resolvem problemas diferentes.

Preciso de banco para Spring Batch?

Para produção com histórico e restart confiáveis, use um banco persistente para o JobRepository. Mesmo que os dados de negócio venham de arquivo, os metadados precisam sobreviver ao processo.

Qual chunk size devo usar?

Não existe número universal. Comece com uma faixa moderada e meça tempo de commit, memória, lock, throughput e custo de rollback. Um item pesado pode exigir chunks menores que um registro simples.

Posso processar em paralelo?

Sim, com steps paralelos, particionamento e outras estratégias. Só aumente concorrência depois de garantir que reader, processor, writer, banco e efeito de negócio são thread-safe e independentes por partição.

Spring Batch substitui Kafka?

Não. Batch processa conjuntos finitos com execução controlada; Kafka atende fluxos contínuos de eventos. Uma arquitetura pode usar ambos: eventos entram continuamente e um job periódico consolida ou reconcilia resultados.

Como evitar duplicidade ao reiniciar?

Use parâmetros estáveis, metadados persistentes, checkpoint e escrita idempotente. Para efeitos externos, adote chave de idempotência ou outbox; o framework não consegue deduplicar automaticamente uma cobrança ou mensagem fora da transação.

Conclusão e próximos passos

Spring Batch com Kotlin transforma uma rotina de lote em um processo operável. A diferença não está apenas em ler um arquivo: está em saber qual instância rodou, onde parou, quantos itens foram confirmados, quais falharam e como retomar sem duplicar efeitos.

Comece com um job pequeno e um step orientado a chunks. Passe o arquivo por parâmetro, use banco persistente para metadados, grave por uma chave idempotente e teste uma falha no meio. Depois acrescente quarentena, métricas, alertas e particionamento somente quando o volume justificar.

Para aprofundar a stack, continue por Kotlin com Spring Boot, Testcontainers com PostgreSQL, OpenTelemetry em Kotlin e Docker para aplicações Kotlin. Um projeto de portfólio que demonstra restart, idempotência e observabilidade comunica muito mais maturidade backend do que uma importação que funciona apenas uma vez no notebook do desenvolvedor.