Applying Behavioral Patterns in Scala

Introduction

Welcome to the fourth and final lesson of our course about Behavioral Patterns in Scala! 🎉 In this final lesson, we'll explore how to apply the behavioral design patterns you've learned about — specifically the Command, Observer, and Strategy patterns — to a real-world scenario by designing a simple chat application. We’ve covered each pattern individually in previous lessons, but now we'll combine them to build an interactive and dynamic application, showcasing the power of collaboration between these patterns. Let's dive in!

Recap of Behavioral Design Patterns

Before we begin designing our chat application, let's quickly revisit the key behavioral design patterns we'll be using:

  • Command Pattern: Encapsulates a request as an object, allowing clients to parameterize and queue requests, and support undoable operations.
  • Observer Pattern: Defines a one-to-many dependency between objects so that when one object changes state, all its dependents are notified and updated automatically.
  • Strategy Pattern: Defines a family of algorithms, encapsulates each one, and makes them interchangeable. The strategy lets the algorithm vary independently from the clients that use it; that is, clients can choose the algorithm to use at runtime.

These patterns help create flexible and loosely coupled designs, enabling us to address complex programming challenges effectively.

Designing the Chat Application

Our goal is to build a simple chat application where users can send messages to a chat room, and all registered users receive notifications of new messages. We'll use the Command pattern to encapsulate message sending, the Observer pattern for user notifications, and the Strategy pattern to process messages differently (e.g., plain text or encrypted).

Let's see how each pattern fits into our application:

  • Command Pattern: We'll create command objects to represent user actions (sending messages).
  • Observer Pattern: Users will subscribe to the chat room to receive messages.
  • Strategy Pattern: We'll process messages using different strategies before broadcasting.

Strategy Pattern: The `MessageProcessor`

First, to allow different ways of processing messages, we implement the Strategy pattern through a MessageProcessor trait and concrete implementations:

// Strategy trait for processing messages
trait MessageProcessor:
  def processMessage(message: String): String

// Concrete strategy for plain text processing
class PlainTextProcessor extends MessageProcessor:
  def processMessage(message: String): String = message

// Concrete strategy for encrypted message processing
class EncryptedProcessor extends MessageProcessor:
  def processMessage(message: String): String = message.reverse
  • The PlainTextProcessor returns the message as is.
  • The EncryptedProcessor reverses the message to simulate encryption.

This Strategy pattern allows us to change the message processing algorithm at runtime, providing flexibility in how messages are handled.

In other words, by using the Strategy pattern we can introduce new message processing algorithms without modifying existing code; this promotes the Open/Closed Principle, one of the SOLID principles, making our application more maintainable and extensible.

Observer Pattern: The `Observer` Trait and the `User` Class

Observer Pattern: The `Subject` Trait and the `ChatRoom` Class

The ChatRoom class serves as the subject in the Observer pattern. It maintains a list of observers (the users) and notifies them of new messages. We'll define a Subject trait to represent this role, and a ChatRoom class implementing this trait:

// Subject trait defining methods for managing observers
trait Subject:
  def addObserver(observer: Observer): Unit
  def removeObserver(observer: Observer): Unit
  def notifyObservers(message: String): Unit

// ChatRoom class acting as the subject in Observer pattern
class ChatRoom extends Subject:
  private var observers = List[Observer]()

  // Register a new observer
  def addObserver(observer: Observer): Unit =
    observers = observer :: observers

  // Remove an observer
  def removeObserver(observer: Observer): Unit =
    observers = observers.filterNot(_ == observer)

  // Notify all observers with a message
  def notifyObservers(message: String): Unit =
    observers.foreach(_.update(message))

  // Display a message in the chat room
  def showMessage(message: String): Unit =
    println(s"ChatRoom displays: $message")

The addObserver and removeObserver methods manage the list of subscribed users, while notifyObservers sends updates to all users.

By implementing the Subject interface, ChatRoom clearly defines its observable behavior. This abstraction encourages reuse and simplifies the management of observers, contributing to a cleaner and more maintainable codebase.

Command Pattern: The `Command` Trait

At this point, we're missing only the Command pattern. We'll define the Command trait, which encapsulates actions as objects. This trait serves as the base for all command objects in our application:

// Command trait representing an executable action
trait Command:
  def execute(): Unit

In our chat application, commands will represent actions like sending a message.

Command Pattern: The `ChatCommand` Class

Now, we'll create the ChatCommand class, which implements the Command trait. This class encapsulates the action of sending a message to the chat room.

// Command class encapsulating the action of sending a message
class ChatCommand(chatRoom: ChatRoom, message: String, processor: MessageProcessor) extends Command:
  def execute(): Unit =
    // Process the message using the strategy
    val processedMessage = processor.processMessage(message)
    // Display the message in the chat room
    chatRoom.showMessage(processedMessage)
    // Notify all observers with the processed message
    chatRoom.notifyObservers(processedMessage)

In the execute method, we process the message using the MessageProcessor strategy before displaying it and notifying observers.

The Command pattern decouples the object that invokes the operation from the one that knows how to perform it. This separation allows for greater flexibility in extending and maintaining the application, such as adding new commands or features without modifying existing code.

Putting It All Together: The Main Application

Finally, we'll integrate all components in our main function, demonstrating how the patterns work together.

// Main application integrating all patterns
@main def main(): Unit =
  val chatRoom = ChatRoom()

  // Create users and register them as observers
  val user1 = User("Alice")
  val user2 = User("Bob")

  chatRoom.addObserver(user1)
  chatRoom.addObserver(user2)

  // Choose the message processing strategy
  val processor: MessageProcessor = EncryptedProcessor()

  // Create a command to send a message
  val chatCommand: Command = ChatCommand(chatRoom, "Hello, everyone!", processor)

  // Execute the command
  chatCommand.execute()

In the main application:

  • We create a ChatRoom instance, which acts as the subject in the Observer pattern.
  • We create two User instances and add them as observers to the chat room.
  • We choose a MessageProcessor strategy (EncryptedProcessor in this case), demonstrating the Strategy pattern.
  • We create a ChatCommand, encapsulating the action of sending a message, following the Command pattern.
  • We execute the command, which processes the message, displays it, and notifies all users.

Conclusion

By integrating the Command, Observer, and Strategy patterns, we've built a simple yet effective chat application. This demonstrates how combining design patterns can create modular, flexible, and maintainable software solutions. Understanding how these patterns interact helps in designing systems that are easy to extend and adapt to new requirements. The use of these patterns:

  • Enhances Flexibility: The application can easily switch strategies for processing messages or extend with new commands.
  • Promotes Maintainability: Clear separation of concerns makes the codebase easier to understand and modify.
  • Facilitates Scalability: Adding new users or message processing algorithms requires minimal changes.

Keep experimenting with these patterns to strengthen your Scala programming skills! 🦾

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal