Module 0 · Lesson 3 · ~20 min read
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 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.
val, var, def, lazy valval 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).
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.
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
Option, Either, TryScala 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.
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).
flatMapval 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.
// 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.
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."
Canton's participant node runtime is built on Akka (now forked as Pekko). Two things to recognize:
Source → Flow → Sink. Backpressure-aware. Used extensively for the transaction stream and related pipelines.Behavior[T] types and ActorRef[T] handles. Canton uses them sparingly but they show up in some infra code.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.
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.
def foo[A: Encoder](a: A)).+A, -A) on generic types.inline definitions.When one of these blocks you from reading a specific file, look it up then. Don't pre-learn.
case class = struct; sealed trait + cases = enum-like.Option, Either, Future, EitherT[Future, Err, A] — error and async plumbing.