Singleton Objects: When You Only Need One

Introduction

The singleton pattern ensures that a class has only one instance and provides global access to that instance. In many programming languages, implementing singletons requires careful coding to handle thread safety and lazy initialization. Scala makes this incredibly simple with the object keyword, which creates singleton instances automatically.

Singleton objects in Scala serve multiple purposes: they can act as utility classes, hold constants, provide factory methods, and serve as application entry points. Understanding when and how to use them effectively is essential for writing idiomatic Scala code.

Basic Object Declaration

Simple Singleton Objects

object MathUtils {
  val PI: Double = 3.14159
  val E: Double = 2.71828

  def square(x: Double): Double = x * x
  def cube(x: Double): Double = x * x * x
  def factorial(n: Int): BigInt = {
    require(n >= 0, "Factorial is not defined for negative numbers")
    if (n <= 1) 1 else (2 to n).map(BigInt(_)).product
  }

  def isPrime(n: Int): Boolean = {
    if (n < 2) false
    else if (n == 2) true
    else if (n % 2 == 0) false
    else (3 to math.sqrt(n).toInt by 2).forall(n % _ != 0)
  }
}

// Usage - no instantiation needed
println(MathUtils.PI)              // 3.14159
println(MathUtils.square(5))       // 25.0
println(MathUtils.factorial(5))    // 120
println(MathUtils.isPrime(17))     // true

Objects with State

object Logger {
  private var _logLevel: String = "INFO"
  private val logs: scala.collection.mutable.ListBuffer[String] = 
    scala.collection.mutable.ListBuffer()

  def setLogLevel(level: String): Unit = {
    require(Set("DEBUG", "INFO", "WARN", "ERROR").contains(level), 
           "Invalid log level")
    _logLevel = level
  }

  def logLevel: String = _logLevel

  private def shouldLog(level: String): Boolean = {
    val levels = List("DEBUG", "INFO", "WARN", "ERROR")
    levels.indexOf(level) >= levels.indexOf(_logLevel)
  }

  def debug(message: String): Unit = log("DEBUG", message)
  def info(message: String): Unit = log("INFO", message)
  def warn(message: String): Unit = log("WARN", message)
  def error(message: String): Unit = log("ERROR", message)

  private def log(level: String, message: String): Unit = {
    if (shouldLog(level)) {
      val timestamp = java.time.LocalDateTime.now().toString
      val logEntry = s"[$timestamp] [$level] $message"
      logs += logEntry
      println(logEntry)
    }
  }

  def getLogs: List[String] = logs.toList
  def clearLogs(): Unit = logs.clear()

  def getLogsSince(since: Int): List[String] = {
    logs.takeRight(since).toList
  }
}

// Usage
Logger.setLogLevel("WARN")
Logger.debug("This won't be shown")    // Below current log level
Logger.info("This won't be shown")     // Below current log level
Logger.warn("This is a warning")       // Will be shown
Logger.error("This is an error")       // Will be shown

println(s"Recent logs: ${Logger.getLogsSince(2)}")

Configuration Objects

object AppConfig {
  private val config: scala.collection.mutable.Map[String, String] = 
    scala.collection.mutable.Map()

  // Initialize with default values
  locally {
    config("app.name") = "MyScalaApp"
    config("app.version") = "1.0.0"
    config("database.host") = "localhost"
    config("database.port") = "5432"
    config("cache.enabled") = "true"
    config("cache.ttl") = "3600"
  }

  def get(key: String): Option[String] = config.get(key)

  def getString(key: String, default: String = ""): String = 
    config.getOrElse(key, default)

  def getInt(key: String, default: Int = 0): Int = 
    config.get(key).map(_.toInt).getOrElse(default)

  def getBoolean(key: String, default: Boolean = false): Boolean = 
    config.get(key).map(_.toBoolean).getOrElse(default)

  def set(key: String, value: String): Unit = {
    config(key) = value
  }

  def load(properties: Map[String, String]): Unit = {
    properties.foreach { case (key, value) => config(key) = value }
  }

  def getAllSettings: Map[String, String] = config.toMap

  def printConfig(): Unit = {
    println("Application Configuration:")
    config.toSeq.sortBy(_._1).foreach { case (key, value) =>
      println(f"  $key%-20s = $value")
    }
  }
}

// Usage
println(AppConfig.getString("app.name"))       // MyScalaApp
println(AppConfig.getInt("database.port"))     // 5432
println(AppConfig.getBoolean("cache.enabled")) // true

