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:
-
ExcelGeneratorbean registered automatically -
application.ymltbeg.*properties bound toTbegProperties - 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:
-
ExcelGeneratoris injected via constructor - no extra setup -
generate()-> returnsByteArray(ideal for controller responses) -
generateToFile()-> returns filePath(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: Usefilename*=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:
-
StreamingResponseBodyruns on a separate thread, keeping the servlet thread non-blocking - Unlike
generate()which returns aByteArray,generateToStream()writes directly to anOutputStream, making response code more concise -
ContentLengthcannot 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...