Table of contents
- The Moment
newBecomes a Problem - Factory Method — Delegating to Subclasses
- Abstract Factory — Creating Related Objects as a Set
- Builder — Assembling Complex Objects Step by Step
- Comparing the Three Patterns
- Common Forms in Practice
- When Choosing a Creational Pattern
The Moment new Becomes a Problem
Using new to create objects is natural. new ArrayList<>(), new UserService() — you have been doing it since you first learned programming. But as a codebase grows, there comes a point where new starts causing friction.
public class OrderService {
public void placeOrder(OrderRequest request) {
// Send notification
Notification notification = new EmailNotification(); // This is the problem
notification.send(request.getUserEmail(), "Your order has been completed.");
}
}
In this code, OrderService directly knows the concrete class EmailNotification. Want to switch to SMS? You have to change it to new SmsNotification(). Need to dynamically choose between email and SMS based on user settings? An if-else creeps in. Want to inject a fake notification in tests without sending real emails? You have to modify OrderService’s code.
The root of this problem is that creation and usage are mixed in one place. Separating the responsibility of creating an object from the responsibility of using it loosens the coupling. The patterns that systematically achieve this separation are Factory Method, Abstract Factory, and Builder.
Factory Method — Delegating to Subclasses
Structure of the Pattern
The Factory Method pattern delegates object creation to subclasses. The superclass defines only the interface for “what to create,” while “which concrete class to use” is decided by the subclass.
classDiagram
class NotificationFactory {
+createNotification()* Notification
+notify(message: String)
}
class EmailNotificationFactory {
+createNotification() Notification
}
class SmsNotificationFactory {
+createNotification() Notification
}
class Notification {
<<interface>>
+send(to: String, message: String)
}
class EmailNotification {
+send(to: String, message: String)
}
class SmsNotification {
+send(to: String, message: String)
}
NotificationFactory <|-- EmailNotificationFactory
NotificationFactory <|-- SmsNotificationFactory
Notification <|.. EmailNotification
Notification <|.. SmsNotification
EmailNotificationFactory ..> EmailNotification : creates
SmsNotificationFactory ..> SmsNotification : creates
NotificationFactory ..> Notification : uses
NotificationFactory declares createNotification() as an abstract factory method. notify() calls this method to obtain the object and sends the actual notification. What Notification implementation comes back is not its concern.
Code Example
Define the factory superclass.
public abstract class NotificationFactory {
// Factory method — decided by the subclass
protected abstract Notification createNotification();
// Usage logic resides in the superclass
public void notify(String to, String message) {
Notification notification = createNotification();
notification.send(to, message);
}
}
What this class does is call createNotification() inside notify() to get a notification object, then call its send(). It does not know what kind of notification it is.
Create the email and SMS factories.
public class EmailNotificationFactory extends NotificationFactory {
@Override
protected Notification createNotification() {
return new EmailNotification("smtp.company.com", 587);
}
}
public class SmsNotificationFactory extends NotificationFactory {
@Override
protected Notification createNotification() {
return new SmsNotification("API_KEY_HERE");
}
}
Client code just swaps the factory.
NotificationFactory factory = resolveFactory(userPreference); // Decided at runtime
factory.notify(user.getEmail(), "Order complete");
resolveFactory() returns the appropriate factory based on user settings. Whether it is email, SMS, or KakaoTalk, the client code does not change.
Core Value of Factory Method
The problems this pattern solves boil down to three things.
- Eliminates concrete class dependency: Instead of
OrderServicedirectly knowingEmailNotification, it only knows theNotificationinterface. - A structure open for extension: When a new notification channel is added, you create one more factory class without touching existing code. This follows the OCP (Open-Closed Principle).
- Encapsulates creation logic: Creating an email notification requires an SMTP server address and port; SMS requires an API key. These details are hidden inside the factory.
Abstract Factory — Creating Related Objects as a Set
How Is It Different from Factory Method?
Factory Method creates a single object in a subclass. Abstract Factory provides an interface for creating a family of related objects at once. It is used not when you need “an email notification” but when you need a consistent set like “email notification + email logger + email template.”
Consider UI themes as an example. A light theme needs light buttons and light text fields; a dark theme needs dark buttons and dark text fields. Buttons and text fields are different objects, but they must belong to the same theme — that is the constraint. A combination of a light button + a dark text field is unintended.
Code Example — Cross-Platform UI Components
First, define the product interfaces.
public interface Button {
void render();
}
public interface TextField {
void render();
}
Create platform-specific implementations.
// Windows family
public class WindowsButton implements Button {
@Override
public void render() { System.out.println("[Windows Button]"); }
}
public class WindowsTextField implements TextField {
@Override
public void render() { System.out.println("[Windows TextField]"); }
}
// macOS family
public class MacButton implements Button {
@Override
public void render() { System.out.println("[Mac Button]"); }
}
public class MacTextField implements TextField {
@Override
public void render() { System.out.println("[Mac TextField]"); }
}
Declare the Abstract Factory. This interface is the key.
public interface UIFactory {
Button createButton();
TextField createTextField();
}
This interface defines the contract for creating buttons and text fields as a set.
Implement the platform-specific factories.
public class WindowsUIFactory implements UIFactory {
@Override
public Button createButton() { return new WindowsButton(); }
@Override
public TextField createTextField() { return new WindowsTextField(); }
}
public class MacUIFactory implements UIFactory {
@Override
public Button createButton() { return new MacButton(); }
@Override
public TextField createTextField() { return new MacTextField(); }
}
The client just receives a UIFactory.
public class Application {
private final Button button;
private final TextField textField;
public Application(UIFactory factory) {
this.button = factory.createButton();
this.textField = factory.createTextField();
}
public void render() {
button.render();
textField.render();
}
}
// Usage
UIFactory factory = isWindows() ? new WindowsUIFactory() : new MacUIFactory();
Application app = new Application(factory);
app.render();
Application has no idea whether the button and text field are Windows or Mac. What the factory guarantees is that objects from the same family are created together. A Mac text field popping out of a Windows factory is structurally impossible.
When to Use and When Not To
Abstract Factory is meaningful only when a “family of related objects” exists. If you only need to create a single button, reaching for Abstract Factory is over-engineering. On the other hand, when you have objects like DB connection + query builder + transaction manager that must change as a set per vendor, this pattern structurally prevents mismatches.
Builder — Assembling Complex Objects Step by Step
Why Constructors Become Cumbersome
Creating an object with many fields via new means memorizing parameter order.
// The call site cannot tell what each parameter means
HttpRequest request = new HttpRequest(
"GET", "https://api.example.com", null, 30, true, false, null
);
Looking at this code alone, you cannot tell whether 30 is a timeout or a retry count. You have no idea what true and false each toggle. Adding a field breaks every existing call site. This condition is called the Telescoping Constructor Anti-Pattern.
Structure of the Pattern
The Builder pattern separates the construction process of a complex object into steps. Each step is expressed as a method call, and calling build() at the end produces the finished object.
flowchart LR
A["Create Builder"] --> B["method(url)"]
B --> C["timeout(30)"]
C --> D["followRedirects(true)"]
D --> E["header(key, value)"]
E --> F["build()"]
F --> G["HttpRequest object"]
style G fill:#5ca45c
Code Example — HTTP Request Builder
First, define the HttpRequest class to be built.
public class HttpRequest {
private final String method;
private final String url;
private final Map<String, String> headers;
private final String body;
private final int timeoutSeconds;
private final boolean followRedirects;
// Package-private — only accessible by the Builder
HttpRequest(Builder builder) {
this.method = builder.method;
this.url = builder.url;
this.headers = Map.copyOf(builder.headers);
this.body = builder.body;
this.timeoutSeconds = builder.timeoutSeconds;
this.followRedirects = builder.followRedirects;
}
// Getters omitted
public static Builder builder(String method, String url) {
return new Builder(method, url);
}
public static class Builder {
// Required
private final String method;
private final String url;
// Optional (with defaults)
private Map<String, String> headers = new HashMap<>();
private String body = null;
private int timeoutSeconds = 30;
private boolean followRedirects = true;
private Builder(String method, String url) {
this.method = method;
this.url = url;
}
public Builder header(String key, String value) {
this.headers.put(key, value);
return this;
}
public Builder body(String body) {
this.body = body;
return this;
}
public Builder timeout(int seconds) {
this.timeoutSeconds = seconds;
return this;
}
public Builder followRedirects(boolean follow) {
this.followRedirects = follow;
return this;
}
public HttpRequest build() {
// Validation goes here too
if (method == null || url == null) {
throw new IllegalStateException("method and url are required");
}
return new HttpRequest(this);
}
}
}
This builder enforces required parameters (method, url) via the constructor, and lets the rest be specified optionally through method chaining.
The calling code is far more readable.
HttpRequest request = HttpRequest.builder("POST", "https://api.example.com/users")
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token)
.body("{\"name\": \"Kim Dev\"}")
.timeout(10)
.build();
Each setting is self-described by the method name. Omitting unnecessary settings applies the defaults.
Do Kotlin’s Named Arguments Replace Builder?
Kotlin natively supports named arguments and default values at the language level. Let us express the same HTTP request as a Kotlin data class.
data class HttpRequest(
val method: String,
val url: String,
val headers: Map<String, String> = emptyMap(),
val body: String? = null,
val timeoutSeconds: Int = 30,
val followRedirects: Boolean = true
)
// Usage
val request = HttpRequest(
method = "POST",
url = "https://api.example.com/users",
headers = mapOf(
"Content-Type" to "application/json",
"Authorization" to "Bearer $token"
),
body = """{"name": "Kim Dev"}""",
timeoutSeconds = 10
)
Most of the benefits that the Builder pattern provides are covered by language features. Parameter names are visible, defaults exist, and order can be rearranged freely. Creating a Builder for a simple DTO (Data Transfer Object) in a Kotlin project is unnecessary boilerplate.
So is Builder completely unnecessary in Kotlin? Not quite. The Builder still has value in these situations:
- When step-by-step validation is needed during construction: When the entire validity must be checked at
build()time, or invariants need to be verified at each step - When the construction process itself involves complex logic: When it is not simple field assignment but one field’s value affects another field’s default
- Interoperability with Java code: When Kotlin classes are used from Java, named arguments are unavailable, so providing a Builder is preferable
When using Builder in Kotlin, the apply scope function keeps things concise.
class QueryBuilder {
private var table: String = ""
private val conditions = mutableListOf<String>()
private var limit: Int? = null
fun from(table: String) = apply { this.table = table }
fun where(condition: String) = apply { conditions.add(condition) }
fun limit(n: Int) = apply { this.limit = n }
fun build(): String {
require(table.isNotBlank()) { "Table name is required" }
val query = StringBuilder("SELECT * FROM $table")
if (conditions.isNotEmpty()) {
query.append(" WHERE ${conditions.joinToString(" AND ")}")
}
limit?.let { query.append(" LIMIT $it") }
return query.toString()
}
}
val sql = QueryBuilder()
.from("users")
.where("age > 20")
.where("status = 'active'")
.limit(100)
.build()
What this builder does is assemble an SQL query condition by condition. Because conditions accumulate rather than being simple field assignment, named arguments cannot replace it.
Comparing the Three Patterns
| Criterion | Factory Method | Abstract Factory | Builder |
|---|---|---|---|
| Problem it solves | Delegating which class to instantiate | Creating related objects consistently as a set | Assembling a complex object step by step |
| Core structure | Inheritance (factory subclasses) | Interface (factory interface) | Method chaining |
| Number of objects created | One | Multiple (a set) | One (complex) |
| What the client knows | Factory superclass | Factory interface | Builder class |
| How to extend | Add a new factory subclass | Add a new factory implementation | Add methods to the builder |
| Java practical examples | Calendar.getInstance() | JDBC DriverManager | StringBuilder, Lombok @Builder |
The three patterns are not competitors — their scopes differ. To hide the concrete type of a single object like notification dispatch, use Factory Method. When you need bundled objects that change together like DB driver + connection + statement, use Abstract Factory. To conveniently create a single object with many configuration options like an HTTP request, reach for Builder.
Common Forms in Practice
Static Factory Methods
GoF’s Factory Method is inheritance-based, but the most common variant in practice is the Static Factory Method. A technique emphasized in Joshua Bloch’s Effective Java, it provides named static methods instead of constructors.
// Constructor — intent is unclear
Boolean b1 = new Boolean(true);
// Static factory method — intent is expressed in the name
Boolean b2 = Boolean.valueOf(true);
List<String> empty = List.of();
Optional<User> user = Optional.ofNullable(findById(id));
The advantages of static factory methods are clear.
- They have names:
of(),valueOf(),from(),getInstance(),newInstance(), etc. allow expressing intent - They are not obligated to create a new object on every call: They can return a cached instance (
Boolean.valueOf(true)always returns the same object) - They can return a subtype of the declared return type: Declare an interface as the return type and hide the implementation
Despite the similar name, this differs from the GoF Factory Method in that it is a static method within a single class, not an inheritance structure. In practice, this variant appears far more frequently.
Lombok’s @Builder
Manually implementing the Builder pattern in every Java project is tedious. Lombok’s @Builder annotation auto-generates Builder code at compile time.
@Builder
public class UserProfile {
private final String name;
private final String email;
@Builder.Default
private final String role = "USER";
@Builder.Default
private final boolean active = true;
}
// Using the auto-generated builder
UserProfile profile = UserProfile.builder()
.name("Kim Dev")
.email("dev@example.com")
.build();
What this annotation does is automatically create a UserProfileBuilder inner class. @Builder.Default declares the default value to use when a value is not specified through the builder.
Convenient, but there are caveats. @Builder does not enforce required fields. Even if name is omitted, it compiles fine and null sneaks in at runtime. If required value validation is needed, manually overriding the build() method or using Kotlin’s require/check is safer.
When Choosing a Creational Pattern
When object creation concerns arise, ask these three questions.
- Does “which concrete class to create” get decided at runtime? -> Factory Method or static factory method
- Do related objects need to change as a set? -> Abstract Factory
- Is it a single object with many configuration options and diverse combinations? -> Builder
For simple cases, just using new is the right call. Patterns are tools to reach for when there is a problem, not a ritual to perform on every creation. Wrapping new ArrayList<>() in a Factory is a textbook case of over-engineering. Find the points in the code where coupling is actually causing problems, and apply patterns precisely there — that is the pragmatic approach.
In the next part, we cover two patterns that wrap existing objects to add features or control access. Decorator extends functionality without inheritance, and Proxy handles access control and lazy loading. We dig into the intent differences between these two, which are easily confused due to their similar structure.




Loading comments...