AppConfig.set("feature.beta", "false")
AppConfig.load(Map(
  "database.host" -> "production-db.example.com",
  "cache.ttl" -> "7200"
))

AppConfig.printConfig()

Companion Objects

Objects Associated with Classes

class BankAccount private(val accountNumber: String, initialBalance: Double) {
  private var _balance: Double = initialBalance

  def balance: Double = _balance

  def deposit(amount: Double): Boolean = {
    if (amount > 0) {
      _balance += amount
      true
    } else {
      false
    }
  }

  def withdraw(amount: Double): Boolean = {
    if (amount > 0 && amount <= _balance) {
      _balance -= amount
      true
    } else {
      false
    }
  }

  def transfer(to: BankAccount, amount: Double): Boolean = {
    if (withdraw(amount)) {
      to.deposit(amount)
      true
    } else {
      false
    }
  }

  override def toString: String = s"Account($accountNumber, balance: $$${_balance})"
}

// Companion object - same name as the class
object BankAccount {
  private var accountCounter: Int = 1000

  // Factory method
  def create(initialBalance: Double): BankAccount = {
    accountCounter += 1
    new BankAccount(f"ACC$accountCounter%06d", initialBalance)
  }

  // Alternative factory methods
  def createSavings(initialBalance: Double): BankAccount = {
    require(initialBalance >= 100, "Savings account requires minimum $100")
    val account = create(initialBalance)
    println(s"Savings account created: ${account.accountNumber}")
    account
  }

  def createChecking(initialBalance: Double): BankAccount = {
    require(initialBalance >= 25, "Checking account requires minimum $25")
    val account = create(initialBalance)
    println(s"Checking account created: ${account.accountNumber}")
    account
  }

  // Utility methods
  def isValidAccountNumber(accountNumber: String): Boolean = {
    accountNumber.matches("ACC\\d{6}")
  }

  def generateRandomAccountNumber(): String = {
    val random = scala.util.Random
    f"ACC${random.nextInt(900000) + 100000}%06d"
  }
}

// Usage
val savings = BankAccount.createSavings(500.0)
val checking = BankAccount.createChecking(100.0)

savings.deposit(250.0)
checking.withdraw(30.0)
savings.transfer(checking, 100.0)

println(savings)   // Account(ACC001001, balance: $650.0)
println(checking)  // Account(ACC001002, balance: $170.0)

println(BankAccount.isValidAccountNumber("ACC001001"))  // true
println(BankAccount.generateRandomAccountNumber())      // ACC123456 (random)

Apply Methods for Factory Pattern

case class Email(address: String) {
  require(address.contains("@"), "Invalid email format")
  require(!address.startsWith("@") && !address.endsWith("@"), 
         "Email cannot start or end with @")

  def domain: String = address.split("@")(1)
  def localPart: String = address.split("@")(0)
  def isGmail: Boolean = domain.toLowerCase == "gmail.com"
  def isCorporate: Boolean = !List("gmail.com", "yahoo.com", "hotmail.com", "outlook.com")
    .contains(domain.toLowerCase)
}

object Email {
  // apply method makes object callable like a function
  def apply(address: String): Email = {
    val cleanAddress = address.trim.toLowerCase
    new Email(cleanAddress)
  }

  // Multiple apply methods for different factory patterns
  def apply(localPart: String, domain: String): Email = {
    apply(s"$localPart@$domain")
  }

  // Validation method
  def isValid(address: String): Boolean = {
    try {
      Email(address)
      true
    } catch {
      case _: IllegalArgumentException => false
    }
  }

  // Pattern for common email providers
  def gmail(localPart: String): Email = apply(localPart, "gmail.com")
  def yahoo(localPart: String): Email = apply(localPart, "yahoo.com")
  def outlook(localPart: String): Email = apply(localPart, "outlook.com")

  // Bulk validation
  def validateAll(addresses: List[String]): (List[Email], List[String]) = {
    addresses.foldLeft((List.empty[Email], List.empty[String])) {
      case ((valid, invalid), address) =>
        if (isValid(address)) {
          (valid :+ Email(address), invalid)
        } else {
          (valid, invalid :+ address)
        }
    }
  }
}

// Usage - apply method allows this syntax
val email1 = Email("john.doe@example.com")     // Calls apply method
val email2 = Email("jane", "company.com")      // Calls apply(String, String)
val email3 = Email.gmail("alice.smith")        // Creates alice.smith@gmail.com

println(email1.domain)        // example.com
println(email2.isCorporate)   // true
println(email3.isGmail)       // true

