- High-Level Architecture The migrated system adopts a layered, Spring Boot-based architecture that preserves the batch semantics of the original COBOL program while introducing modern patterns: dependency injection, declarative transaction management, and structured observability.
Architecture Layers
[ Flat File Input ] ──> [ FlatFileItemReader ] ──> [ AccountItemProcessor ] | [ InterestCalculationService ] [ BalanceValidationService ] | [ Flat File Output ] <── [ FlatFileItemWriter ] <── [ AccountOutputRecord ] | [ RunControlCounters / Metrics ]
Technology stack: • Java 17 (LTS) • Spring Batch 5 for chunk-oriented processing • Spring Boot 3 for auto-configuration and embedded job launcher • Micrometer + Prometheus for operational metrics (replaces DISPLAY counters) • Testcontainers for integration tests
- Core Components 3.1 AccountRecord (maps COBOL ACCT-IN-REC) // src/main/java/com/example/acctproc/model/AccountRecord.java public record AccountRecord( long customerId, // AI-CUST-ID PIC 9(10) String lastName, // AI-LAST-NAME PIC X(20) String firstName, // AI-FIRST-NAME PIC X(15) BigDecimal balance, // AI-BALANCE PIC S9(11)V99 COMP-3 AccountType accountType, // AI-ACCT-TYPE 88 TYPE-SAVINGS / TYPE-CHECKING AccountStatus status // AI-STATUS 88 STATUS-ACTIVE / STATUS-CLOSED ) {}
public enum AccountType { SAVINGS, CHECKING } public enum AccountStatus { ACTIVE, CLOSED }
3.2 AccountOutputRecord (maps COBOL ACCT-OUT-REC) public record AccountOutputRecord( long customerId, // AO-CUST-ID BigDecimal newBalance, // AO-NEW-BALANCE BigDecimal interestAmt, // AO-INTEREST ReturnCode returnCode // AO-RETURN-CODE ) {}
public enum ReturnCode { OK, LB, SK } // OK=ok, LB=low-balance, SK=skipped
3.3 RunControlCounters (maps COBOL WS-COUNTERS) @Component public class RunControlCounters { private final AtomicLong totalRead = new AtomicLong(); // WS-TOTAL-READ private final AtomicLong totalProcessed = new AtomicLong(); // WS-TOTAL-PROC private final AtomicLong totalSkipped = new AtomicLong(); // WS-TOTAL-ERR
public void incrementRead() { totalRead.incrementAndGet(); }
public void incrementProcessed() { totalProcessed.incrementAndGet(); }
public void incrementSkipped() { totalSkipped.incrementAndGet(); }
public void printSummary() {
log.info("RECORDS READ: {}", totalRead.get());
log.info("RECORDS PROCESSED: {}", totalProcessed.get());
log.info("RECORDS SKIPPED: {}", totalSkipped.get());
}
}
- Interfaces
4.1 InterestCalculationService
Encapsulates COBOL paragraph 2100-CALC-INTEREST.
public interface InterestCalculationService {
/**
- COBOL: COMPUTE WS-INTEREST-AMT = AI-BALANCE * WS-INTEREST-RATE
- Rate applied only to SAVINGS accounts; CHECKING returns ZERO. */ BigDecimal calculate(AccountRecord account); }
4.2 BalanceValidationService Encapsulates COBOL paragraph 2200-VALIDATE-BALANCE. public interface BalanceValidationService { /** * COBOL: IF AI-BALANCE < WS-MIN-BALANCE -> 'LB' ELSE -> 'OK' */ ReturnCode validate(AccountRecord account); }
4.3 AccountItemProcessor (Spring Batch ItemProcessor) Replaces COBOL paragraph 2000-PROCESS-ACCOUNTS. public interface AccountItemProcessor extends ItemProcessor<AccountRecord, AccountOutputRecord> { @Override AccountOutputRecord process(AccountRecord item) throws Exception; }
-
API Signatures 5.1 InterestCalculationServiceImpl @Service public class InterestCalculationServiceImpl implements InterestCalculationService {
private static final BigDecimal SAVINGS_RATE = new BigDecimal("0.0325"); // WS-INTEREST-RATE PIC V9(4) VALUE .0325
@Override public BigDecimal calculate(AccountRecord account) { if (account.accountType() == AccountType.SAVINGS) { return account.balance() .multiply(SAVINGS_RATE) .setScale(2, RoundingMode.HALF_EVEN); } return BigDecimal.ZERO; } }
5.2 BalanceValidationServiceImpl @Service public class BalanceValidationServiceImpl implements BalanceValidationService {
private static final BigDecimal MIN_BALANCE =
new BigDecimal("100.00"); // WS-MIN-BALANCE PIC 9(9)V99 VALUE 100.00
@Override
public ReturnCode validate(AccountRecord account) {
return account.balance().compareTo(MIN_BALANCE) < 0
? ReturnCode.LB // 'LB' low-balance
: ReturnCode.OK; // 'OK' passed
}
}
5.3 AccountItemProcessorImpl @Component public class AccountItemProcessorImpl implements AccountItemProcessor {
private final InterestCalculationService interestSvc;
private final BalanceValidationService validationSvc;
private final RunControlCounters counters;
@Override
public AccountOutputRecord process(AccountRecord item) {
counters.incrementRead();
if (item.status() != AccountStatus.ACTIVE) {
counters.incrementSkipped();
return new AccountOutputRecord(
item.customerId(), item.balance(),
BigDecimal.ZERO, ReturnCode.SK);
}
BigDecimal interest = interestSvc.calculate(item);
ReturnCode returnCode = validationSvc.validate(item);
BigDecimal newBalance = item.balance().add(interest);
counters.incrementProcessed();
return new AccountOutputRecord(
item.customerId(), newBalance, interest, returnCode);
}
}
5.4 Batch Job Configuration @Configuration public class AccountProcessingJobConfig {
@Bean
public FlatFileItemReader<AccountRecord> accountReader(
@Value("${job.input.file}") Resource resource) { ... }
@Bean
public FlatFileItemWriter<AccountOutputRecord> accountWriter(
@Value("${job.output.file}") Resource resource) { ... }
@Bean
public Step processAccountsStep(...) {
return stepBuilderFactory
.get("processAccountsStep")
.<AccountRecord, AccountOutputRecord>chunk(500)
.reader(accountReader(null))
.processor(processor)
.writer(accountWriter(null))
.faultTolerant()
.skip(FlatFileParseException.class).skipLimit(100)
.build();
}
@Bean
public Job accountProcessingJob(Step processAccountsStep) {
return jobBuilderFactory
.get("accountProcessingJob")
.start(processAccountsStep)
.listener(runControlListener)
.build();
}
}
- Data Models The table below maps every significant COBOL data item to its Java equivalent, preserving semantic precision (especially fixed-decimal arithmetic).
COBOL Item COBOL Picture Java Type Notes AI-CUST-ID PIC 9(10) long 10-digit unsigned integer AI-LAST-NAME PIC X(20) String Trimmed on read AI-FIRST-NAME PIC X(15) String Trimmed on read AI-BALANCE PIC S9(11)V99 COMP-3 BigDecimal(2) HALF_EVEN rounding; COMP-3 packed decimal AI-ACCT-TYPE 88 SA/CH AccountType enum SAVINGS | CHECKING AI-STATUS 88 A/C AccountStatus enum ACTIVE | CLOSED WS-INTEREST-RATE PIC V9(4) VALUE .0325 BigDecimal("0.0325") Constant; injected via @Value WS-MIN-BALANCE PIC 9(9)V99 VALUE 100.00 BigDecimal("100.00") Configurable via application.yml WS-TOTAL-READ PIC 9(7) AtomicLong Thread-safe counter WS-TOTAL-PROC PIC 9(7) AtomicLong Thread-safe counter WS-TOTAL-ERR PIC 9(7) AtomicLong Thread-safe counter AO-RETURN-CODE PIC XX OK/LB/SK ReturnCode enum OK | LB (low-balance) | SK (skipped)
- Testing Strategy The testing pyramid mirrors the COBOL paragraph structure: each paragraph becomes a unit, each division becomes an integration surface.
Layer Framework Scope COBOL Equivalent Unit JUnit 5 + Mockito Service classes in isolation Paragraph-level logic Slice Spring Batch Test Single Step with mock I/O Division-level flow Integration Testcontainers + embedded job Full job on real files Full PERFORM … UNTIL flow Contract Pact Input/output file schemas FD record layouts
7.1 Unit Test Example — InterestCalculationService @Test void savingsAccount_appliesRate() { var acct = new AccountRecord(1L, "Smith", "John", new BigDecimal("10000.00"), AccountType.SAVINGS, AccountStatus.ACTIVE); BigDecimal interest = svc.calculate(acct); // COBOL: COMPUTE WS-INTEREST-AMT = 10000.00 * .0325 = 325.00 assertThat(interest).isEqualByComparingTo("325.00"); }
@Test void checkingAccount_returnsZero() { var acct = new AccountRecord(2L, "Doe", "Jane", new BigDecimal("5000.00"), AccountType.CHECKING, AccountStatus.ACTIVE); assertThat(svc.calculate(acct)).isEqualByComparingTo(BigDecimal.ZERO); }
7.2 Unit Test Example — BalanceValidationService @ParameterizedTest @CsvSource({ "99.99,LB", "100.00,OK", "100.01,OK" }) void balanceThreshold(String balanceStr, ReturnCode expected) { var acct = buildAccount(new BigDecimal(balanceStr)); assertThat(svc.validate(acct)).isEqualTo(expected); }
- Error Recovery Strategy The COBOL program has no explicit error recovery — a bad record causes ABEND. The Java migration replaces this with Spring Batch's built-in fault-tolerance model.
Failure Type COBOL Behaviour Java Recovery Parse error ABEND / job terminates Skip record up to skipLimit=100; write to dead-letter file Numeric overflow ABEND or silent truncation ArithmeticException caught; record skipped, counter incremented I/O error on input ABEND Non-skippable; job fails fast, restart from checkpoint I/O error on output ABEND Non-skippable; job fails fast, restart from last committed chunk Closed account WS-TOTAL-ERR += 1 ReturnCode.SK; record written to output with SK code
8.1 Skip Listener @Component public class AccountSkipListener implements SkipListener<AccountRecord, AccountOutputRecord> {
@Override
public void onSkipInProcess(AccountRecord item, Throwable t) {
log.error("SKIP cust={} reason={}", item.customerId(), t.getMessage());
counters.incrementSkipped();
deadLetterWriter.write(item); // audit trail for ops team
}
}
8.2 Restart & Checkpoint Spring Batch persists job execution state to a JobRepository (H2 for dev, PostgreSQL for prod). If the job fails mid-run, re-executing the same JobInstance resumes from the last committed chunk boundary — equivalent to a JCL RESTART on the mainframe.
-
Integration Test Strategy 9.1 Full-Job Integration Test Uses a curated golden input file that exercises every code path from the COBOL PROCEDURE DIVISION and asserts on the output file contents and run-control counters. @SpringBatchTest @SpringBootTest class AccountProcessingJobIT {
@Autowired JobLauncherTestUtils jobLauncherTestUtils; @Autowired RunControlCounters counters;
@Test void fullJob_matchesCobolGoldenOutput() throws Exception { // Input: 5 ACTIVE SAVINGS, 2 ACTIVE CHECKING, 1 CLOSED JobExecution exec = jobLauncherTestUtils.launchJob();
assertThat(exec.getStatus()).isEqualTo(BatchStatus.COMPLETED); assertThat(counters.totalRead()).isEqualTo(8L); assertThat(counters.totalProcessed()).isEqualTo(7L); assertThat(counters.totalSkipped()).isEqualTo(1L); // Assert output matches COBOL ACCT-OUT.DAT golden file byte-for-byte assertThat(outputFile).hasSameTextualContentAs(goldenOutputFile);} }
9.2 Parallel Execution Smoke Test Validates that multiple job instances (different input partitions) do not corrupt shared counters. Spring Batch's JobScope ensures each job execution gets isolated beans. @Test void concurrentJobs_isolatedCounters() throws Exception { var f1 = executor.submit(() -> launchJob("partition-1.dat")); var f2 = executor.submit(() -> launchJob("partition-2.dat")); assertThat(f1.get().getStatus()).isEqualTo(BatchStatus.COMPLETED); assertThat(f2.get().getStatus()).isEqualTo(BatchStatus.COMPLETED); // Each job has its own RunControlCounters bean — no shared state }
9.3 Restart Recovery Test @Test void failedJob_restartsFromCheckpoint() throws Exception { // First run: inject I/O failure at record 500 JobExecution failed = launchJobWithFault(500); assertThat(failed.getStatus()).isEqualTo(BatchStatus.FAILED);
// Second run: same JobInstance, resumes from committed chunk
JobExecution restarted = jobLauncherTestUtils.launchJob();
assertThat(restarted.getStatus()).isEqualTo(BatchStatus.COMPLETED);
// Output file contains ALL records (first 499 from run 1 + rest from run 2)
assertOutputRecordCount(TOTAL_INPUT_RECORDS);
}