Skip to main content

Structural Design Patterns for Beginners

Photo by Caspar Camille Rubin on Unsplash


Structural Design Patterns in Object-Oriented Programming (OOP) focus on how classes and objects are composed to form larger structures. These patterns simplify the design by identifying how to realize relationships between entities, improving flexibility and reusability.

In this guide, we will explore the following structural design patterns:

  • Adapter
  • Bridge
  • Composite
  • Decorator
  • Facade
  • Flyweight
  • Proxy

Each pattern is explained with a Kotlin example that can be tested using the Kotlin Playground.

Adapter Pattern

The Adapter pattern allows incompatible interfaces to work together by converting the interface of a class into another interface the client expects.

Example

interface AmericanSocket {
fun plugIn(): String
}

class AmericanAppliance : AmericanSocket {
override fun plugIn() = "Plugged into American socket."
}

interface EuropeanSocket {
fun powerOn(): String
}

class EuropeanAppliance : EuropeanSocket {
override fun powerOn() = "Powered by European socket."
}

class SocketAdapter(private val europeanAppliance: EuropeanAppliance) : AmericanSocket {
override fun plugIn(): String = europeanAppliance.powerOn()
}

fun main() {
val americanAppliance = AmericanAppliance()
println(americanAppliance.plugIn())

val europeanAppliance = EuropeanAppliance()
val adapter = SocketAdapter(europeanAppliance)
println(adapter.plugIn()) // Using adapter to plug European appliance into an American socket
}

Bridge Pattern

The Bridge pattern separates an object’s abstraction from its implementation so the two can vary independently.

Example

interface Device {
fun turnOn(): String
fun turnOff(): String
}

class TV : Device {
override fun turnOn() = "TV turned on"
override fun turnOff() = "TV turned off"
}

class Radio : Device {
override fun turnOn() = "Radio turned on"
override fun turnOff() = "Radio turned off"
}

abstract class RemoteControl(protected val device: Device) {
abstract fun togglePower(): String
}

class BasicRemote(device: Device) : RemoteControl(device) {
private var isOn = false

override fun togglePower(): String {
isOn = !isOn
return if (isOn) device.turnOn() else device.turnOff()
}
}

fun main() {
val tv = TV()
val radio = Radio()

val tvRemote = BasicRemote(tv)
val radioRemote = BasicRemote(radio)

println(tvRemote.togglePower())
println(radioRemote.togglePower())
}

Composite Pattern

The Composite pattern is used to treat individual objects and compositions of objects uniformly. It creates a tree structure where individual objects and groups of objects can be treated the same way.

Example

interface Graphic {
fun draw(): String
}

class Circle : Graphic {
override fun draw() = "Drawing Circle"
}

class Square : Graphic {
override fun draw() = "Drawing Square"
}

class CompositeGraphic : Graphic {
private val graphics = mutableListOf<Graphic>()

fun add(graphic: Graphic) = graphics.add(graphic)

override fun draw(): String {
return graphics.joinToString(", ") { it.draw() }
}
}

fun main() {
val circle = Circle()
val square = Square()

val composite = CompositeGraphic()
composite.add(circle)
composite.add(square)

println(composite.draw())
}

Decorator Pattern

The Decorator pattern allows behavior to be added to individual objects, either statically or dynamically, without affecting the behavior of other objects from the same class.

Example

interface Coffee {
fun cost(): Double
}

class SimpleCoffee : Coffee {
override fun cost() = 2.0
}

class MilkDecorator(private val coffee: Coffee) : Coffee {
override fun cost() = coffee.cost() + 0.5
}

class SugarDecorator(private val coffee: Coffee) : Coffee {
override fun cost() = coffee.cost() + 0.3
}

fun main() {
val coffee = SimpleCoffee()
val coffeeWithMilk = MilkDecorator(coffee)
val coffeeWithMilkAndSugar = SugarDecorator(coffeeWithMilk)

println("Cost of plain coffee: ${coffee.cost()}")
println("Cost of coffee with milk: ${coffeeWithMilk.cost()}")
println("Cost of coffee with milk and sugar: ${coffeeWithMilkAndSugar.cost()}")
}

Facade Pattern

The Facade pattern provides a simplified interface to a complex subsystem, making it easier for the client to interact with the system.

Example

class CPU {
fun start() = "CPU started"
}

class Memory {
fun load() = "Memory loaded"
}

class HardDrive {
fun read() = "Hard drive read"
}

class ComputerFacade(private val cpu: CPU, private val memory: Memory, private val hardDrive: HardDrive) {
fun startComputer(): String {
return "${cpu.start()}, ${memory.load()}, ${hardDrive.read()}"
}
}

fun main() {
val computer = ComputerFacade(CPU(), Memory(), HardDrive())

println(computer.startComputer())
}

Flyweight Pattern

The Flyweight pattern reduces memory usage by sharing objects instead of creating new instances. This is useful when many objects are needed, but they share a common state.

Example

class Shape(val type: String) {
fun draw(): String = "Drawing $type"
}

class ShapeFactory {
private val shapes = mutableMapOf<String, Shape>()

fun getShape(type: String): Shape {
return shapes.getOrPut(type) { Shape(type) }
}
}

fun main() {
val factory = ShapeFactory()

val circle1 = factory.getShape("Circle")
val circle2 = factory.getShape("Circle")
val square = factory.getShape("Square")

println(circle1.draw())
println(circle2.draw())
println("Are circle1 and circle2 the same instance? ${circle1 === circle2}")
println(square.draw())
}