// Bulk validation
val addresses = List(
  "valid@example.com",
  "invalid-email",
  "another@valid.org",
  "@invalid.com"
)

val (validEmails, invalidAddresses) = Email.validateAll(addresses)
println(s"Valid: ${validEmails.map(_.address)}")
println(s"Invalid: $invalidAddresses")

Application Entry Points

Main Methods in Objects

object FileAnalyzer {
  def main(args: Array[String]): Unit = {
    if (args.length != 1) {
      println("Usage: FileAnalyzer <directory-path>")
      sys.exit(1)
    }

    val directoryPath = args(0)
    val directory = new java.io.File(directoryPath)

    if (!directory.exists() || !directory.isDirectory) {
      println(s"Error: '$directoryPath' is not a valid directory")
      sys.exit(1)
    }

    analyzeDirectory(directory)
  }

  private def analyzeDirectory(directory: java.io.File): Unit = {
    val files = Option(directory.listFiles()).getOrElse(Array.empty)

    val (directories, regularFiles) = files.partition(_.isDirectory)
    val totalSize = regularFiles.map(_.length()).sum

    // Group files by extension
    val filesByExtension = regularFiles
      .groupBy(getFileExtension)
      .view.mapValues(_.length)
      .toMap
      .toSeq
      .sortBy(-_._2)

    // Find largest files
    val largestFiles = regularFiles
      .sortBy(-_.length())
      .take(5)

    printReport(directory.getName, directories.length, regularFiles.length, 
              totalSize, filesByExtension, largestFiles)
  }

  private def getFileExtension(file: java.io.File): String = {
    val name = file.getName
    val lastDot = name.lastIndexOf('.')
    if (lastDot > 0) name.substring(lastDot + 1).toLowerCase else "no extension"
  }

  private def formatFileSize(bytes: Long): String = {
    val kb = bytes / 1024.0
    val mb = kb / 1024.0
    val gb = mb / 1024.0

    if (gb >= 1) f"${gb}%.2f GB"
    else if (mb >= 1) f"${mb}%.2f MB"
    else if (kb >= 1) f"${kb}%.1f KB"
    else s"$bytes bytes"
  }

  private def printReport(dirName: String, dirCount: Int, fileCount: Int, 
                         totalSize: Long, filesByExtension: Seq[(String, Int)],
                         largestFiles: Array[java.io.File]): Unit = {
    println(s"=== Directory Analysis: $dirName ===")
    println(f"Directories: $dirCount%,d")
    println(f"Files: $fileCount%,d")
    println(s"Total Size: ${formatFileSize(totalSize)}")
    println()

    if (filesByExtension.nonEmpty) {
      println("Files by Extension:")
      filesByExtension.take(10).foreach { case (ext, count) =>
        println(f"  .$ext%-15s: $count%,d files")
      }
      println()
    }

    if (largestFiles.nonEmpty) {
      println("Largest Files:")
      largestFiles.foreach { file =>
        val size = formatFileSize(file.length())
        println(f"  ${file.getName}%-40s: $size")
      }
    }
  }
}

Using App Trait (Alternative to main)

object WebServerConfig extends App {
  // Command line argument parsing
  if (args.length < 2) {
    println("Usage: WebServerConfig <port> <environment>")
    println("Environment: development | staging | production")
    sys.exit(1)
  }

  val port = args(0).toInt
  val environment = args(1).toLowerCase

  if (!Set("development", "staging", "production").contains(environment)) {
    println("Invalid environment. Use: development, staging, or production")
    sys.exit(1)
  }

  // Configuration based on environment
  val config = environment match {
    case "development" => Map(
      "database.host" -> "localhost",
      "database.name" -> "myapp_dev",
      "cache.enabled" -> "false",
      "log.level" -> "DEBUG"
    )
    case "staging" => Map(
      "database.host" -> "staging-db.company.com",
      "database.name" -> "myapp_staging",
      "cache.enabled" -> "true",
      "log.level" -> "INFO"
    )
    case "production" => Map(
      "database.host" -> "prod-db.company.com",
      "database.name" -> "myapp_prod",
      "cache.enabled" -> "true",
      "log.level" -> "WARN"
    )
  }

  // Generate configuration file
  val configContent = s"""# Web Server Configuration
                         |# Environment: $environment
                         |# Generated: ${java.time.LocalDateTime.now()}
                         |
                         |server.port=$port
                         |server.environment=$environment
                         |
                         |${config.map { case (k, v) => s"$k=$v" }.mkString("\n")}
                         |""".stripMargin

