Structural Design Patterns in Scala

Saurav Paul
6 min readJul 15, 2023

--

Photo by Teo D on Unsplash

In the object-oriented world, we always strive to write code & package it a way that uses the four principles of OO design (abstraction, inheritance, encapsulation, and polymorphism). To help us achieve this when composing our classes and objects, we can use one of the structural design patterns that focus on the following key areas.

  1. providing a uniform abstraction of different interfaces ( Adapter)
  2. changing the composition at runtime and providing flexibility of object composition
  3. Ensuring efficiency and consistency by sharing objects
  4. Adding object responsibility dynamically

Structural design patterns are a category of design patterns in software development that focus on the organization, composition, and relationships between classes and objects. These patterns provide solutions for creating larger structures from individual components in a flexible and reusable manner.

They mostly help address design problems related to the composition and interaction of classes and objects, rather than focusing on the algorithms or behaviors of individual components. These patterns aim to simplify the design, enhance code readability, promote code reuse, and improve the overall maintainability of the software system.

There are several commonly used structural design patterns like (adapter, bridge, composite, decorator, facade, flyweight and proxy). In this article, I will discuss adapter, bridge and decorator patterns and will discuss the remaining ones on a follow up article. Link to part 2 here

Lets quickly understand these patterns one by one along with an example code in Scala

1. Adapter Pattern: The Adapter pattern allows objects with incompatible interfaces to work together by providing a common interface. It acts as a bridge between two incompatible interfaces, translating one interface into another. These pattern can be useful when integrating two different components written independently.

In the Adapter pattern, there are three main components:
- Target: This represents the desired interface that the client code expects to work with.
- Adaptee: This is the existing interface that needs to be adapted to the target interface.
- Adapter: This is the class that implements the target interface and internally wraps the adaptee, translating its methods into the target interface.

In the below example code, We have a MediaPlayer trait representing the target interface and an AdvancedMediaPlayer trait representing the existing interface.
The VlcPlayer and Mp4Player classes are concrete implementations of the AdvancedMediaPlayer trait, representing the existing classes with incompatible interfaces.
The MediaAdapter class implements the MediaPlayer trait and internally wraps an instance of the AdvancedMediaPlayer. It translates the play method calls into the appropriate methods of the adaptee.

// Target interface
trait MediaPlayer {
def play(filename: String): Unit
}

// Adaptee interface
trait AdvancedMediaPlayer {
def playVlc(filename: String): Unit
def playMp4(filename: String): Unit
}

// Concrete Adaptee implementation
class VlcPlayer extends AdvancedMediaPlayer {
def playVlc(filename: String): Unit = {
println(s"Playing vlc file: $filename")
}

def playMp4(filename: String): Unit = {
// Do nothing
}
}

// Concrete Adaptee implementation
class Mp4Player extends AdvancedMediaPlayer {
def playVlc(filename: String): Unit = {
// Do nothing
}

def playMp4(filename: String): Unit = {
println(s"Playing mp4 file: $filename")
}
}

// Adapter implementation
class MediaAdapter(advancedPlayer: AdvancedMediaPlayer) extends MediaPlayer {
def play(filename: String): Unit = {
if (filename.endsWith(".vlc")) {
advancedPlayer.playVlc(filename)
} else if (filename.endsWith(".mp4")) {
advancedPlayer.playMp4(filename)
}
}
}

// Concrete Target implementation
class AudioPlayer(mediaAdapter: MediaPlayer) extends MediaPlayer {
def play(filename: String): Unit = {
if (filename.endsWith(".mp3")) {
println(s"Playing mp3 file: $filename")
} else {
mediaAdapter.play(filename)
}
}
}

// Client code
case object ClientCodeObject extends App {
val vlcPlayer = new VlcPlayer()
val mp4Player = new Mp4Player()
val mediaAdapter = new MediaAdapter(vlcPlayer)
val audioPlayer = new AudioPlayer(mediaAdapter)

audioPlayer.play("song.mp3")
audioPlayer.play("movie.vlc")
audioPlayer.play("movie.mp4")

}

In the client code above, we create instances of the VlcPlayer, Mp4Player, MediaAdapter, and AudioPlayer. We invoke the play method on the AudioPlayer with different file names. The adapter pattern allows the AudioPlayer to play both mp3 files (handled directly) and other formats like vlc and mp4 (handled via the adapter).

In this way, the adapter pattern facilitates the interaction between objects with incompatible interfaces, providing a common interface for seamless integration.

2. Bridge Pattern: The Bridge pattern is a design pattern that helps us decouple an abstraction from its implementation. It allows us to create two separate class hierarchies, one for the abstraction and another for the implementation, and connect them using a bridge.

In simpler terms, imagine you have different types of shapes (e.g., circle, square) and different types of colours (e.g., red, blue). Instead of creating separate classes for each combination of shape and colour, the Bridge pattern suggests having separate hierarchies for shapes and colours. The shape hierarchy represents the various shapes, and the colour hierarchy represents the various colours.

The below example in Scala demonstrates the bridge pattern.

The bridge acts as a link between the shape and the colour. Each shape class has a reference to a colour object, and the colour object can be easily changed without modifying the shape classes. This allows us to create different combinations of shapes and colours dynamically.

// Implementor hierarchy
trait Color {
def fill(): Unit
}

class RedColor extends Color {
def fill(): Unit = {
println("Filling with red color.")
}
}

class BlueColor extends Color {
def fill(): Unit = {
println("Filling with blue color.")
}
}

// Abstraction hierarchy
abstract class Shape(color: Color) {
def draw(): Unit
}

class Circle(color: Color) extends Shape(color) {
def draw(): Unit = {
println("Drawing a circle.")
color.fill()
}
}

class Square(color: Color) extends Shape(color) {
def draw(): Unit = {
println("Drawing a square.")
color.fill()
}
}

case object ShapeColor extends App {
// Client code
val redCircle = new Circle(new RedColor)
redCircle.draw()

val blueSquare = new Square(new BlueColor)
blueSquare.draw()
}

The Bridge pattern helps us avoid creating a large number of classes and allows us to add new shapes or colours independently. It promotes flexibility and scalability in our code by keeping the abstraction and implementation separate, enabling us to vary them independently.

By understanding and applying the Bridge pattern, we can create more modular and maintainable code, making it easier to extend and adapt our software in the future.

3. Composite Pattern: The Composite pattern allows you to treat a group of objects in the same way as you would treat an individual object. It composes objects into tree-like structures to represent part-whole hierarchies.

The main components of the Composite pattern are:

  1. Component: This is the common interface or abstract class that represents both the individual objects and the composite objects. It defines the common operations that can be performed on the objects, whether they are leaf objects (individual objects) or composite objects (groups of objects).
  2. Leaf: This represents the individual objects that don’t have any children. They implement the operations defined by the Component interface.
  3. Composite: This represents the composite objects, which are containers that can hold leaf objects as well as other composite objects. They implement the operations defined by the Component interface but also provide additional operations to manage the child objects.

For example, in the below code , we have a Song class with MusicComponent as trait acting as the common interface and playList class acting as the composite class which also implements the MusicComponent interface. Thus both a Song and playList can be played while the playList acts as a group of songs.

// Component trait
trait MusicComponent {
def play(): Unit
}

// Leaf class
class Song(name: String) extends MusicComponent {
def play(): Unit = {
println(s"Playing song: $name")
}
}

// Composite class
class Playlist(name: String) extends MusicComponent {
private var components: List[MusicComponent] = List.empty

def add(component: MusicComponent): Unit = {
components = component :: components
}

def remove(component: MusicComponent): Unit = {
components = components.filterNot(_ == component)
}

def play(): Unit = {
println(s"Playing playlist: $name")
components.foreach(_.play())
}
}

// Client code
case object CompositPattern extends App {
val song1 = new Song("Song 1")
val song2 = new Song("Song 2")
val song3 = new Song("Song 3")

val playlist1 = new Playlist("Playlist 1")
playlist1.add(song1)
playlist1.add(song2)

val playlist2 = new Playlist("Playlist 2")
playlist2.add(song3)
playlist2.add(playlist1)

playlist2.play()
}

The Composite pattern allows us to treat individual songs and playlists uniformly. We can add, remove, or perform operations on any component within the hierarchy without needing to differentiate between them. This pattern is useful when we want to represent hierarchical structures, such as playlists containing songs, and perform operations on both individual songs and entire playlists.

In the next article, I will go over the remaining structural design patterns. Hope you find these examples useful. Checkout part 2 — here

--

--