In Part 3, we explored TBEG's template syntax in depth. Now let's build an Excel download API with Spring Boot + TBEG - the most common real-world use case.

Add one dependency, and you get an ExcelGenerator bean auto-registered - ready to serve Excel files straight from your controller.

Setup

Adding the Dependency

// build.gradle.kts dependencies { implementation("io.github.jogakdal:tbeg:1.2.5") } 

For Groovy DSL, Maven, and other build tools, see the GitHub documentation.

application.yml

tbeg: file-naming-mode: timestamp # none, timestamp timestamp-format: yyyyMMdd_HHmmss file-conflict-policy: sequence # error, sequence preserve-template-layout: true missing-data-behavior: warn # warn, throw unmarked-hide-policy: warn-and-hide # warn-and-hide, error 

These are the key options. For the complete list, see the Configuration Reference.

Auto-Configuration

Adding the tbeg dependency automatically activates TbegAutoConfiguration:

  • ExcelGenerator bean registered automatically
  • application.yml tbeg.* properties bound to TbegProperties
  • Automatic resource cleanup on application shutdown

No @Bean definitions or @EnableXxx annotations required.

Service Pattern

Kotlin

@Service class ReportService( private val excelGenerator: ExcelGenerator, private val resourceLoader: ResourceLoader, private val employeeRepository: EmployeeRepository ) { /** Generate a simple report using a Map */ fun generateSimpleReport(): ByteArray { val template = resourceLoader.getResource("classpath:templates/simple.xlsx") val data = mapOf( "title" to "Simple Report", "date" to LocalDate.now().toString(), "author" to "Yongho Hwang" ) return excelGenerator.generate(template.inputStream, data) } /** Generate an employee report using DataProvider */ fun generateEmployeeReport(): Path { val template = resourceLoader.getResource("classpath:templates/employees.xlsx") val employeeCount = employeeRepository.count().toInt() val provider = simpleDataProvider { value("title", "Employee Status Report") value("date", LocalDate.now().toString()) // Providing count upfront enables immediate formula range calculation // and prevents double iteration for better performance items("employees", employeeCount) { employeeRepository.findAll().iterator() } metadata { title = "Employee Status Report" author = "HR System" company = "Example Corp" } } return excelGenerator.generateToFile( template = template.inputStream, dataProvider = provider, outputDir = Path.of("/var/reports"), baseFileName = "employee_report" ) } } 

Java

@Service public class ReportService { private final ExcelGenerator excelGenerator; private final ResourceLoader resourceLoader; private final EmployeeRepository employeeRepository; public ReportService( ExcelGenerator excelGenerator, ResourceLoader resourceLoader, EmployeeRepository employeeRepository) { this.excelGenerator = excelGenerator; this.resourceLoader = resourceLoader; this.employeeRepository = employeeRepository; } public byte[] generateSimpleReport() throws IOException { var template = resourceLoader.getResource("classpath:templates/simple.xlsx"); Map<String, Object> data = new HashMap<>(); data.put("title", "Simple Report"); data.put("date", LocalDate.now().toString()); data.put("author", "Yongho Hwang"); return excelGenerator.generate(template.getInputStream(), data); } public Path generateEmployeeReport() throws IOException { var template = resourceLoader.getResource("classpath:templates/employees.xlsx"); int employeeCount = (int) employeeRepository.count(); var provider = SimpleDataProvider.builder() .value("title", "Employee Status Report") .value("date", LocalDate.now().toString()) .itemsFromSupplier("employees", employeeCount, () -> employeeRepository.findAll().iterator()) .metadata(meta -> meta .title("Employee Status Report") .author("HR System") .company("Example Corp")) .build(); return excelGenerator.generateToFile( template.getInputStream(), provider, Path.of("/var/reports"), "employee_report" ); } } 

Key points:

