Scala Programming Guidesscala-basicsscala-tutorial

Data Modeling with Classes and Objects | Scala Programming Guide

By Dmitri Meshin
Picture of the author
Published on
Data Modeling with Classes and Objects - Scala Programming Guide

Classes with Fields and Methods

Classes are blueprints for objects. Scala classes are more concise than Java's, yet powerful. A class encapsulates data (fields) and behavior (methods) into a cohesive unit.

// A simple class representing a music track
class Track(titleInput: String, artistInput: String, durationInput: Int) {
  // Fields hold data
  val title: String = titleInput
  val artist: String = artistInput
  val durationSeconds: Int = durationInput

  // Methods operate on that data
  def durationMinutes: Double = durationSeconds / 60.0

  def displayInfo(): String = {
    f"$title by $artist (${durationMinutes}%.1f minutes)"
  }
}

// Create an instance
val track = new Track("Midnight City", "M83", 244)
println(track.displayInfo())
// "Midnight City by M83 (4.1 minutes)"

This works, but we're repeating ourselves—the constructor parameters are copied into fields. Scala has a cleaner way:

// Scala style: parameters are implicitly fields if declared as val/var
class TrackScala(val title: String, val artist: String, val durationSeconds: Int) {
  // No need to redeclare fields; title, artist, durationSeconds are automatically available

  def durationMinutes: Double = durationSeconds / 60.0

  def displayInfo(): String = {
    f"$title by $artist (${durationMinutes}%.1f minutes)"
  }
}

val track = new TrackScala("Neon Dreams", "The Midnight", 238)
track.title      // "Neon Dreams" — automatically accessible
track.artist     // "The Midnight"

When you declare a constructor parameter with val or var, Scala automatically creates a field. No boilerplate needed. This is called a "constructor parameter" or "primary constructor." The entire class signature is the constructor—when you call new TrackScala(...), you're invoking the primary constructor with those parameters, and the fields are initialized automatically.

This is the primary constructor. Parameters with val become immutable fields. Use var if the field must be mutable (rare):

class MutableTrack(var title: String, val artist: String) {
  // title can be reassigned; artist cannot
}

val track = new MutableTrack("Original Title", "Artist")
track.title = "New Title"  // OK
// track.artist = "Other Artist"  // ERROR: val cannot be reassigned

Marking a field as var makes it mutable—code can reassign it. This is a code smell in most cases; make it clear that the field changes. By default, use val (immutable). If you need to reassign, make it var and be aware that concurrent access becomes tricky.

Primary Constructors and Auxiliary Constructors

The primary constructor is what we just saw—the parameters in the class signature. You can add additional constructors for convenience:

class Playlist(val name: String, val maxSize: Int) {
  // The parameter names and bodies define the primary constructor
  // This code runs when you call new Playlist(...)
  println(s"Created playlist: $name with max size $maxSize")

  // An auxiliary constructor (secondary constructor)
  def this(name: String) = {
    this(name, 100)  // Must call another constructor (primary or another auxiliary)
  }

  // Another auxiliary constructor
  def this() = {
    this("Untitled", 50)
  }
}

val p1 = new Playlist("Synthwave", 200)
// "Created playlist: Synthwave with max size 200"

val p2 = new Playlist("Electronic")
// "Created playlist: Electronic with max size 100"

val p3 = new Playlist()
// "Created playlist: Untitled with max size 50"

Auxiliary constructors allow flexibility in object creation. Each must call another constructor (via this(...)), which eventually calls the primary constructor. This ensures all fields are properly initialized.

Multiple constructors are useful, but in modern Scala, default parameters are often cleaner and more flexible:

// This replaces all three constructors above:
class PlaylistModern(val name: String = "Untitled", val maxSize: Int = 50) {
  println(s"Created playlist: $name with max size $maxSize")
}

val p1 = new PlaylistModern("Synthwave", 200)
val p2 = new PlaylistModern("Electronic")
val p3 = new PlaylistModern()

With default parameters, you get the same flexibility without the boilerplate. Each parameter can have a default value, and callers can override whichever they need. This is often superior to auxiliary constructors because it's more flexible and concise.

Companion Objects and apply()

