Skip to content
ioob.dev
Go back

Design Patterns Part 5 — Factory Method, Abstract Factory, Builder

· 8 min read
Design Pattern Series (5/8)
  1. Design Patterns Part 1 — Why Learn Patterns
  2. Design Patterns Part 2 — Strategy and State
  3. Design Patterns Part 3 — Observer and Command
  4. Design Patterns Part 4 — Template Method and Chain of Responsibility
  5. Design Patterns Part 5 — Factory Method, Abstract Factory, Builder
  6. Design Patterns Part 6 — Decorator and Proxy
  7. Design Patterns Part 7 — Adapter, Facade, Composite: Patterns for Organizing Structure
  8. Design Patterns Part 8 — Singleton, Iterator, Prototype: Frequently Used but Often Misunderstood
Table of contents

Table of contents

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.

  1. Eliminates concrete class dependency: Instead of OrderService directly knowing EmailNotification, it only knows the Notification interface.
  2. 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).
  3. 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.

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:

  1. 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
  2. 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
  3. 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

CriterionFactory MethodAbstract FactoryBuilder
Problem it solvesDelegating which class to instantiateCreating related objects consistently as a setAssembling a complex object step by step
Core structureInheritance (factory subclasses)Interface (factory interface)Method chaining
Number of objects createdOneMultiple (a set)One (complex)
What the client knowsFactory superclassFactory interfaceBuilder class
How to extendAdd a new factory subclassAdd a new factory implementationAdd methods to the builder
Java practical examplesCalendar.getInstance()JDBC DriverManagerStringBuilder, 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.

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.

  1. Does “which concrete class to create” get decided at runtime? -> Factory Method or static factory method
  2. Do related objects need to change as a set? -> Abstract Factory
  3. 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.

-> Part 6: Decorator and Proxy


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
Design Patterns Part 4 — Template Method and Chain of Responsibility
Next Post
Design Patterns Part 6 — Decorator and Proxy