  val configFile = new java.io.File(s"config-$environment.properties")
  val writer = new java.io.PrintWriter(configFile)
  try {
    writer.write(configContent)
    println(s"Configuration written to: ${configFile.getAbsolutePath}")
  } finally {
    writer.close()
  }

  // Display summary
  println(s"""
           |Configuration Summary:
           |Environment: $environment
           |Port: $port
           |Database: ${config("database.host")}/${config("database.name")}
           |Cache: ${config("cache.enabled")}
           |Log Level: ${config("log.level")}
           |""".stripMargin)
}

Advanced Patterns with Objects

Registry Pattern

trait Renderer {
  def render(content: String): String
  def mimeType: String
}

class HTMLRenderer extends Renderer {
  def render(content: String): String = s"<html><body>$content</body></html>"
  def mimeType: String = "text/html"
}

class JSONRenderer extends Renderer {
  def render(content: String): String = s"""{"content": "$content"}"""
  def mimeType: String = "application/json"
}

class XMLRenderer extends Renderer {
  def render(content: String): String = s"<content>$content</content>"
  def mimeType: String = "application/xml"
}

object RendererRegistry {
  private val renderers: scala.collection.mutable.Map[String, Renderer] = 
    scala.collection.mutable.Map()

  // Register default renderers
  locally {
    register("html", new HTMLRenderer)
    register("json", new JSONRenderer)
    register("xml", new XMLRenderer)
  }

  def register(name: String, renderer: Renderer): Unit = {
    renderers(name.toLowerCase) = renderer
    println(s"Registered renderer: $name (${renderer.mimeType})")
  }

  def get(name: String): Option[Renderer] = {
    renderers.get(name.toLowerCase)
  }

  def getOrDefault(name: String): Renderer = {
    get(name).getOrElse(get("html").get) // Default to HTML
  }

  def availableRenderers: List[String] = renderers.keys.toList.sorted

  def renderContent(content: String, format: String): (String, String) = {
    val renderer = getOrDefault(format)
    (renderer.render(content), renderer.mimeType)
  }

  def listRenderers(): Unit = {
    println("Available Renderers:")
    renderers.foreach { case (name, renderer) =>
      println(f"  $name%-10s -> ${renderer.mimeType}")
    }
  }
}

// Usage
RendererRegistry.listRenderers()

val content = "Hello, World!"
val (htmlOutput, htmlMimeType) = RendererRegistry.renderContent(content, "html")
val (jsonOutput, jsonMimeType) = RendererRegistry.renderContent(content, "json")

println(s"HTML ($htmlMimeType): $htmlOutput")
println(s"JSON ($jsonMimeType): $jsonOutput")

// Register custom renderer
class PlainTextRenderer extends Renderer {
  def render(content: String): String = content
  def mimeType: String = "text/plain"
}

RendererRegistry.register("text", new PlainTextRenderer)

Cache Object Pattern

object Cache {
  private val cache: scala.collection.mutable.Map[String, (Any, Long)] = 
    scala.collection.mutable.Map()

  private val defaultTTL: Long = 5 * 60 * 1000 // 5 minutes in milliseconds

  def put[T](key: String, value: T, ttlMs: Long = defaultTTL): Unit = {
    val expiryTime = System.currentTimeMillis() + ttlMs
    cache(key) = (value, expiryTime)
  }

  def get[T](key: String): Option[T] = {
    cache.get(key) match {
      case Some((value, expiryTime)) =>
        if (System.currentTimeMillis() <= expiryTime) {
          Some(value.asInstanceOf[T])
        } else {
          cache.remove(key) // Remove expired entry
          None
        }
      case None => None
    }
  }

  def getOrElse[T](key: String, default: => T): T = {
    get[T](key).getOrElse(default)
  }

  def getOrCompute[T](key: String, compute: => T, ttlMs: Long = defaultTTL): T = {
    get[T](key) match {
      case Some(value) => value
      case None =>
        val computed = compute
        put(key, computed, ttlMs)
        computed
    }
  }

  def remove(key: String): Boolean = {
    cache.remove(key).isDefined
  }

  def clear(): Unit = {
    cache.clear()
  }

  def size: Int = {
    cleanupExpired()
    cache.size
  }

  def keys: Set[String] = {
    cleanupExpired()
    cache.keySet.toSet
  }

  private def cleanupExpired(): Unit = {
    val currentTime = System.currentTimeMillis()
    val expiredKeys = cache.filter { case (_, (_, expiryTime)) =>
      currentTime > expiryTime
    }.keys

    expiredKeys.foreach(cache.remove)
  }

