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:
- Tight coupling: client code depends on concrete classes.
- Duplication: branching logic gets repeated in multiple places.
- 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);
};
}
}
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")
}
}
}
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"))
}
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"))
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 interfacepluswhengives 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:
- Correct concrete type is returned for valid input.
- 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());
}
@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)
}
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"
}
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"
}
When to Use Factory Pattern
Use a factory when:
- Construction logic depends on runtime input.
- You want to hide concrete classes from clients.
- You want one place to validate and normalize creation input.
- 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:
- Java 21: NotificationFactory.java
- Kotlin: NotificationFactory.kt
- Scala 2: NotificationFactory.scala
- Scala 3: NotificationFactory.scala
Test files:
- Java 21: NotificationFactoryTest.java
- Kotlin: NotificationFactoryTest.kt
- Scala 2: NotificationFactoryTest.scala
- Scala 3: NotificationFactoryTest.scala
This is part of our Design Patterns in JVM Languages series. Check out the full design patterns guide for more patterns and interview preparation.