Every class in Scala can have a companion object—a singleton with the same name, typically used as a factory. The companion object can be defined in the same file as the class and can access private members of the class.

class Artist(val name: String, val yearsActive: Int)

// Companion object
object Artist {
  // Factory method: users don't need the 'new' keyword
  def apply(name: String, yearsActive: Int): Artist = {
    new Artist(name, yearsActive)
  }
}

// Users can create instances naturally:
val artist1 = Artist("M83", 25)           // Uses apply() implicitly
val artist2 = new Artist("M83", 25)       // Explicit: also works

// apply() often does more than just construction:
object Artist {
  def apply(name: String): Artist = {
    // Fetch yearsActive from a database or API
    new Artist(name, 15)  // Default value
  }

  def apply(csv: String): Artist = {
    val parts = csv.split(",")
    new Artist(parts(0), parts(1).toInt)
  }
}

val artist = Artist("M83")              // Uses single-parameter version
val artist = Artist("M83,25")           // Uses CSV parsing version

The apply() method is special in Scala—when you call Artist(...), Scala translates it to Artist.apply(...). This convention allows you to use classes like functions, making the API natural. The companion object can have multiple apply() methods (method overloading), each handling different input types or performing different initialization logic.

Why is this powerful? You can encapsulate object creation logic. Instead of callers constructing objects directly (which exposes implementation details), they use the factory methods. The factory can validate input, fetch related data, or transform the construction process. If you later need to change how objects are created, you can modify the factory without changing all call sites.

Case classes (covered in Chapter 8) automatically generate companion objects with smart apply() methods, so you often don't need to write them manually.

Singleton Objects

An object without a companion class is a singleton—a single instance that exists throughout the program. Singletons replace static members from Java. They're useful for global state, configuration, and utilities:

// A global music database
object MusicDatabase {
  // Private mutable state (package-private)
  private val genres = scala.collection.mutable.Map[String, Int](
    "synthwave" -> 150,
    "synthpop" -> 120
  )

  // Public methods to interact with it
  def getPopularity(genre: String): Int = {
    genres.getOrElse(genre, 0)
  }

  def addGenre(genre: String, popularity: Int): Unit = {
    genres(genre) = popularity
  }
}

// Use it like a namespace
println(MusicDatabase.getPopularity("synthwave"))  // 150
MusicDatabase.addGenre("vaporwave", 80)
println(MusicDatabase.getPopularity("vaporwave"))  // 80

// Only one instance ever exists
val db1 = MusicDatabase
val db2 = MusicDatabase
db1 eq db2  // true — same object in memory

A singleton object is instantiated exactly once, on first use. The JVM ensures thread-safe initialization. You access its members via the object name, like MusicDatabase.getPopularity(...). Internally, the state is hidden in private fields, and you interact via public methods. This encapsulation is powerful: you can change the implementation without changing the public interface.

Singletons replace static methods from Java. They're great for:

  • Global configuration
  • Factory functions
  • Utilities and helpers
  • Database connections
  • Logging facilities

Abstract Classes

Abstract classes define interfaces that subclasses must implement. They can have abstract methods (no implementation) and concrete methods (with implementation). This is useful for defining a common interface that multiple subclasses will implement.

// An abstract class for audio sources
abstract class AudioSource {
  // Abstract method (no implementation)
  def play(): String

  def pause(): String

  // Concrete method (has implementation)
  def describe(): String = {
    s"This is an audio source: ${play()}"
  }
}

// A concrete subclass must implement abstract methods
class StreamingService(val name: String) extends AudioSource {
  def play(): String = {
    s"Playing from $name"
  }

  def pause(): String = {
    s"Paused on $name"
  }
}

// Use it
val spotify = new StreamingService("Spotify")
println(spotify.play())      // "Playing from Spotify"
println(spotify.describe())  // "This is an audio source: Playing from Spotify"

// Can't instantiate abstract class directly:
// val source = new AudioSource()  // ERROR: cannot instantiate abstract class

Abstract classes define a contract: subclasses must implement abstract methods. They can also provide concrete methods that subclasses inherit. This is useful for sharing code across a family of related classes while enforcing a common interface.

When you extend an abstract class, you inherit the concrete methods automatically. You must implement the abstract methods—the compiler will error if you don't. This ensures all subclasses have consistent behavior for critical operations.