  def stats(): String = {
    cleanupExpired()
    s"Cache Stats: ${cache.size} entries, Keys: ${keys.mkString(", ")}"
  }
}

// Usage example
def expensiveComputation(n: Int): BigInt = {
  println(s"Computing factorial of $n...")
  Thread.sleep(1000) // Simulate expensive operation
  (1 to n).map(BigInt(_)).product
}

// First call - computed and cached
val result1 = Cache.getOrCompute("factorial_10", expensiveComputation(10))
println(s"First call: $result1")

// Second call - retrieved from cache (much faster)
val result2 = Cache.getOrCompute("factorial_10", expensiveComputation(10))
println(s"Second call: $result2")

println(Cache.stats())

// Manual cache operations
Cache.put("user_123", "John Doe", 2000) // 2 second TTL
println(Cache.get[String]("user_123"))

Thread.sleep(2500) // Wait for expiration
println(Cache.get[String]("user_123")) // None - expired

Practical Examples

URL Builder Singleton

object URLBuilder {
  private var baseURL: String = "https://api.example.com"
  private var apiVersion: String = "v1"
  private val defaultHeaders: scala.collection.mutable.Map[String, String] = 
    scala.collection.mutable.Map("Content-Type" -> "application/json")

  def setBaseURL(url: String): URLBuilder.type = {
    baseURL = url.replaceAll("/+$", "") // Remove trailing slashes
    this
  }

  def setAPIVersion(version: String): URLBuilder.type = {
    apiVersion = version
    this
  }

  def addDefaultHeader(key: String, value: String): URLBuilder.type = {
    defaultHeaders(key) = value
    this
  }

  case class URL(
    path: String,
    queryParams: Map[String, String] = Map(),
    headers: Map[String, String] = Map()
  ) {
    def withParam(key: String, value: String): URL = {
      copy(queryParams = queryParams + (key -> value))
    }

    def withParams(params: Map[String, String]): URL = {
      copy(queryParams = queryParams ++ params)
    }

    def withHeader(key: String, value: String): URL = {
      copy(headers = headers + (key -> value))
    }

    def build(): String = {
      val queryString = if (queryParams.nonEmpty) {
        "?" + queryParams.map { case (k, v) => s"$k=$v" }.mkString("&")
      } else ""

      s"$baseURL/$apiVersion/$path$queryString"
    }

    def getAllHeaders(): Map[String, String] = {
      defaultHeaders.toMap ++ headers
    }
  }

  def url(path: String): URL = {
    URL(path.replaceAll("^/+", "")) // Remove leading slashes
  }

  // Predefined endpoints
  def users: URL = url("users")
  def user(id: String): URL = url(s"users/$id")
  def posts: URL = url("posts")
  def post(id: String): URL = url(s"posts/$id")
  def search: URL = url("search")
}

// Usage
URLBuilder
  .setBaseURL("https://jsonplaceholder.typicode.com")
  .setAPIVersion("")  // No version for this API
  .addDefaultHeader("Authorization", "Bearer token123")

val usersURL = URLBuilder.users
  .withParam("page", "2")
  .withParam("limit", "10")
  .build()

val userURL = URLBuilder.user("123")
  .withHeader("Accept", "application/json")
  .build()

val searchURL = URLBuilder.search
  .withParams(Map("q" -> "scala", "type" -> "post"))
  .build()

println(usersURL)  // https://jsonplaceholder.typicode.com/users?page=2&limit=10
println(userURL)   // https://jsonplaceholder.typicode.com/users/123
println(searchURL) // https://jsonplaceholder.typicode.com/search?q=scala&type=post

Summary

In this lesson, you've explored Scala's singleton objects and learned how to use them effectively:

Object Declaration: Creating singleton instances with the object keyword
Companion Objects: Factory methods and utilities associated with classes
Apply Methods: Making objects callable like functions
Application Entry Points: Using main methods and the App trait
Design Patterns: Registry, cache, and configuration patterns
State Management: Handling mutable state in singleton objects

Singleton objects are one of Scala's most powerful features, providing a clean way to implement common patterns without the complexity found in other languages.

What's Next

In the next lesson, we'll explore "Companion Objects: The Perfect Partnership." You'll learn how companion objects and classes work together to provide clean APIs, factory methods, and access to private members.

This will deepen your understanding of one of Scala's most elegant features for organizing code and creating intuitive interfaces.

Ready to explore companion objects? Let's continue!