Skip to content
ioob.dev
Go back

Design Patterns Part 3 — Observer and Command

· 8 min read
Design Pattern Series (3/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

You Need to Send Notifications

When a product’s price changes in an e-commerce platform, multiple places need to be informed. The total in a shopping cart must be recalculated, push notifications must be sent to users who subscribed to price alerts, and dashboard statistics for admins need refreshing.

// Code that directly calls every related service on price change
public class Product {
    private int price;
    private CartService cartService;
    private NotificationService notificationService;
    private DashboardService dashboardService;

    public void changePrice(int newPrice) {
        this.price = newPrice;

        // Every time a new service needs notifying, this code must be modified
        cartService.recalculate(this);
        notificationService.notifyPriceChange(this);
        dashboardService.updateStats(this);
    }
}

Can you see the problem? Product directly knows about CartService, NotificationService, and DashboardService. Every time a new service is added, the Product class must be modified. The domain logic of price changes and the notification logic of “who to inform” are tangled together in one class.

The Observer pattern breaks this coupling.

Structure of the Observer Pattern

The core of the Observer pattern is the Publish-Subscribe structure. The side whose state changes (Subject) announces the change, and interested parties (Observers) receive the notification and each do their own work. The Subject does not know specifically who is listening. It only needs to know the Observer interface.

Two roles participate:

The sequence diagram below shows the Observer pattern’s workflow.

sequenceDiagram
    participant Client
    participant Subject
    participant ObserverA
    participant ObserverB

    Client->>Subject: addObserver(ObserverA)
    Client->>Subject: addObserver(ObserverB)

    Client->>Subject: changeState()
    Subject->>Subject: State change

    Subject->>ObserverA: update(state)
    ObserverA->>ObserverA: Execute own logic

    Subject->>ObserverB: update(state)
    ObserverB->>ObserverB: Execute own logic

Looking at the arrow directions, the Subject calls the Observers but does not directly know the concrete implementations (ObserverA, ObserverB). It simply calls the update() method of the Observer interface.

Implementing the Observer Pattern

Let us refactor the earlier product price change example using the Observer pattern. First, define the Observer interface and Subject.

// Observer interface that receives price change events
interface PriceObserver {
    fun onPriceChanged(productId: String, oldPrice: Int, newPrice: Int)
}

The Product class, acting as the Subject, manages the Observer list and notifies all observers when the price changes.

// Subject: registers/unregisters Observers and notifies them on state change
class Product(
    val id: String,
    val name: String,
    private var price: Int
) {
    private val observers = mutableListOf<PriceObserver>()

    fun addObserver(observer: PriceObserver) {
        observers.add(observer)
    }

    fun removeObserver(observer: PriceObserver) {
        observers.remove(observer)
    }

    fun changePrice(newPrice: Int) {
        val oldPrice = this.price
        this.price = newPrice
        println("[$name] Price changed: $oldPrice -> $newPrice")
        notifyObservers(oldPrice, newPrice)
    }

    private fun notifyObservers(oldPrice: Int, newPrice: Int) {
        observers.forEach { it.onPriceChanged(id, oldPrice, newPrice) }
    }

    fun getPrice() = price
}

Now create the Observer implementations. Each handles only its own concern.

// Observer that recalculates cart totals
class CartObserver : PriceObserver {
    override fun onPriceChanged(productId: String, oldPrice: Int, newPrice: Int) {
        println("  [Cart] Price change detected for product $productId -> recalculating total")
    }
}

// Observer that sends push notifications to users
class NotificationObserver : PriceObserver {
    override fun onPriceChanged(productId: String, oldPrice: Int, newPrice: Int) {
        val diff = newPrice - oldPrice
        val direction = if (diff > 0) "increased" else "decreased"
        println("  [Notification] Product $productId price $direction by ${Math.abs(diff)}")
    }
}

// Observer that updates the admin dashboard
class DashboardObserver : PriceObserver {
    override fun onPriceChanged(productId: String, oldPrice: Int, newPrice: Int) {
        println("  [Dashboard] Updating price statistics for product $productId")
    }
}

The client code looks like this.

fun main() {
    val product = Product("P001", "Wireless Keyboard", 50000)

    // Register Observers
    product.addObserver(CartObserver())
    product.addObserver(NotificationObserver())
    product.addObserver(DashboardObserver())

    // Price change -> all registered Observers are automatically notified
    product.changePrice(45000)

    // Output:
    // [Wireless Keyboard] Price changed: 50000 -> 45000
    //   [Cart] Price change detected for product P001 -> recalculating total
    //   [Notification] Product P001 price decreased by 5000
    //   [Dashboard] Updating price statistics for product P001
}

Product no longer directly knows about CartService or NotificationService. It only knows the PriceObserver interface. To add a new service, just create a class implementing PriceObserver and register it. Not a single line of Product code needs modification.

Observer Pattern in Practice

The Observer pattern is already built into countless frameworks and libraries. Java’s java.util.EventListener, Spring’s @EventListener, Android’s LiveData, and JavaScript’s DOM event listeners are all variations of the Observer pattern.

Let us look at a Spring usage example.

// Spring's event system — a framework-level implementation of the Observer pattern
data class PriceChangedEvent(
    val productId: String,
    val oldPrice: Int,
    val newPrice: Int
)

@Service
class ProductService(
    private val eventPublisher: ApplicationEventPublisher
) {
    fun changePrice(productId: String, newPrice: Int) {
        val oldPrice = findPrice(productId)
        updatePrice(productId, newPrice)

        // Publish event — ProductService has no idea who is listening
        eventPublisher.publishEvent(PriceChangedEvent(productId, oldPrice, newPrice))
    }
}

// Each listener independently receives the event
@Component
class CartEventListener {
    @EventListener
    fun handle(event: PriceChangedEvent) {
        println("Recalculating cart total: ${event.productId}")
    }
}

@Component
class NotificationEventListener {
    @EventListener
    fun handle(event: PriceChangedEvent) {
        println("Sending price change notification: ${event.productId}")
    }
}

There is no need to manually create an Observer interface — the framework provides the publish-subscribe infrastructure. Just annotate with @EventListener and it is automatically registered as an Observer.

Observer Pattern Pitfalls

The Observer pattern has several traps.

Memory leaks. If you register an Observer but never unregister it, the Subject holds a reference to the Observer indefinitely. Even when the Observer should be eligible for garbage collection, it will not be collected. In Java, either use WeakReference or explicitly unregister in accordance with the lifecycle.

Notification order dependency. You should not write logic that depends on the order in which Observers receive notifications. In many cases, the Subject does not guarantee “who receives first.” If Observer A’s result is referenced by Observer B, you need a different approach — not the Observer pattern.

Cascading updates. If Observer A receives a notification and changes its own state, which triggers a change in another Subject, which then notifies Observer B… such chain reactions make debugging extremely difficult. It is safest to avoid modifying another Subject’s state inside a notification handler.

The Command Pattern — Requests as Objects

While Observer focuses on “notifying that state has changed,” the Command pattern focuses on “turning the request itself into an object.” The direction is somewhat different.

Suppose you are building a text editor. The user types characters, deletes them, bolds text, and hits Undo. Implementing these actions directly might look like this:

// Directly executing each action — Undo is extremely difficult
public class TextEditor {
    private StringBuilder text = new StringBuilder();

    public void type(String input) {
        text.append(input);
    }

    public void delete(int count) {
        text.delete(text.length() - count, text.length());
    }

    // How to implement Undo? Where to store the previous state?
    // Each action requires different Undo logic.
}

To implement Undo, you need to record “what action was performed.” And when undoing, you must execute the inverse operation of that action. The Command pattern provides exactly this structure.

Structure of the Command Pattern

The core of the Command pattern is encapsulating a request as an object. The command “type characters” becomes an object itself, and that object contains both the execute and undo logic.

Four roles participate:

classDiagram
    class Invoker {
        -history: Stack~Command~
        +executeCommand(Command)
        +undo()
        +redo()
    }

    class Command {
        <<interface>>
        +execute()
        +undo()
    }

    class TypeCommand {
        -receiver: TextEditor
        -text: String
        +execute()
        +undo()
    }

    class DeleteCommand {
        -receiver: TextEditor
        -count: int
        -deleted: String
        +execute()
        +undo()
    }

    class BoldCommand {
        -receiver: TextEditor
        -range: Range
        +execute()
        +undo()
    }

    class TextEditor {
        +insert(text)
        +remove(count)
        +applyBold(range)
    }

    Invoker --> Command : executes
    Command <|.. TypeCommand
    Command <|.. DeleteCommand
    Command <|.. BoldCommand
    TypeCommand --> TextEditor
    DeleteCommand --> TextEditor
    BoldCommand --> TextEditor

The Invoker only knows the Command interface. It does not care what specific command it is or what Receiver it manipulates. This means adding new kinds of commands does not change the Invoker code.

Implementing the Command Pattern

Let us implement Undo/Redo for the text editor using the Command pattern.

// Common interface for commands: execute and undo
interface Command {
    fun execute()
    fun undo()
    fun description(): String
}

The Receiver TextEditor provides only basic text manipulation.

// Receiver: the actual implementation of text manipulation
class TextEditor {
    private val content = StringBuilder()

    fun insert(position: Int, text: String) {
        content.insert(position, text)
    }

    fun remove(position: Int, length: Int): String {
        val removed = content.substring(position, position + length)
        content.delete(position, position + length)
        return removed
    }

    fun getText(): String = content.toString()
    fun length(): Int = content.length
}

Now implement the concrete commands. Starting with the type command.

// Type command: inserts text on execute, removes the inserted text on undo
class TypeCommand(
    private val editor: TextEditor,
    private val position: Int,
    private val text: String
) : Command {
    override fun execute() {
        editor.insert(position, text)
    }

    override fun undo() {
        editor.remove(position, text.length)
    }

    override fun description() = "Type: '$text' (position: $position)"
}

// Delete command: removes text on execute, restores deleted text on undo
class DeleteCommand(
    private val editor: TextEditor,
    private val position: Int,
    private val length: Int
) : Command {
    private var deletedText: String = ""  // Remember deleted text for undo

    override fun execute() {
        deletedText = editor.remove(position, length)
    }

    override fun undo() {
        editor.insert(position, deletedText)
    }

    override fun description() = "Delete: $length characters (position: $position)"
}

The Invoker executes commands and manages history using stacks.

// Invoker: manages command execution and Undo/Redo history
class CommandInvoker {
    private val undoStack = ArrayDeque<Command>()
    private val redoStack = ArrayDeque<Command>()

    fun execute(command: Command) {
        command.execute()
        undoStack.addLast(command)
        redoStack.clear()  // Clear redo history when a new command is executed
        println("Executed: ${command.description()}")
    }

    fun undo() {
        if (undoStack.isEmpty()) {
            println("Nothing to undo")
            return
        }
        val command = undoStack.removeLast()
        command.undo()
        redoStack.addLast(command)
        println("Undo: ${command.description()}")
    }

    fun redo() {
        if (redoStack.isEmpty()) {
            println("Nothing to redo")
            return
        }
        val command = redoStack.removeLast()
        command.execute()
        undoStack.addLast(command)
        println("Redo: ${command.description()}")
    }

    fun getHistory(): List<String> = undoStack.map { it.description() }
}

Let us run through the entire flow.

fun main() {
    val editor = TextEditor()
    val invoker = CommandInvoker()

    // Type text
    invoker.execute(TypeCommand(editor, 0, "Hello"))
    println("Current: '${editor.getText()}'")  // Hello

    invoker.execute(TypeCommand(editor, 5, " World"))
    println("Current: '${editor.getText()}'")  // Hello World

    invoker.execute(TypeCommand(editor, 11, "!"))
    println("Current: '${editor.getText()}'")  // Hello World!

    // Undo twice
    invoker.undo()
    println("Current: '${editor.getText()}'")  // Hello World

    invoker.undo()
    println("Current: '${editor.getText()}'")  // Hello

    // Redo
    invoker.redo()
    println("Current: '${editor.getText()}'")  // Hello World

    // Check command history
    println("History: ${invoker.getHistory()}")
}

Because each command carries its own execute and undo logic, the Invoker can manage any type of command uniformly. Adding new commands (bold, indent, find-and-replace, etc.) does not change the Invoker code.

Extending the Command Pattern

The power of the Command pattern comes from the fact that “a request is an object.” Since it is an object, it can be stored, passed around, queued, and logged. Let us look at two representative patterns that leverage these properties.

Macro Commands

You can bundle multiple commands into one and execute them all at once as a macro.

// A macro command that bundles multiple commands into one
class MacroCommand(
    private val commands: List<Command>
) : Command {
    override fun execute() {
        commands.forEach { it.execute() }
    }

    override fun undo() {
        // Undo in reverse order to restore the original state
        commands.reversed().forEach { it.undo() }
    }

    override fun description() = "Macro: [${commands.joinToString(", ") { it.description() }}]"
}

// Example: a macro that types "Hello World!" all at once
val macro = MacroCommand(listOf(
    TypeCommand(editor, 0, "Hello"),
    TypeCommand(editor, 5, " World"),
    TypeCommand(editor, 11, "!")
))
invoker.execute(macro)  // All three commands execute at once
invoker.undo()           // All three commands are undone at once

Command Queues

Instead of executing commands immediately, you can queue them up and execute them later. This is commonly used in task schedulers and asynchronous processing.

// A structure that queues commands and executes them all later
class CommandQueue {
    private val queue = ArrayDeque<Command>()

    fun addCommand(command: Command) {
        queue.addLast(command)
        println("Added to queue: ${command.description()}")
    }

    fun processAll() {
        println("--- Queue processing started (${queue.size} items) ---")
        while (queue.isNotEmpty()) {
            val command = queue.removeFirst()
            command.execute()
            println("Processed: ${command.description()}")
        }
        println("--- Queue processing complete ---")
    }
}

This structure forms the foundation of message queue-based systems. If you think of messages transmitted via Kafka or RabbitMQ as “serialized Command objects,” the architecture makes natural sense.

Observer vs Command

Both patterns are closely related to “events,” but they solve different problems.

CriterionObserverCommand
Core questionWho should be notified when state changes?How should a request be encapsulated?
Direction1:N — one Subject notifies many Observers1:1 — one Invoker executes one Command
CouplingSubject and Observer are loosely connectedInvoker and Command are loosely connected
Execution timingImmediate notification on state changeFlexible: immediate, deferred, queued, etc.
Undo supportGenerally noneA core feature of the pattern
Typical use casesEvent listeners, notification systems, data bindingUndo/Redo, transactions, macros, task queues

In practice, these two patterns are often combined. For example, you can build a structure where executing a Command also triggers notification to Observers. When a user types text (Command execution), the screen updates immediately (Observer notification) while the undo stack records it (Command history management).

Practical Pitfalls

Overusing Observer Creates Debugging Nightmares

The Observer pattern trades loose coupling for harder flow tracing. When you call product.changePrice(45000), it is hard to tell from the code alone which Observers will react. You have to search for all implementations of onPriceChanged in your IDE.

This problem is especially pronounced with Spring’s @EventListener. The event-publishing code and the receiving code are completely decoupled, so finding “who handles this event?” sometimes requires searching the entire project.

The solution is maintaining clear naming conventions and an event catalog. Keeping event classes in a single package and documenting the publishers and subscribers for each event makes tracing much easier.

Command’s Undo Is Harder Than You Think

For simple text editing, Undo is straightforward. But in practice, things get tricky. Commands that call external APIs may be irreversible, and undoing data written to a database requires compensating transactions.

When implementing Undo in the Command pattern, verify two things first. Can this command actually be reversed? And is enough information being stored at execute time to support the undo? Just as DeleteCommand stored the deleted text in deletedText, you must preserve the context needed for undo within the command object.

Key Takeaways


In the next part, we look at two approaches to controlling flow. We examine Template Method, which defines the skeleton of an algorithm, and Chain of Responsibility, which passes requests along a chain.

-> Part 4: Template Method and Chain of Responsibility


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
Design Patterns Part 2 — Strategy and State
Next Post
Design Patterns Part 4 — Template Method and Chain of Responsibility