engineering-design-and-analysis
Designing a Customizable Reporting Engine with the Builder Pattern in Java Spring Boot
Table of Contents
Introduction: The Need for a Flexible Reporting Engine
Enterprise applications frequently require a reporting engine that can adapt to ever-changing business requirements. A static, hard‑coded report generator quickly becomes a maintenance burden when stakeholders demand new data sources, filters, output formats, or visual layouts. The Builder Pattern, a creational design pattern from the Gang of Four, offers a clean way to construct complex objects step by step while keeping the construction process independent of the object’s representation. When applied to a reporting engine in a Java Spring Boot application, this pattern enables true customizability, testability, and extensibility.
In this article we will design a reporting engine from the ground up, starting with a core Report class and a flexible ReportBuilder interface. We will then implement concrete builders, integrate them as Spring Boot beans, add a Director for pre‑defined report templates, and discuss real‑world considerations such as caching, thread safety, and testing. By the end you will have a production‑ready blueprint for a reporting subsystem that can grow with your business.
Understanding the Builder Pattern in Depth
The Builder Pattern is often confused with the Abstract Factory or Factory Method patterns, but its purpose is distinct: it guides the construction of a product step by step, allowing the client to choose which steps to invoke and in which order. A classic “Design Patterns” book example is creating a document – you might want a PDF version, an HTML version, or a plain‑text version, all built from the same sequence of steps (add header, add paragraph, add footer).
Key participants in the pattern:
- Product – The complex object being built (our
Report). - Builder – Abstract interface defining the construction steps.
- ConcreteBuilder – Implements the Builder interface, assembles the product, and provides a method to retrieve the result.
- Director (optional) – Orchestrates the building steps using the Builder interface, often encapsulates a default or often‑used construction sequence.
This separation of concerns means that the same construction process can produce different representations simply by swapping the ConcreteBuilder. For a reporting engine, this translates into being able to generate a “summary report” or a “detailed report” using the same ReportBuilder interface but different implementations.
Designing the Reporting Engine
Our reporting engine will be built around the Report product and a fluent ReportBuilder interface. Fluent interfaces (method chaining) are a natural fit for the Builder Pattern and lead to readable client code.
Defining the Report Product
The Report class holds the core data needed to generate any report. In a real system you might add fields for headers, footers, chart definitions, subreports, etc. For our example we keep it focused:
public class Report {
private String title;
private String dataSource; // e.g., "jdbc/myDb" or "file:/data.csv"
private String query; // SQL or a query identifier
private List<String> columns; // columns to display
private Filter filter; // complex filter object
private String outputFormat; // PDF, CSV, XLSX, HTML
private boolean showTotals;
// private constructor – only builders create instances
private Report() {}
// Builder inner class or external – we'll use an external builder
// Getters (no setters after construction) – omitted for brevity
public String getTitle() { return title; }
public String getDataSource() { return dataSource; }
// etc.
}
Notice the private constructor. This enforces that a Report can only be created through a builder, ensuring that every instance is properly configured.
Creating the ReportBuilder Interface
The builder interface declares methods for each optional configuration step. To support method chaining, each setter returns ReportBuilder itself. A final build() method returns the constructed Report.
public interface ReportBuilder {
ReportBuilder setTitle(String title);
ReportBuilder setDataSource(String dataSource);
ReportBuilder setQuery(String query);
ReportBuilder setColumns(List<String> columns);
ReportBuilder setFilter(Filter filter);
ReportBuilder setOutputFormat(String outputFormat);
ReportBuilder showTotals(boolean showTotals);
Report build();
}
This interface is intentionally broad. Concrete builders can choose to ignore certain methods (e.g., a simple summary report builder might ignore setFilter) or validate the configuration before building.
Implementing Concrete Builders
Let’s implement two builders to demonstrate flexibility: a DetailedReportBuilder and a SummaryReportBuilder. Both implement the same interface but produce different kinds of reports.
DetailedReportBuilder
public class DetailedReportBuilder implements ReportBuilder {
private Report report = new Report();
@Override
public ReportBuilder setTitle(String title) {
report.setTitle(title);
return this;
}
@Override
public ReportBuilder setDataSource(String dataSource) {
report.setDataSource(dataSource);
return this;
}
@Override
public ReportBuilder setQuery(String query) {
report.setQuery(query);
return this;
}
@Override
public ReportBuilder setColumns(List<String> columns) {
report.setColumns(columns);
return this;
}
@Override
public ReportBuilder setFilter(Filter filter) {
report.setFilter(filter);
return this;
}
@Override
public ReportBuilder setOutputFormat(String outputFormat) {
report.setOutputFormat(outputFormat);
return this;
}
@Override
public ReportBuilder showTotals(boolean showTotals) {
report.setShowTotals(showTotals);
return this;
}
@Override
public Report build() {
// Validate critical fields
if (report.getDataSource() == null) {
throw new IllegalStateException("DataSource must be set");
}
// Additional validation logic...
return report;
}
}
SummaryReportBuilder
A summary report might ignore columns, filter, and totals, and instead aggregate everything into a single number or a simple table.
public class SummaryReportBuilder implements ReportBuilder {
private String title;
private String dataSource;
private String query;
// other fields are ignored or given defaults
@Override
public ReportBuilder setTitle(String title) {
this.title = title;
return this;
}
@Override
public ReportBuilder setDataSource(String dataSource) {
this.dataSource = dataSource;
return this;
}
@Override
public ReportBuilder setQuery(String query) {
this.query = query;
return this;
}
// All other setter methods either do nothing or throw UnsupportedOperationException
@Override
public ReportBuilder setColumns(List<String> columns) {
return this; // summary report ignores columns
}
// ... similar for filter, outputFormat, showTotals
@Override
public Report build() {
Report report = new Report();
report.setTitle(title);
report.setDataSource(dataSource);
report.setQuery(query);
report.setOutputFormat("CSV"); // default format
return report;
}
}
With this approach, a client can choose the builder that matches the required output complexity without changing the construction sequence. This is the essence of the Builder Pattern.
Adding a Director for Pre‑defined Templates
Often you want to encapsulate common construction sequences. A Director class can do this:
public class ReportDirector {
private final ReportBuilder builder;
public ReportDirector(ReportBuilder builder) {
this.builder = builder;
}
public Report constructMonthlySalesReport(String region) {
return builder
.setTitle("Monthly Sales – " + region)
.setDataSource("jdbc/sales_db")
.setQuery("SELECT * FROM sales WHERE region = :region")
.setColumns(List.of("Product", "Units Sold", "Revenue"))
.setFilter(new DateFilter(LocalDate.now().minusMonths(1), LocalDate.now()))
.setOutputFormat("PDF")
.showTotals(true)
.build();
}
public Report constructQuickSummary() {
return builder
.setTitle("Quick Summary")
.setDataSource("jdbc/sales_db")
.setQuery("SELECT count(*) as cnt, sum(revenue) as total FROM sales")
.setOutputFormat("CSV")
.build();
}
}
The Director can be injected with any ReportBuilder implementation. This decouples the template from the concrete construction details.
Builder Pattern in Spring Boot: Wiring and Usage
Spring Boot’s dependency injection makes it easy to manage builders as beans and switch them at runtime.
Step 1: Define Builders as Spring Beans
We can annotate our concrete builders with @Component or declare them in a @Configuration class:
@Configuration
public class ReportConfig {
@Bean
@Scope("prototype") // because each builder session uses a fresh instance
public DetailedReportBuilder detailedReportBuilder() {
return new DetailedReportBuilder();
}
@Bean
@Scope("prototype")
public SummaryReportBuilder summaryReportBuilder() {
return new SummaryReportBuilder();
}
@Bean
@Scope("prototype")
public ReportDirector reportDirector(ReportBuilder builder) {
// This bean will not resolve without specifying the builder – we'll discuss later
return new ReportDirector(builder);
}
}
Using prototype scope is important: each call to build() should create a new builder instance with a fresh internal state. If we used singleton scope, the builder would retain state from previous calls, causing bugs.
To handle the fact that ReportDirector requires a specific builder, we can use @Qualifier or a factory pattern. A practical approach is to define multiple director beans, one per builder type:
@Bean
public ReportDirector detailedReportDirector(@Qualifier("detailedReportBuilder") ReportBuilder builder) {
return new ReportDirector(builder);
}
@Bean
public ReportDirector summaryReportDirector(@Qualifier("summaryReportBuilder") ReportBuilder builder) {
return new ReportDirector(builder);
}
Step 2: Inject Builders/Directors into Controllers or Services
A typical controller might accept a report type parameter and use the appropriate component:
@RestController
@RequestMapping("/reports")
public class ReportController {
@Autowired
private ReportDirector detailedReportDirector;
@Autowired
private ReportDirector summaryReportDirector;
@GetMapping("/monthly/{region}")
public ResponseEntity<Report> getMonthlySales(@PathVariable String region) {
Report report = detailedReportDirector.constructMonthlySalesReport(region);
// Execute report generation logic...
return ResponseEntity.ok(report);
}
@GetMapping("/summary")
public ResponseEntity<Report> getSummary() {
Report report = summaryReportDirector.constructQuickSummary();
return ResponseEntity.ok(report);
}
}
Alternatively, you could inject builders directly and let the service layer choose. The key point: the client code never knows about the builder internals – it just calls build() or a director method.
Advanced Customization: Dynamic Builders with Spring’s ObjectProvider
Sometimes the builder selection must happen at runtime based on configuration properties or user roles. Spring’s ObjectProvider can help inject a prototype bean lazily:
@Service
public class ReportService {
private final ObjectProvider<DetailedReportBuilder> detailedBuilderProvider;
private final ObjectProvider<SummaryReportBuilder> summaryBuilderProvider;
public ReportService(ObjectProvider<DetailedReportBuilder> detailedBuilderProvider,
ObjectProvider<SummaryReportBuilder> summaryBuilderProvider) {
this.detailedBuilderProvider = detailedBuilderProvider;
this.summaryBuilderProvider = summaryBuilderProvider;
}
public Report generateReport(String type, Map<String, String> params) {
ReportBuilder builder;
if ("detailed".equalsIgnoreCase(type)) {
builder = detailedBuilderProvider.getObject();
} else {
builder = summaryBuilderProvider.getObject();
}
// Apply common params (e.g., title, dataSource)
String title = params.getOrDefault("title", "Report");
builder.setTitle(title)
.setDataSource(params.get("dataSource"));
// Build
return builder.build();
}
}
This pattern avoids having to pre‑wire every possible builder directly, while still keeping the code clean and testable.
Ensuring Immutability and Thread Safety
The Report product should be immutable after construction. Because builders are typically used in a single thread and are not shared, we do not need to synchronize the builder itself. However, if you plan to reuse a builder across threads (not recommended), ensure that the builder has no mutable shared state.
To enforce immutability, make the Report class truly immutable:
- Mark all fields as
private final. - Pass all values through the constructor (the builder calls a private constructor that sets everything).
- Provide only getters, no setters.
- For collections (e.g., columns), make defensive copies in the constructor or use
Collections.unmodifiableList.
public class Report {
private final String title;
private final String dataSource;
private final String query;
private final List<String> columns;
private final Filter filter;
private final String outputFormat;
private final boolean showTotals;
Report(String title, String dataSource, String query,
List<String> columns, Filter filter,
String outputFormat, boolean showTotals) {
this.title = title;
this.dataSource = dataSource;
this.query = query;
this.columns = columns == null ? List.of() : List.copyOf(columns);
this.filter = filter;
this.outputFormat = outputFormat;
this.showTotals = showTotals;
}
// getters...
}
Then the DetailedReportBuilder.build() creates the Report via this full constructor, passing all gathered values. This guarantees that once built, the report cannot be altered.
Testing the Reporting Engine
The Builder Pattern makes testing straightforward because you can inject mock builders or test‑specific builders. For unit tests of the Report product, you can instantiate it directly using a builder. For integration tests, you can verify that the correct builder is called and that the final report meets expectations.
Unit Testing a Concrete Builder
@Test
void testDetailedReportBuilder() {
DetailedReportBuilder builder = new DetailedReportBuilder();
Report report = builder
.setTitle("Test")
.setDataSource("jdbc/test")
.setOutputFormat("PDF")
.build();
assertThat(report.getTitle()).isEqualTo("Test");
assertThat(report.getDataSource()).isEqualTo("jdbc/test");
assertThat(report.getOutputFormat()).isEqualTo("PDF");
assertThat(report.isShowTotals()).isFalse(); // default
}
Testing with Mocks
When testing a service that uses a builder, mock the builder interface to verify interactions:
@Test
void testReportServiceUsesBuilderCorrectly() {
ReportBuilder mockBuilder = mock(ReportBuilder.class);
when(mockBuilder.setTitle(any())).thenReturn(mockBuilder);
when(mockBuilder.setDataSource(any())).thenReturn(mockBuilder);
// ... other stubs
Report expectedReport = new Report(/* ... */);
when(mockBuilder.build()).thenReturn(expectedReport);
ReportService service = new ReportService(/* ... */);
// inject mockBuilder via a test specific method
Report result = service.generateReport("detailed", Map.of("title", "Test", "dataSource", "jdbc/db"));
assertThat(result).isSameAs(expectedReport);
verify(mockBuilder).setTitle("Test");
verify(mockBuilder).setDataSource("jdbc/db");
verify(mockBuilder).build();
}
Performance Considerations and Caching
Building a Report object itself is cheap – it is merely assembling configuration data. The expensive part is executing the underlying query, transforming data, and generating the output file (PDF, XLSX). Therefore, the builder should not trigger any I/O. That responsibility belongs to a separate ReportExecutor or similar service.
If the same report configuration is requested repeatedly (e.g., the same monthly sales report for the same region), you can cache the Report object (the configuration) and reuse it. For caching you can use Spring’s @Cacheable on the director method or service method. Because the Report is immutable, it is safe to cache without defensive copies.
@Cacheable("reportConfigs")
public Report getMonthlySalesConfig(String region) {
return detailedReportDirector.constructMonthlySalesReport(region);
}
Caching the configuration allows the builder to run only once per distinct set of parameters, speeding up subsequent requests even before query execution.
Comparing the Builder Pattern with Other Approaches
When designing a reporting engine, you might consider other patterns:
- Factory Method – Good for creating a report object in one step, but doesn’t support step‑wise configuration.
- Constructor with many parameters – Telescoping constructors are error‑prone and hard to read. The Builder pattern provides a clear, named‑parameter style.
- JavaBeans pattern (mutable setters) – Allows step‑wise configuration but breaks immutability and can lead to partially initialized objects.
- Strategy Pattern – Could be combined with Builder; the builder could accept a strategy for rendering or data fetching.
The Builder pattern excels when the product has many optional components, like a report. It also supports the Open/Closed Principle – you can add new report types by implementing a new builder without altering existing code.
Real‑World Extensions
A production reporting engine often needs more than simple configuration. Consider these extensions:
- Nested builders for subreports – each subreport can have its own builder.
- A
ReportTemplateconcept – pre‑configured builders stored in a database or YAML files. - Integration with Spring Cloud Config to change report templates without redeploying.
- Using Lombok’s
@Builderannotation to auto‑generate the builder class. Be cautious: Lombok generates a static nested builder, which may not allow polymorphic builders for different report types. For our purpose, custom builders give more control.
For example, a YAML‑based template could be loaded:
monthly-sales:
title: "Monthly Sales - ${region}"
dataSource: "jdbc/sales"
query: "SELECT ..."
columns: ["Product", "Units Sold"]
outputFormat: "PDF"
showTotals: true
A service could parse this template and call the appropriate builder methods, making the reporting engine fully data‑driven.
Conclusion
The Builder Pattern, when applied to a Java Spring Boot reporting engine, provides a clean separation between the construction of report configurations and their representation. By defining a fluent ReportBuilder interface and implementing multiple concrete builders, you enable dynamic, runtime customization of reports without accumulating technical debt. The optional Director class encapsulates commonly used sequences, and Spring’s dependency injection makes it trivial to wire everything together.
This design is not only extensible – you can add new report types by writing a new builder – but also testable, because builders are plain Java objects that can be mocked or instantiated in isolation. Combined with immutable products and caching, the engine remains performant and safe.
Whether you are building a simple dashboard or a full‑fledged business intelligence platform, the Builder pattern gives you the flexibility to meet evolving requirements while maintaining a codebase that is a pleasure to work with. For further reading, see the official Spring Framework documentation on bean scopes and the classic Design Patterns book for more context on creational patterns.