Skip to content
← All insights
Scala language9 min read

Using traits as precise boundaries in Scala

Traits can define capabilities, share behaviour and close a domain hierarchy; those are different jobs with different design trade-offs.

A trait can describe a capability

A Scala trait can contain abstract members, concrete methods and state. That makes it more capable than a minimal Java interface, but the most maintainable service boundaries usually remain small and describe one role.

A repository trait is useful when application logic should depend on persistence behaviour without knowing MongoDB details. Its method signatures should expose domain intent rather than mirroring every method offered by a database client.

Do not create a trait solely to satisfy a rule that every class needs an interface. A single pure implementation with no meaningful boundary often needs no abstraction. Add the trait when multiple interpreters, testing, dependency direction or ownership of an effect makes the boundary valuable.

Describe the operation, not the database driverscala 2.13
trait AccountRepository {
  def find(id: AccountId): Future[Option[Account]]
  def save(account: Account): Future[Unit]
}

final class MongoAccountRepository(collection: AccountCollection)
    extends AccountRepository {
  override def find(id: AccountId): Future[Option[Account]] = ???
  override def save(account: Account): Future[Unit] = ???
}

A sealed trait defines a closed choice

Sealing a trait means direct subclasses must be declared in the same source file in Scala 2. The compiler can then reason about the complete set of alternatives and warn when a pattern match is not exhaustive.

This is a different use of traits from dependency abstraction. A sealed hierarchy models data: a payment was accepted or declined; a command is pending, running or complete. Case classes and case objects carry the data for each alternative.

Closed models make illegal combinations harder to construct. Instead of a record with several booleans and optional fields, each case can contain exactly the information available in that state.

Represent each permitted state directlyscala 2.13
sealed trait ImportState
object ImportState {
  case object Pending extends ImportState
  final case class Running(startedAt: Instant) extends ImportState
  final case class Completed(rows: Int, finishedAt: Instant) extends ImportState
  final case class Failed(reason: String, failedAt: Instant) extends ImportState
}

def isFinished(state: ImportState): Boolean = state match {
  case ImportState.Completed(_, _) | ImportState.Failed(_, _) => true
  case ImportState.Pending | ImportState.Running(_)            => false
}

Concrete behaviour introduces linearisation

Traits can implement methods and call super, enabling stackable modifications. When several traits override the same method, Scala linearises the inheritance graph to determine the order. The trait mixed in furthest to the right is entered first, then its super call proceeds through the chain.

This can be elegant for small, orthogonal behaviour, but application control flow becomes difficult to follow when logging, retries, metrics and validation are all mixed into a class. Constructor-injected decorators usually make order and dependencies more explicit.

If mixin order changes behaviour, test that order directly and keep the chain short. Avoid mutable trait state shared across concurrent requests unless its lifecycle and synchronisation are deliberate.

Mixin order is part of the resultscala 2.13
trait Message { def text: String = "account created" }

trait WithTimestamp extends Message {
  abstract override def text: String = s"[12:00] ${super.text}"
}

trait AsWarning extends Message {
  abstract override def text: String = s"WARNING: ${super.text}"
}

val message = new Message with WithTimestamp with AsWarning
message.text // "WARNING: [12:00] account created"

Self-types state a requirement but do not provide it

A self-type says that a trait may only be mixed into something that also conforms to another type. It can make a capability available inside the trait without extending that capability as part of the public subtype relationship.

Self-types are useful in some modular designs, but they can hide the eventual object graph across several files. Constructor parameters are usually clearer for application services because the required dependencies are visible where the class is instantiated.

Use a self-type when composition through mixins is genuinely the chosen model. Do not use it as a more mysterious spelling of dependency injection.

A self-type constrains legal compositionscala 2.13
trait AuditLog {
  def record(event: String): Unit
}

trait AuditedOperations { self: AuditLog =>
  def closeAccount(id: AccountId): Unit = {
    record(s"closing ${id.value}")
    // perform the operation
  }
}

final class AccountAdmin extends AuditedOperations with AuditLog {
  override def record(event: String): Unit = println(event)
}

Choose the job before choosing the trait

Traits are used for at least three distinct jobs: open service abstractions, closed algebraic data types and reusable implementation. Confusing those jobs produces broad interfaces, deep inheritance and models that callers can extend when they should be closed.

For a service boundary, prefer a small vocabulary of meaningful operations. For domain alternatives, seal the hierarchy. For shared implementation, check whether an ordinary function, helper value or explicit decorator would make execution order easier to see.

The best trait is not the most reusable one. It is the one that makes a real boundary or set of permitted states obvious to the next engineer.