  • ExcelGenerator is injected via constructor - no extra setup
  • generate() -> returns ByteArray (ideal for controller responses)
  • generateToFile() -> returns file Path (saves to disk)
  • simpleDataProvider { } DSL for declarative data composition

Excel Download from Controller

The most common pattern in practice - GET request -> Excel file download.

Kotlin

@RestController @RequestMapping("/api/reports") class ReportController( private val excelGenerator: ExcelGenerator, private val resourceLoader: ResourceLoader, private val employeeRepository: EmployeeRepository ) { @GetMapping("/employees/download") fun downloadEmployeeReport(): ResponseEntity<Resource> { val template = resourceLoader.getResource("classpath:templates/employees.xlsx") val data = mapOf( "title" to "Employee Status", "date" to LocalDate.now().toString(), "employees" to employeeRepository.findAll() ) val bytes = excelGenerator.generate(template.inputStream, data) val filename = "employee_status_${LocalDate.now()}.xlsx" val encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8) return ResponseEntity.ok() .header( HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''$encodedFilename" ) .contentType(MediaType.parseMediaType( "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" )) .contentLength(bytes.size.toLong()) .body(ByteArrayResource(bytes)) } } 

Java

@RestController @RequestMapping("/api/reports") public class ReportController { private final ExcelGenerator excelGenerator; private final ResourceLoader resourceLoader; private final EmployeeRepository employeeRepository; // Constructor omitted... @GetMapping("/employees/download") public ResponseEntity<Resource> downloadEmployeeReport() throws IOException { var template = resourceLoader.getResource("classpath:templates/employees.xlsx"); Map<String, Object> data = new HashMap<>(); data.put("title", "Employee Status"); data.put("date", LocalDate.now().toString()); data.put("employees", employeeRepository.findAll()); byte[] bytes = excelGenerator.generate(template.getInputStream(), data); String filename = "employee_status_" + LocalDate.now() + ".xlsx"; String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8); return ResponseEntity.ok() .header( HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encodedFilename ) .contentType(MediaType.parseMediaType( "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" )) .contentLength(bytes.length) .body(new ByteArrayResource(bytes)); } } 

Key points:

  • Content-Disposition: Use filename*=UTF-8'' for proper filename encoding
  • Content-Type: Use the .xlsx-specific MIME type
  • ContentLength: Enables browser download progress display

Streaming Response

The controller example above uses generate() to create a ByteArray first, then wraps it in the response. For large files, generateToStream() writes directly to the HTTP response stream, reducing memory usage.

Kotlin

@GetMapping("/employees/download/stream") fun downloadEmployeeReportStream(): ResponseEntity<StreamingResponseBody> { val template = resourceLoader.getResource("classpath:templates/employees.xlsx") val data = mapOf( "title" to "Employee Status", "date" to LocalDate.now().toString(), "employees" to employeeRepository.findAll() ) val filename = "employee_status_${LocalDate.now()}.xlsx" val encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8) val body = StreamingResponseBody { outputStream -> excelGenerator.generateToStream(template.inputStream, data, outputStream) } return ResponseEntity.ok() .header( HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''$encodedFilename" ) .contentType(MediaType.parseMediaType( "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" )) .body(body) } 

Java

@GetMapping("/employees/download/stream") public ResponseEntity<StreamingResponseBody> downloadEmployeeReportStream() throws IOException { var template = resourceLoader.getResource("classpath:templates/employees.xlsx"); Map<String, Object> data = new HashMap<>(); data.put("title", "Employee Status"); data.put("date", LocalDate.now().toString()); data.put("employees", employeeRepository.findAll()); String filename = "employee_status_" + LocalDate.now() + ".xlsx"; String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8); StreamingResponseBody body = outputStream -> excelGenerator.generateToStream(template.getInputStream(), data, outputStream); return ResponseEntity.ok() .header( HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encodedFilename ) .contentType(MediaType.parseMediaType( "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" )) .body(body); } 

Key points:

  • StreamingResponseBody runs on a separate thread, keeping the servlet thread non-blocking
  • Unlike generate() which returns a ByteArray, generateToStream() writes directly to an OutputStream, making response code more concise
  • ContentLength cannot be set (size is unknown upfront) - the browser shows download size instead of progress

Asynchronous Report Generation

Large reports can take a while. With submitToFile(), you return 202 Accepted immediately and get notified via events when generation completes.

Kotlin

@PostMapping("/employees/async") fun generateReportAsync( @RequestBody request: ReportRequest ): ResponseEntity<JobResponse> { val template = resourceLoader.getResource("classpath:templates/employees.xlsx") val provider = simpleDataProvider { value("title", request.title) items("employees") { employeeRepository.findByHireDateBetween( request.startDate, request.endDate ).iterator() } } val job = excelGenerator.submitToFile( template = template.inputStream, dataProvider = provider, outputDir = Path.of("/var/reports"), baseFileName = "employee_report", listener = object : ExcelGenerationListener { override fun onCompleted(jobId: String, result: GenerationResult) { eventPublisher.publishEvent( ReportReadyEvent(jobId, result.filePath!!, result.rowsProcessed) ) } override fun onFailed(jobId: String, error: Exception) { eventPublisher.publishEvent( ReportFailedEvent(jobId, error.message) ) } } ) return ResponseEntity.accepted().body(JobResponse(job.jobId)) } 

Java

@PostMapping("/employees/async") public ResponseEntity<JobResponse> generateReportAsync( @RequestBody ReportRequest request ) throws IOException { var template = resourceLoader.getResource("classpath:templates/employees.xlsx"); var provider = SimpleDataProvider.builder() .value("title", request.getTitle()) .itemsFromSupplier("employees", () -> employeeRepository.findByHireDateBetween( request.getStartDate(), request.getEndDate() ).iterator()) .build(); var job = excelGenerator.submitToFile( template.getInputStream(), provider, Path.of("/var/reports"), "employee_report", new ExcelGenerationListener() { @Override public void onCompleted(String jobId, GenerationResult result) { eventPublisher.publishEvent( new ReportReadyEvent(jobId, result.getFilePath(), result.getRowsProcessed()) ); } @Override public void onFailed(String jobId, Exception error) { eventPublisher.publishEvent( new ReportFailedEvent(jobId, error.getMessage()) ); } } ); return ResponseEntity.accepted().body(new JobResponse(job.getJobId())); } 

ExcelGenerationListener callbacks:

Callback Trigger
onStarted(jobId) Generation begins
onProgress(jobId, progress) At configured row intervals
onCompleted(jobId, result) Generation complete
onFailed(jobId, error) Error occurred

Combine with Spring's @EventListener for email notifications, WebSocket push, and other post-processing workflows.

For a complete async API + WebSocket real-time progress example, see the GitHub documentation.

JPA Stream Integration

For datasets with hundreds of thousands of rows, JPA Streams provide memory-efficient processing.

Kotlin

@Service class LargeReportService( private val excelGenerator: ExcelGenerator, private val resourceLoader: ResourceLoader, private val employeeRepository: EmployeeRepository ) { @Transactional(readOnly = true) // Required to keep the Stream alive fun generateLargeEmployeeReport(): Path { val template = resourceLoader.getResource("classpath:templates/employees.xlsx") val employeeCount = employeeRepository.count().toInt() val provider = simpleDataProvider { value("title", "All Employee Status") items("employees", employeeCount) { employeeRepository.streamAll().iterator() } } return excelGenerator.generateToFile( template = template.inputStream, dataProvider = provider, outputDir = Path.of("/var/reports"), baseFileName = "all_employees" ) } } 

Java

@Service public class LargeReportService { private final ExcelGenerator excelGenerator; private final ResourceLoader resourceLoader; private final EmployeeRepository empl...
Source:https://dev.to/jogakdal/building-an-excel-download-api-with-tbeg-and-spring-boot-kotlinjava-4ghh