Access Modifiers: private, protected, package-private

Control what code can access what using access modifiers:

class Playlist(val name: String) {
  // Private: only this class can access
  private val tracks = scala.collection.mutable.ListBuffer[String]()

  // Protected: this class and subclasses can access
  protected val metadata = scala.collection.mutable.Map[String, String]()

  // Public (default): anyone can access
  def addTrack(title: String): Unit = {
    tracks += title
  }

  // Private method: only internal use
  private def validateTrackTitle(title: String): Boolean = {
    title.nonEmpty && title.length < 200
  }

  def getTrackCount: Int = tracks.size
}

val playlist = new Playlist("Night Driving")
playlist.addTrack("Midnight City")
println(playlist.name)        // OK: public field
println(playlist.getTrackCount)  // OK: public method
// println(playlist.tracks)    // ERROR: private
  • Private: Only this class can access. Use for internal implementation details.
  • Protected: This class and its subclasses can access. Use for things subclasses need to customize.
  • Public (default): Anyone can access. This is your public API.

Package-private (no modifier) is the default:

// File: com/music/Player.scala
package com.music

class Player {
  // Accessible to anything in the com.music package
  val volume = 100
}

// File: com/music/Equalizer.scala
package com.music

class Equalizer {
  val player = new Player()
  println(player.volume)  // OK: same package
}

// File: com/other/Main.scala
package com.other

class Main {
  val player = new com.music.Player()
  // println(player.volume)  // ERROR: package-private
}

Fields and methods with no modifier are accessible within their package. This is useful for grouping related classes in a package and sharing implementation details without exposing them publicly.

Lazy val Fields

Lazy fields aren't evaluated until first accessed. This is useful for expensive initialization:

class User(val name: String, val userId: Int) {
  // This query doesn't run until someone accesses this field
  lazy val preferences = {
    println("Fetching preferences from database...")
    scala.io.Source.fromFile(s"prefs/$userId.txt").mkString
  }
}

val user = new User("Alice", 42)
// Preferences not fetched yet

println(user.name)  // "Alice"
// Still not fetched

val prefs = user.preferences
// NOW the lazy field is evaluated
// "Fetching preferences from database..."
// And prefs contains the loaded preferences

val prefs2 = user.preferences
// Already computed, returns cached value immediately

Lazy fields are evaluated once, on first access, and the result is cached. Subsequent accesses return the cached value without re-computing. This is thread-safe in Scala—the JVM ensures thread-safe initialization of lazy fields.

Lazy evaluation is perfect for:

  • Database queries (compute only if needed)
  • File I/O (defer reading until necessary)
  • Expensive computations (cache the result)
  • Breaking circular dependencies (A depends on B, B depends on A lazily)

Packaging and Imports

Organize your code with packages. Packages create a hierarchy, preventing name collisions and organizing code logically:

// File: src/main/scala/com/music/library/Playlist.scala
package com.music.library

class Playlist(val name: String)

// File: src/main/scala/com/music/library/Track.scala
package com.music.library

class Track(val title: String)

// File: src/main/scala/com/music/Player.scala
package com.music

// Import from a subpackage
import com.music.library.Playlist
import com.music.library.Track

class Player {
  val myPlaylist = new Playlist("Night Driving")
  val track = new Track("Midnight City")
}

Wildcard imports:

// Import everything from a package
import com.music.library._

val playlist = new Playlist("Synthwave")
val track = new Track("Synthwave")

// Import everything from java.util except a specific class
import java.util.{List => _, *}  // Imports all except List

Aliases for long names:

import com.music.library.{Playlist => MusicPlaylist}

val list = new MusicPlaylist("Electronic")

Best practice: Use specific imports. import com.music.library._ is sometimes lazy. Explicit imports make dependencies clear. Tools like scalafmt can auto-organize imports, so don't spend time on it manually—just use specific imports as you write code.


End of Part I

This foundation covers everything you need before diving into Scala's functional paradigm (Part II) and advanced type system (Part III). You now understand values and types, control flow, functions as first-class values, and data modeling with classes. You're ready to build real Scala programs.

Next: Part II explores Scala's functional backbone—pattern matching, collections, and composition.