Proxy Pattern

The Proxy pattern provides a substitute or placeholder for another object, controlling access to it. Proxies are often used for lazy initialization or access control.

Example

interface Image {
fun display(): String
}

class RealImage(private val filename: String) : Image {
init {
println("Loading $filename")
}

override fun display() = "Displaying $filename"
}

class ProxyImage(private val filename: String) : Image {
private var realImage: RealImage? = null

override fun display(): String {
if (realImage == null) {
realImage = RealImage(filename)
}
return realImage!!.display()
}
}

fun main() {
val proxyImage = ProxyImage("photo.png")

println(proxyImage.display()) // Loads and displays the image
println(proxyImage.display()) // Just displays the image without loading
}

Conclusion

In this guide, we explored the following Structural Design Patterns:

  1. Adapter: Helps incompatible interfaces work together.
  2. Bridge: Decouples an abstraction from its implementation.
  3. Composite: Composes objects into tree structures and treats them uniformly.
  4. Decorator: Dynamically adds responsibilities to objects.
  5. Facade: Simplifies interactions with complex subsystems.
  6. Flyweight: Optimizes memory usage by sharing objects.
  7. Proxy: Controls access to another object.

Each of these patterns addresses a different structural need, making your code more flexible, reusable, and easier to maintain. Test these examples in a Kotlin playground to get a better understanding of how they work!

Comments

Popular posts from this blog

Understanding Number Systems: Decimal, Binary, and Hexadecimal

In everyday life, we use numbers all the time, whether for counting, telling time, or handling money. The number system we’re most familiar with is the   decimal system , but computers use other systems, such as   binary   and   hexadecimal . Let’s break down these number systems to understand how they work. What is a Number System? A number system is a way of representing numbers using a set of symbols and rules. The most common number systems are: Decimal (Base 10) Binary (Base 2) Hexadecimal (Base 16) Each system has a different “base” that tells us how many unique digits (symbols) are used to represent numbers. Decimal Number System (Base 10) This is the system we use daily. It has  10 digits , ranging from  0 to 9 . Example: The number  529  in decimal means: 5 × 1⁰² + 2 × 1⁰¹ + 9 × 1⁰⁰ =  500 + 20 + 9 = 529 Each position represents a power of 10, starting from the rightmost digit. Why Base 10? Decimal is base 10 because it has 10 digits...

How to Monetize Your API as an Individual Developer While Hosting on Your Own Server?

In the API economy, cloud services like AWS, Google Cloud, and Azure offer many conveniences, such as scaling and infrastructure management. However, some developers prefer more control and autonomy, opting to host their APIs on personal servers. Whether for cost efficiency, data privacy, or customization, hosting your own API comes with both advantages and challenges. But, even without cloud platforms, there are effective ways to monetize your API. This guide will explore how individual developers can successfully monetize their APIs while hosting them on their own servers. Why Host Your API on Your Own Server? Hosting your own API gives you full control over the infrastructure and potentially lower long-term costs. Here’s why some developers choose this approach: Cost Control : Instead of paying ongoing cloud fees, you may opt for a one-time or lower-cost hosting solution that fits your budget and resource needs. Data Ownership : You have full control over data, which is critical if ...

API Testing with Jest and Supertest: A Step-by-Step Guide

API testing is essential to ensure your endpoints behave as expected across all scenarios. In this guide, we’ll explore how to use Jest and Supertest to test a sample API with various response types, including success, authentication errors, and validation errors. By the end, you’ll understand how to apply these tools to check for different response structures and status codes. 0. Prerequisites: Setting Up Your Environment Before diving into API testing, it’s important to ensure that your development environment is properly set up. Here’s what you need to do: Step 1: Install Node.js and npm Node.js  is a JavaScript runtime that allows you to run JavaScript code on the server side. It comes with  npm  (Node Package Manager), which helps you install and manage packages. Installation Steps: Download and install Node.js from the  official website . To verify the installation, open your terminal and run: node -v npm -v This should display the installed versions of Node.js...

The Weight of Responsibility: A Developer’s Journey to Balance Passion and Reality

For the past several years, Eddie has been on a steady climb in his career as a developer, but recently, he found himself at a crossroads — caught between the weight of his responsibilities and the desire to pursue his true passions. His journey began with a three-month internship as a web developer, which led to nearly four years in an application developer role. After that, he spent almost a year as a systems associate, managing tasks across systems analysis, quality assurance, and business analysis. Eventually, he returned to full-time software development for another two years before transitioning into more complex roles. For over a year, he worked as a multi-role software developer and database administrator before stepping into his current position as a senior software developer, database administrator, and cloud administrator — occasionally handling security tasks as well. Now, with over 8 years of professional experience, he also leads a small team of developers, which has been...

Avoiding Confusion in API Design: The Importance of Clear Responses

In today’s fast-paced software development landscape, APIs play a crucial role in connecting services and enabling functionality. However, poor design choices can lead to confusion and inefficiency for both developers and users. One such choice is the omission of a response body for successful requests, a practice I recently encountered in an enterprise API designed for bill payments. The Case of the No-Response API The API in question serves two main endpoints: one for inquiring about account validity and another for confirming payment. When successful, the API returned a  200 OK  status but no response body. This design choice led to significant confusion during our integration process. Even the internal team who developed the said API struggled to justify this approach, revealing a lack of clarity around the rationale behind it. Pros of This Design Choice While the intention behind this design may have been to streamline responses, several potential benefits can be identifi...