Factory Pattern: Let Someone Else Do the Creating

java java21 scala scala2 scala3 kotlin design-patterns creational-patterns factory-pattern

Imagine your code needs to send notifications through email, SMS, or push channels based on runtime input. If every service does if/else or switch and constructs concrete classes directly, object creation logic spreads everywhere, and every new channel means touching multiple files.

That is exactly where the Factory Pattern helps: you ask for a type of object, and a dedicated factory decides which concrete implementation to return.

The Problem: Creation Logic Everywhere

Without a factory, client code usually looks like this:

Notification notification;
if (channel.equalsIgnoreCase("email")) {
    notification = new EmailNotification();
} else if (channel.equalsIgnoreCase("sms")) {
    notification = new SmsNotification();
} else {
    notification = new PushNotification();
}

This works at first, but it creates three issues:

  1. Tight coupling: client code depends on concrete classes.
  2. Duplication: branching logic gets repeated in multiple places.
  3. Poor extensibility: adding a new type requires editing many callers.

Key Concepts

Concept What it means Why it matters
Product Common contract (Notification) Clients depend on behavior, not implementation
Concrete Products Email, Sms, Push classes/objects Encapsulate channel-specific behavior
Factory A single creation entry point Centralizes validation and object selection
Client Code that requests notifications Stays simple and open for extension

The Solution: Factory Across JVM Languages

Below is the same notification factory idea in Java 21, Kotlin, Scala 2, and Scala 3.

sealed interface Notification permits EmailNotification, SmsNotification, PushNotification {
    String send(String recipient, String message);
}

public final class NotificationFactory {
    public static Notification create(String channel) {
        if (channel == null || channel.isBlank()) {
            throw new IllegalArgumentException("Notification channel must not be blank");
        }
        return switch (channel.trim().toLowerCase(Locale.ROOT)) {
        case "email" -> new EmailNotification();
        case "sms" -> new SmsNotification();
        case "push" -> new PushNotification();
        default -> throw new IllegalArgumentException("Unsupported notification channel: " + channel);
        };
    }
}

View in repository

sealed interface Notification {
    fun send(recipient: String, message: String): String
}

object NotificationFactory {
    fun create(channel: String?): Notification {
        val normalized = channel?.trim()?.lowercase()?.takeIf { it.isNotEmpty() }
            ?: throw IllegalArgumentException("Notification channel must not be blank")
        return when (normalized) {
            "email" -> EmailNotification
            "sms" -> SmsNotification
            "push" -> PushNotification
            else -> throw IllegalArgumentException("Unsupported notification channel: $normalized")
        }
    }
}

View in repository

sealed trait Notification {
  def send(recipient: String, message: String): String
}

object NotificationFactory {
  def create(channel: String): Notification =
    Option(channel).map(_.trim.toLowerCase).filter(_.nonEmpty).map {
      case "email" => EmailNotification
      case "sms"   => SmsNotification
      case "push"  => PushNotification
      case other   => throw new IllegalArgumentException(s"Unsupported notification channel: $other")
    }.getOrElse(throw new IllegalArgumentException("Notification channel must not be blank"))
}

View in repository

sealed trait Notification:
  def send(recipient: String, message: String): String

object NotificationFactory:
  def create(channel: String): Notification =
    Option(channel).map(_.trim.toLowerCase).filter(_.nonEmpty).map:
      case "email" => EmailNotification
      case "sms"   => SmsNotification
      case "push"  => PushNotification
      case other   => throw new IllegalArgumentException(s"Unsupported notification channel: $other")
    .getOrElse(throw new IllegalArgumentException("Notification channel must not be blank"))

View in repository

Scala Developer Mental Model

  • In Java 21, you usually model the product contract with interfaces/sealed interfaces and route with switch.
  • In Scala 2/3, ADTs (sealed trait + case objects/classes) make product families explicit and pattern matching ergonomic.
  • In Kotlin, sealed interface plus when gives similar expressiveness with low boilerplate.

Comparison: Java 21 vs Scala 2 vs Scala 3 vs Kotlin

Language Factory branching Product modeling Boilerplate
Java 21 switch expression sealed interface + classes Medium
Scala 2 match expression sealed trait + case objects Low
Scala 3 match + indentation syntax sealed trait + case objects Low
Kotlin when expression sealed interface + objects Low

Testing the Factory

Factory tests should verify two things:

  1. Correct concrete type is returned for valid input.
  2. Invalid input fails fast with a clear error.
@Test
void shouldCreateEmailNotification() {
    Notification notification = NotificationFactory.create("email");
    assertInstanceOf(EmailNotification.class, notification);
}

@Test
void shouldRejectUnsupportedChannels() {
    IllegalArgumentException error = assertThrows(IllegalArgumentException.class,
        () -> NotificationFactory.create("fax"));
    assertEquals("Unsupported notification channel: fax", error.getMessage());
}

View full test file

@Test
fun shouldCreateEmailNotification() {
    val notification = NotificationFactory.create("email")
    assertTrue(notification is EmailNotification)
}

@Test
fun shouldRejectUnsupportedChannels() {
    val error = assertThrows(IllegalArgumentException::class.java) {
        NotificationFactory.create("fax")
    }
    assertEquals("Unsupported notification channel: fax", error.message)
}

View full test file

test("Factory should create email notification") {
  NotificationFactory.create("email") shouldBe EmailNotification
}

test("Factory should reject unsupported channels") {
  val error = the[IllegalArgumentException] thrownBy NotificationFactory.create("fax")
  error.getMessage shouldBe "Unsupported notification channel: fax"
}

View full test file

test("Factory should create email notification") {
  NotificationFactory.create("email") shouldBe EmailNotification
}

test("Factory should reject unsupported channels") {
  val error = the[IllegalArgumentException] thrownBy NotificationFactory.create("fax")
  error.getMessage shouldBe "Unsupported notification channel: fax"
}

View full test file

When to Use Factory Pattern

Use a factory when:

  1. Construction logic depends on runtime input.
  2. You want to hide concrete classes from clients.
  3. You want one place to validate and normalize creation input.
  4. You expect object families to grow over time.

Avoid it when creation is trivial and unlikely to change; a direct constructor may be simpler.

Code Samples

All examples in this post are available in the repository:

Implementation files:

Test files:


This is part of our Design Patterns in JVM Languages series. Check out the full design patterns guide for more patterns and interview preparation.