Writing clean and efficient Java code isn’t just about syntax - it’s about choosing the right design patterns to solve problems effectively. While everyone knows Singleton and Factory patterns, some rare but powerful patterns can reduce boilerplate, improve performance, and make code more maintainable.
1) Null Object Pattern
In Java, we often check for null
before calling a method to avoid NullPointerException
:
if (payment != null) {
payment.pay();
}
This can quickly clutter the codebase with unnecessary if-else
checks everywhere.
When to Use It:
When you frequently check for
null
before calling methods.When you want a default behaviour instead of handling
null
separately.When you want cleaner, safer, and more readable code.
Instead of returning null
, return an instance of a "do-nothing" class that implements the same interface.
// Step 1: Define an Interface
interface Logger {
void log(String message);
}
// Step 2: Real Implementation
class ConsoleLogger implements Logger {
public void log(String message) {
System.out.println("Logging: " + message);
}
}
// Step 3: Null Object Implementation
class NullLogger implements Logger {
public void log(String message) {
// Do nothing
}
}
// Step 4: Usage
public class Application {
private Logger logger;
public Application(Logger logger) {
// Instead of null, use a NullLogger as default
this.logger = (logger != null) ? logger : new NullLogger();
}
public void run() {
logger.log("Application started"); // Always safe
}
public static void main(String[] args) {
Application appWithLogger = new Application(new ConsoleLogger());
appWithLogger.run(); // Logs message
Application appWithoutLogger = new Application(null);
appWithoutLogger.run(); // No log, but no NPE either!
}
}
Instead of checking if (logger != null) logger.log()
, we return a "do-nothing" object (NullLogger
). This ensures that log()
is always safe to call, avoiding NullPointerException
and removing redundant null
checks.
Benefits:
No more
null
checksNo accidental
NullPointerException
Default behaviour is always safe
2) Parameter Object Pattern
Long parameter lists are hard to read, maintain, and extend.
public void search(String query, int pageNumber, int pageSize, boolean caseSensitive) { }
If we need to add a new parameter, we have to update every method call.
When to Use It:
When a method has too many parameters.
When parameters logically belong together.
When passing configurable options.
Instead of passing multiple parameters, wrap them into one object.
// Step 1: Create a Parameter Object
class SearchCriteria {
String query;
int pageNumber;
int pageSize;
boolean caseSensitive;
public SearchCriteria(String query, int pageNumber, int pageSize, boolean caseSensitive) {
this.query = query;
this.pageNumber = pageNumber;
this.pageSize = pageSize;
this.caseSensitive = caseSensitive;
}
}
// Step 2: Use it in a method
class SearchService {
public void search(SearchCriteria criteria) {
System.out.println("Searching for: " + criteria.query);
}
}
// Step 3: Usage
public class Main {
public static void main(String[] args) {
SearchCriteria criteria = new SearchCriteria("Java", 1, 10, false);
new SearchService().search(criteria);
}
}
Instead of passing multiple parameters to the search()
method, we wrap them inside a single SearchCriteria
object. This makes method calls cleaner, easier to extend, and more readable.
Benefits:
Cleaner method calls
Easier to modify (just update
SearchCriteria
without changing method signatures)More readable & maintainable
3) Initialization-on-Demand Holder (Best Singleton)
Creating a thread-safe Singleton usually requires synchronization, which affects performance.
When to Use It:
When you need a lazy-loaded, thread-safe Singleton.
When you want to avoid synchronization overhead.
When you want simpler Singleton code.
class Singleton {
private Singleton() {} // Private constructor
// Static inner class responsible for Singleton instance
private static class Holder {
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return Holder.INSTANCE;
}
}
By using a static inner class, we achieve a lazy-loaded, thread-safe Singleton without using synchronization or volatile
. This makes it more efficient and easy to implement.
Benefits:
Thread-safe by default
Lazy-loaded (created only when
getInstance()
is called)No synchronization overhead
4) Execute Around Pattern
You often repeat try-finally
for resource management (files, DB connections, etc.), which adds boilerplate code.
try (BufferedReader reader = new BufferedReader(new FileReader("file.txt"))) {
System.out.println(reader.readLine());
} catch (IOException e) {
e.printStackTrace();
}
When to Use It:
When handling resources that require setup & cleanup.
When you frequently repeat
try-finally
logic.
@FunctionalInterface
interface FileProcessor {
void process(BufferedReader reader) throws IOException;
}
class FileService {
public static void executeWithFile(String filePath, FileProcessor processor) {
try (BufferedReader reader = new BufferedReader(new FileReader(filePath))) {
processor.process(reader);
} catch (IOException e) {
e.printStackTrace();
}
}
}
// Usage
public class Main {
public static void main(String[] args) {
FileService.executeWithFile("file.txt", reader -> System.out.println(reader.readLine()));
}
}
Instead of repeating try-finally
blocks for handling files, we wrap resource management in a method (executeWithFile()
). This ensures automatic cleanup and reduces boilerplate while keeping the logic focused on processing the file.
Benefits:
Less boilerplate
Automatic resource management
Encapsulates repetitive logic
5) Proxy Pattern
Sometimes, you need to control access to an object - maybe it's expensive to create, or you need security checks before usage.
For example:
Lazy loading (only create an object when needed).
Security proxies (restrict access).
Logging, caching, or access control in network calls or databases.
When to Use It:
When creating an object is expensive, and you want to delay it until needed.
When you need logging, security checks, or caching before calling the real object.
When working with remote services (REST APIs, databases) to avoid unnecessary calls.
A database connection proxy that logs queries before executing them.
// Step 1: Define an Interface
interface Database {
void query(String sql);
}
// Step 2: Real Object (Expensive to create)
class RealDatabase implements Database {
public RealDatabase() {
System.out.println("Connecting to real database...");
}
@Override
public void query(String sql) {
System.out.println("Executing query: " + sql);
}
}
// Step 3: Proxy Object
class DatabaseProxy implements Database {
private RealDatabase realDatabase;
@Override
public void query(String sql) {
if (realDatabase == null) {
realDatabase = new RealDatabase(); // Lazy initialization
}
System.out.println("[LOG] Query: " + sql);
realDatabase.query(sql);
}
}
// Step 4: Usage
public class ProxyDemo {
public static void main(String[] args) {
Database db = new DatabaseProxy();
// First query (creates the real database object)
db.query("SELECT * FROM users");
// Second query (reuses the real database object)
db.query("DELETE FROM users WHERE id=1");
}
}
Instead of directly creating a costly database connection, we use a proxy to lazy-load the actual database object and log every query. This helps in performance optimization, logging, and security.
Benefits:
Lazy loading (object created only when needed).
Logging, security, and caching without modifying the real object.
Best for controlling access to expensive or sensitive operations.
These patterns are not as widely discussed, but they solve real-world Java problems in an elegant & maintainable way.