Module 0 · Lesson 3 · ~20 min read

Scala for Reading Canton's Core

Canton's core is Scala. You don't need to write Scala to contribute Go tooling, but you'll read it constantly — to understand what the Ledger API actually does, to trace a behavior back to its source, to file a bug. This is the minimum set of constructs that unblocks reading.

Scala in one paragraph

Scala is a JVM language that fuses object-oriented and functional programming. Same runtime as Java, same interop, but richer type system (parameterized types, variance, type classes via implicits), and a functional idiom that's closer to Haskell than Java. Canton uses Scala 2.13 (as of most 3.x versions) — if you see implicit keywords everywhere, that's Scala 2; Scala 3 uses given/using.

The eight things that trip up readers

1. val, var, def, lazy val

val x = 42              // immutable binding (like Python's = but can't reassign)
var y = 42              // mutable — avoid in idiomatic Scala
def f(x: Int): Int = x + 1   // method / function
lazy val expensive = compute()     // evaluated on first access, cached

Canton's codebase uses val almost exclusively. var is rare and usually a sign of something mutable by design (e.g. an internal counter).

2. Case classes — Scala's dataclass

case class Participant(id: ParticipantId, version: ProtocolVersion)

// Built-in: equality, hashCode, toString, .copy(), and unapply for pattern matching.
val p = Participant(ParticipantId("alice"), ProtocolVersion.v5)
val p2 = p.copy(version = ProtocolVersion.v6)

Canton uses case classes heavily for domain types. When you see one in a file, treat it as a Go struct with value semantics.

3. Pattern matching — the one you must internalize

def describe(x: Any): String = x match {
  case 0                  => "zero"
  case n: Int if n > 0  => "positive int"
  case s: String          => s"string: $s"
  case Participant(id, _) => s"participant $id"    // destructures case class
  case List(a, b, _*)      => s"list starting $a, $b"
  case _                  => "something else"
}

Pattern matching is exhaustive (when it can be), destructuring, type-testing, and predicate-filtering — all in one syntax. Any non-trivial Scala file uses it. If you can't read match, you can't read Scala.

Tip: sealed trait + case class/case object is Scala's algebraic data type idiom. The compiler checks exhaustiveness across all subtypes.

sealed trait Result
case class Ok(value: String) extends Result
case class Err(msg: String) extends Result
case object Pending extends Result

4. Option, Either, Try

Scala does not use null. Or rather, it has null for Java interop but idiomatic code represents optional values with Option.

def lookup(id: String): Option[User] = db.get(id)

lookup("alice") match {
  case Some(user) => render(user)
  case None       => renderNotFound()
}

// Or chain it:
lookup("alice").map(_.name).getOrElse("anonymous")

Either[A, B] is used for "one of two things, typically error or success." Try[A] is "either a successful A or a thrown exception, as a value." Canton uses Either heavily for error-typed results.

5. Traits — closer to Go interfaces than to Java interfaces

trait HasId {
  def id: String                         // abstract
  def shortId: String = id.take(8)        // default implementation
}

class Participant(val id: String) extends HasId

Unlike Go's interfaces, you must explicitly extends. Unlike Java, traits can have both abstract and concrete members, and a class can mix in multiple traits (there's linearization magic for method resolution — you can mostly ignore it while reading).

6. For-comprehensions — sugar for flatMap

val result = for {
  user <- findUser(id)             // Option, Future, Either, etc.
  addr <- findAddress(user.addrId)
  code <- lookupCountry(addr)
} yield code

// Equivalent to:
// findUser(id).flatMap(user => findAddress(user.addrId).flatMap(addr => lookupCountry(addr).map(code => code)))

Every line is a bind. The whole block short-circuits on the first failure/None/Left/whatever. Looks imperative, reads left-to-right, but semantically it's chained monadic binds. Recognize the pattern and skip over the details.

7. Implicits (Scala 2) — the one that melts beginners' brains

// Declaration — somewhere in scope
implicit val ec: ExecutionContext = ExecutionContext.global

// Use — note no argument passed explicitly
def fetchAll(): Future[Seq[Thing]] = ...

// Definition that demands it:
def fetchAll()(implicit ec: ExecutionContext): Future[Seq[Thing]] = ...

The compiler looks in scope for a matching implicit and passes it automatically. Canton has implicit TraceContext (for distributed tracing), implicit ExecutionContext (for async), and implicit type class instances. You'll see method signatures with a trailing (implicit ...) parameter list everywhere.

When reading, mentally translate "any method that takes an implicit param" as "depends on ambient context configured elsewhere." You don't need to trace where it comes from unless you're debugging.

8. Futures — async as values

val f: Future[Int] = Future { expensive() }

f.map(_ * 2).onComplete {
  case Success(v) => println(v)
  case Failure(e) => e.printStackTrace()
}

A Future[T] is a handle to a possibly-not-yet-computed T. Map/flatMap chain asynchronously. Canton uses Future pervasively. You'll also see EitherT[Future, Err, A] — a monad transformer that combines Future and Either so for-comprehensions handle both async and typed errors in one pipeline. Treat it as "Future of Either, but you can use it in a for-comp without nesting."

Akka / Pekko — actors and streams

Canton's participant node runtime is built on Akka (now forked as Pekko). Two things to recognize:

You don't need to master Akka. You need to recognize Source.fromPublisher(...), .map, .runWith(Sink.foreach)-style code as "streaming pipeline" and keep reading.

A real-looking Canton-style snippet

Here's the kind of thing you'll see in Canton's codebase. Try to read it without translating every token.

def submitAsync(
    commands: Seq[Command],
    workflowId: Option[WorkflowId] = None,
)(implicit tc: TraceContext, ec: ExecutionContext): EitherT[Future, SubmissionError, SubmissionId] = {
  for {
    validated <- EitherT.fromEither[Future](validate(commands))
    ledgerEnd <- EitherT.right(ledgerClient.fetchEnd())
    submissionId = SubmissionId.generate()
    _ <- EitherT.right(
      ledgerClient.submitAndWait(validated, workflowId, submissionId, ledgerEnd)
    )
  } yield submissionId
}

Parse it like this: "Function submitAsync takes commands and optional workflowId, plus an implicit trace context and execution context. Returns an async computation that either fails with a SubmissionError or succeeds with a SubmissionId. Steps: validate, fetch ledger end, generate an ID, submit, return the ID."

You don't need to know how EitherT.right works internally. You need to follow the shape.

What to skip, at least for now

When one of these blocks you from reading a specific file, look it up then. Don't pre-learn.

Where to go if you want more

Takeaways