Stop Overusing ‘new’: Four Design Patterns That Separate Junior from Senior Developers
The article explains why indiscriminate use of the Java new operator leads to duplicated configuration, hidden complexity, and performance problems, and demonstrates how Factory, Builder, Object‑Pool, and Prototype patterns—illustrated with Spring Boot 3.5.0 code—provide clean, maintainable alternatives that distinguish junior from senior developers.
In Java the new keyword merely creates an object; it gives no information about the object's purpose, validity, creation cost, or whether a new instance is truly needed. The article shows a typical legacy code fragment where twelve places each instantiate and configure an HttpClient with identical settings, making a simple timeout change require edits in twelve files and risking inconsistency.
While using new is acceptable for simple objects such as new ArrayList<>() or new OrderRequest(), it becomes problematic when object creation involves complex configuration, variant selection, expensive construction, or cloning. Four representative scenarios are presented:
Complex configuration requiring callers to know many details.
Choosing an implementation via a magic string.
High‑cost construction performed on every request.
Repeatedly building nearly identical objects.
2.1 Factory Pattern: Hide the concrete type you are creating
Problem: When creation logic must choose among multiple implementations, hard‑coding the decision at each call forces widespread changes if the decision changes.
// ❌ Bad – callers use magic strings
public Notification send(String type, String message) {
if (type.equals("EMAIL")) return new EmailNotification(message);
if (type.equals("SMS")) return new SmsNotification(message);
if (type.equals("PUSH")) return new PushNotification(message);
throw new IllegalArgumentException("Unknown type: " + type);
}
// Adding a new type requires editing every if‑chainSolution: Move the selection logic into a factory so callers only request a Notification and the factory decides which implementation to provide.
public interface Notification {
void send(String message, String recipient);
}
public class NotificationFactory {
private final Map<String, Notification> strategies;
public NotificationFactory(EmailNotification email, SmsNotification sms, PushNotification push) {
this.strategies = Map.of(
"EMAIL", email,
"SMS", sms,
"PUSH", push
);
}
public Notification create(String channel) {
Notification n = strategies.get(channel.toUpperCase());
if (n == null) {
throw new IllegalArgumentException("Unknown channel: " + channel);
}
return n;
}
}
// Caller does not know the concrete class
Notification nf = factory.create(user.getPreferredChannel());
nf.send(message, user.getContactAddress());In Spring Boot the factory can be wired automatically:
@Component
public class NotificationFactory {
private final Map<String, Notification> strategies;
public NotificationFactory(List<Notification> notifications) {
this.strategies = notifications.stream()
.collect(Collectors.toMap(
n -> n.getClass().getSimpleName().replace("Notification", "").toUpperCase(),
n -> n
));
}
public Notification create(String channel) {
return Optional.ofNullable(strategies.get(channel.toUpperCase()))
.orElseThrow(() -> new IllegalArgumentException("Unknown channel: " + channel));
}
}
// Adding a new @Component Notification requires no factory changes.Golden rule: When object creation involves a conditional choice of implementation, that condition belongs in a factory, not at the call site.
2.2 Builder Pattern: Avoid telescoping constructors for complex objects
Problem: Objects with many optional fields lead to dozens of overloaded constructors or a single constructor filled with null values, making calls unreadable and error‑prone.
// ❌ Bad – many overloaded constructors
public Report(String title, String format) { ... }
public Report(String title, String format, boolean includeCharts) { ... }
public Report(String title, String format, boolean includeCharts, String currency) { ... }
// Or a single constructor with many arguments, e.g.
Report r = new Report("Q4 Summary", "PDF", true, false, "EUR", null, Locale.UK, 2);Solution: Use a Builder to construct the object step‑by‑step, giving each field a name and allowing any combination of optional values.
public class Report {
private final String title;
private final String format;
private final boolean includeCharts;
private final String currency;
private final Locale locale;
private final int columns;
private Report(Builder builder) {
this.title = builder.title;
this.format = builder.format;
this.includeCharts = builder.includeCharts;
this.currency = builder.currency;
this.locale = builder.locale;
this.columns = builder.columns;
}
public static class Builder {
private final String title;
private final String format;
private boolean includeCharts = false;
private String currency = "USD";
private Locale locale = Locale.US;
private int columns = 3;
public Builder(String title, String format) {
this.title = Objects.requireNonNull(title, "title is required");
this.format = Objects.requireNonNull(format, "format is required");
}
public Builder includeCharts(boolean v) { this.includeCharts = v; return this; }
public Builder currency(String v) { this.currency = v; return this; }
public Builder locale(Locale v) { this.locale = v; return this; }
public Builder columns(int v) { this.columns = v; return this; }
public Report build() { return new Report(this); }
}
}
Report report = new Report.Builder("Q4 Summary", "PDF")
.includeCharts(true)
.currency("EUR")
.locale(Locale.UK)
.columns(2)
.build();Lombok can generate the same pattern with zero boilerplate:
@Builder
public class ReportRequest {
@NonNull private final String title;
@NonNull private final String format;
@Builder.Default private boolean includeCharts = false;
@Builder.Default private String currency = "USD";
@Builder.Default private int columns = 3;
}
ReportRequest request = ReportRequest.builder()
.title("Q4 Summary")
.format("PDF")
.includeCharts(true)
.currency("EUR")
.build();Golden rule: When an object has more than three or four fields—especially optional ones—constructors become unwieldy; a Builder is the appropriate tool.
2.3 Object‑Pool Pattern: Reuse expensive resources instead of recreating them
Problem: Creating high‑cost objects (e.g., database connections, HTTP clients) on every request multiplies latency and resource consumption under load.
// ❌ Bad – new connection per request
public List<Order> getOrders(Long userId) {
DatabaseConnection conn = new DatabaseConnection(host, port, user, pass);
List<Order> orders = conn.query("SELECT * FROM orders WHERE user_id = ?", userId);
conn.close();
return orders; // 500 req/sec would open 500 connections
}Solution: Maintain a pre‑created pool of objects; callers borrow, use, and return them.
public class ObjectPool<T> {
private final BlockingQueue<T> pool;
private final Supplier<T> factory;
private final Consumer<T> resetFn;
public ObjectPool(int size, Supplier<T> factory, Consumer<T> resetFn) {
this.factory = factory;
this.resetFn = resetFn;
this.pool = new LinkedBlockingQueue<>(size);
IntStream.range(0, size).forEach(i -> pool.offer(factory.get()));
}
public T borrow() throws InterruptedException {
return pool.poll(5, TimeUnit.SECONDS);
}
public void release(T instance) {
resetFn.accept(instance);
pool.offer(instance);
}
}
// Example bean for PDF generators
@Bean
public ObjectPool<PdfGenerator> pdfGeneratorPool() {
return new ObjectPool<>(10, PdfGenerator::new, PdfGenerator::reset);
}
@Service
public class ReportService {
private final ObjectPool<PdfGenerator> pool;
public ReportService(ObjectPool<PdfGenerator> pool) { this.pool = pool; }
public byte[] generateReport(ReportRequest request) throws InterruptedException {
PdfGenerator generator = pool.borrow();
try { return generator.generate(request); }
finally { pool.release(generator); }
}
}Spring Boot’s default connection pool HikariCP is configured as follows:
spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000Golden rule: If object construction involves I/O, network handshakes, or takes more than a few milliseconds, it belongs in a pool rather than being created on every hot‑path execution.
2.4 Prototype Pattern: Clone a pre‑configured instance instead of rebuilding it
Problem: Some objects are expensive to configure (loading templates, initializing data structures) but are needed many times with only minor variations.
// ❌ Bad – rebuild template for each user
public EmailTemplate buildWelcomeEmail(User user) {
EmailTemplate template = new EmailTemplate();
template.loadFromDisk("templates/welcome.html");
template.setStylesheet(cssEngine.compile("styles/email.css"));
template.registerHelpers(handlebarsHelpers);
template.setLocale(user.getLocale());
template.setVariable("name", user.getName());
return template;
}Solution: Build a fully configured prototype once and clone it for each use.
@Component
public class EmailTemplatePrototype implements Cloneable {
private String compiledHtml;
private String compiledCss;
private Helpers registeredHelpers;
@PostConstruct
public void init() throws IOException {
this.compiledHtml = Files.readString(Path.of("templates/welcome.html"));
this.compiledCss = cssEngine.compile("styles/email.css");
this.registeredHelpers = handlebarsHelpers.load();
}
@Override
public EmailTemplatePrototype clone() {
try { return (EmailTemplatePrototype) super.clone(); }
catch (CloneNotSupportedException e) { throw new RuntimeException("Clone failed", e); }
}
public void setVariable(String key, String value) { /* ... */ }
public String render() { return compiledHtml; }
}
@Service
public class EmailService {
private final EmailTemplatePrototype welcomePrototype;
public EmailService(EmailTemplatePrototype welcomePrototype) { this.welcomePrototype = welcomePrototype; }
public void sendWelcome(User user) {
EmailTemplatePrototype template = welcomePrototype.clone();
template.setVariable("name", user.getName());
template.setVariable("locale", user.getLocale().toString());
emailClient.send(user.getEmail(), template.render());
}
}In Spring, the same effect can be achieved with @Scope("prototype") beans, ensuring each injection yields a fresh instance.
Golden rule: When constructing a new instance repeats costly configuration, create a prototype once and clone it; if cloning would share mutable state (e.g., a Map ), ensure a deep copy or use Spring’s prototype scope.
These four patterns—Factory, Builder, Object Pool, and Prototype—provide systematic ways to replace scattered new usages, improve code maintainability, reduce duplication, and scale performance, marking the transition from junior to senior Java/Spring development practices.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Spring Full-Stack Practical Cases
Full-stack Java development with Vue 2/3 front-end suite; hands-on examples and source code analysis for Spring, Spring Boot 2/3, and Spring Cloud.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
