Preface
Ask two Scala developers what their language is like and you can receive answers with no overlap. One describes a better Java: concise, practical, immediately productive. The other describes a different discipline entirely, where programs are values and the compiler polices side effects. Both are right, both ship real software, and the gap between them is where most Scala learning goes to die: books and courses pick a side and quietly pretend the other doesn’t exist.
This book refuses the choice. It takes you from experienced JVM developer to someone who reads and writes both Scalas, and, more usefully, knows when each earns its place, by building one real thing end to end: Fully Booked, a restaurant reservation service that grows from three case classes into a tested, documented, containerised application backed by Postgres.
Who this is for. You’re fluent in Java or a comparable mainstream language and comfortable on the JVM. You may have eight years of Spring behind you or three of Kotlin; what matters is that interface, thread and dependency are working vocabulary. I assume no Scala whatsoever.
Who it is not for. First-time programmers: the pace assumes you’ve shipped software and will find "here’s `if`" insulting rather than helpful. Developers seeking a ZIO-first path: I teach the Typelevel stack and signpost ZIO honestly, but its ecosystem gets paragraphs here, not chapters. And anyone wanting a Spark or data-engineering book: Spark shares Scala’s syntax and little else with the work in these pages, so it gets a pointer in appendix B and no more.
What you’ll be able to do. By the final chapter you will have built, and will know how to rebuild: a domain model whose illegal states don’t compile; validation that reports every problem at once with typed errors; a concurrent booking engine that provably never double-books, tested under contention; an HTTP API defined once and interpreted into server, OpenAPI documentation and client; a Postgres-backed persistence layer whose transactions survive fleets and restarts; a test suite spanning properties, virtual clocks and a real database in a container; and a graceful, health-checked artefact your operations team will not resent. You’ll also read the Scala you didn’t write, Play and Future and Scala 2 alike, because inheriting code is half the job.
How it works. Every code example compiles and runs; the companion repository carries all of it, checkpointed per chapter, tests included, and continuous integration compiles the lot against pinned versions (Scala 3.8 on JDK 21 at the time of writing). Chapters end with two or three exercises that exist because doing them teaches something prose can’t, with solutions in the repository. When the ecosystem is contested, pre-1.0, or declining, the book says so plainly and gives the alternatives their say; the verdicts are yours to reach.
One promise about tone, since you’re about to spend a whole book with it: this book is written the way a senior engineer explains things at a whiteboard, direct and occasionally dry, with a standing allergy to ceremony, and it never says "it’s easy" about anything that isn’t. Where Scala costs something, hiring pools, compile times, a two-dialect community, the bill is itemised. The language survives honesty better than most, which is roughly why this book exists.
Let’s get into it.
Part I: Scala as a Better Java
1. Which Scala?
Here are two functions from two production codebases. Both answer the same question: how much does this customer owe us?
def unpaidTotal(customerId: Long): BigDecimal =
val invoices = invoiceDb.fetchUnpaid(customerId)
invoices.map(_.amount).sum
def unpaidTotal(customerId: CustomerId): IO[BigDecimal] =
for
invoices <- invoiceStore.unpaidFor(customerId)
_ <- Log.info(s"unpaid total requested for $customerId")
yield invoices.map(_.amount).sum
The first could be Java with the ceremony sanded off. It calls the database, sums what comes back, returns a number. If you write Java for a living you can already review it.
The second returns an IO[BigDecimal], whatever that is, and doesn’t seem to run anything at all so much as describe something that will run later. The logging is a value. The customer id has its own type. It is doing the same job as the first function, and a Java developer can stare at it for a while without being sure of that.
Both are idiomatic Scala in 2026. Each would fail the other team’s code review.
This book calls them the two Scalas, and it exists because of the gap between them. Most Scala material picks one dialect and quietly pretends the other doesn’t exist. That produces developers who can’t read half the Scala in the wild, and it produces internet arguments about what "real" Scala is. We’re going to skip both outcomes. By the end of this book you’ll read and write both of these functions, and, more usefully, you’ll know when each one is the right call.
1.1. The Two Scalas
Scala arrived in 2004 as Martin Odersky’s argument that object-oriented and functional programming belong in one language on the JVM. The argument worked twice, on two different audiences, and that’s where the split began.
The first audience came from Java. Twitter’s very public migration made the loudest case in 2009: they wanted the JVM’s performance and libraries without Java’s ceremony, and Scala delivered exactly that. Case classes replaced forty-line bean definitions, and collections became something you transform rather than loop over. Everything still compiled to bytecode the ops team already knew how to run. Much of the JVM’s data infrastructure grew up in this dialect: Spark still lives there, and Kafka’s core began there, though Kafka has been shedding its Scala for years. It’s the first function above: direct, effectful, recognisably Java-shaped underneath.
The second audience arrived from the opposite direction. Functional programmers, many with Haskell in their history, saw a typed language on an industrial runtime and started building the discipline they were used to: effects tracked by the compiler, and errors that travel as ordinary values instead of thrown exceptions. Out of that came the Typelevel stack (Cats, then Cats Effect, then the http4s and doobie family that carries Part IV of this book) and later ZIO, a parallel ecosystem with the same conviction and different ergonomics. That’s the second function above: nothing happens when you call it; it builds a description of a program, and the runtime executes that description at the edge of the application.
The language supports both because it was designed to. Odersky describes Scala as a staircase you can stop climbing at any step. What the design didn’t anticipate is that teams camp on a step and interview for it.
There are "better Java" shops, very often Play Framework codebases, where an IO in a pull request reads as showing off. There are functional shops where a bare side effect reads as a defect. Both kinds ship real software and have done for years.
Picture the same three-line change, a discount recomputed on invoice totals, opened as a pull request in both codebases. In the first it’s approved in minutes with a nitpick about naming. In the second, the review asks why the function reaches into the database directly instead of returning a description of the work, and what happens when two discounts land at once. Neither review is wrong. They’re reading different languages that happen to share a compiler.
So this book refuses the usual move of anointing one dialect. The route instead: Part I teaches Scala as a better Java, honestly and without apology, because that’s the working on-ramp for a Java developer and it’s real Scala, not a training-wheels dialect. Part II covers the type-driven middle ground both cultures share, where the language stops being a nicer syntax and starts being a better tool. Part III builds the second Scala from first principles, so that when IO arrives you’ll have invented most of it yourself and can judge it on results. Part IV puts the whole thing in production, and includes a chapter on Play precisely because the first dialect runs a large fraction of the industry’s Scala and pretending otherwise helps nobody.
1.2. What Scala Buys You
The honest pitch for a Java developer comes down to how much work the compiler does for you.
In Java, a large class of correctness lives in convention: this reference is never null (says a comment), this switch covers every case (until someone adds an enum constant), this object is immutable (as long as nobody calls that one setter). Scala’s habit, and the running theme of this book, is to move those promises into types, where breaking them stops compilation instead of paging whoever’s on call. Chapter 4 does it to null. Chapter 7 does it to "did we handle every case". By Part III the same trick applies to "what side effects can this function have", which is the promise Java can’t even write down.
Meanwhile you lose nothing about the platform you rely on. Scala compiles to JVM bytecode and calls any Java library directly, so your profilers and your hard-won GC folklore keep working. Your Postgres driver doesn’t know the difference, and neither does your deployment pipeline. This matters more than any language feature: adopting Scala is a language decision, and only a language decision, on a platform you already trust.
And Scala 3, the only Scala this book teaches, is a calmer language than its folklore. The reputation for inscrutable code was earned mostly by Scala 2-era implicits and a community phase involving custom operators that looked like modem line noise. Scala 3 removed or tamed the worst of it, and the libraries this book teaches have all completed their move to it. The honest exception is the big-data corner: Spark still builds against Scala 2.13, which matters if your future is data engineering and not at all if it’s services. Either way, you’re arriving after the turbulence, which is a genuinely good time to arrive, well done you.
1.3. What It Costs
You should also hear the costs from us rather than discover them.
The hiring pool is smaller than Java’s by an order of magnitude, and the two-dialect split cuts it again: a "Scala developer" who has only ever written Play services will need weeks to be productive in a Typelevel codebase, and vice versa. This book exists partly to shrink those weeks, but no book removes them.
The ecosystem carries scars. Lightbend relicensed Akka out from under the community in 2022, and the fork (Pekko) is what Play now runs on; chapter 21 tells that story properly. Several libraries the community treats as production-standard have spent years shy of a 1.0 version number. None of this is disqualifying, and the community that remains is unusually senior and unusually helpful, but the JVM’s boring stability lives in Java-land, and Scala trades some of it for reach.
The compiler is slower than Java’s, too. It’s doing more work, and on a large codebase the difference is real. Incremental compilation and the tooling this book uses keep it comfortable at the scale we’ll work at, but the folklore about Scala build times was earned honestly, and the IDE story was rough for years before Metals and IntelliJ’s Scala plugin matured. It’s fine now. It wasn’t always.
Where does that leave the decision? Scala pays off where correctness is expensive to get wrong: money, bookings, anything concurrent, anything where a 3am incident costs more than a slower onboarding. If your organisation needs to hire forty juniors a quarter, write Java. If it needs six people to run something correct and concurrent, keep reading.
1.4. What We’re Building
Abstract examples teach abstractions; this book builds one thing and keeps building it. Fully Booked is a restaurant table-reservation service, chosen because you already understand the domain from both sides of the front door, and because it grows in exactly the order we need it to.
It starts as a handful of case classes: tables, parties, bookings. Part I queries a day’s schedule with collections. Part II makes illegal bookings unrepresentable and turns validation failures into values instead of exceptions. Part III meets the domain’s hardest problem: two parties grabbing the last table at 8pm, at the same moment, from two web requests. Part IV gives it an HTTP API that documents itself, a Postgres schema that survives restarts, a test suite that would catch every bug this book warns about, and a container to ship it in.
The last-table race is worth flagging now, because it recurs on purpose. We solve it naively and watch it fail, solve it properly with concurrency primitives, solve it again in direct style for comparison, and finally make it durable with database transactions. Same problem, four chapters apart, deliberately: it’s the book’s yardstick for whether each new tool actually improves on the last one.
Every line of Fully Booked, and every exercise solution, lives in the companion repository, checkpointed per chapter so any chapter can be joined cold. Nothing in the book depends on you having typed out the previous chapter.
1.5. Setting Up
Two installations and you’re compiling.
A JDK, 21 or newer. Any distribution works; Eclipse Temurin is the safe default if your machine doesn’t already have one. The book’s examples are tested on JDK 21. If you’re on a Java team, whatever you already run is almost certainly fine.
scala-cli. Since Scala 3.5, the official scala command is scala-cli under a shorter name, and it’s all you need for most of this book: it compiles, runs, tests, formats, fetches dependencies, and pins versions from comments in the source file. No build tool, no project scaffolding. (A build tool arrives in chapter 16, when the case study has earned one.)
# macOS
brew install Virtuslab/scala-cli/scala-cli
# Linux
curl -sSLf https://scala-cli.virtuslab.org/get | sh
# Windows
winget install virtuslab.scalacli
Verify it:
scala-cli version
For an editor, IntelliJ IDEA with the Scala plugin and VS Code with Metals are both first-class; pick whichever matches your Java habits. Nothing in this book depends on an IDE.
1.6. First Contact
The traditional proof of life, in a file called hello.scala:
@main def hello(): Unit =
println("table for two, 8pm")
$ scala-cli run hello.scala
table for two, 8pm
No class wrapping the method, and no public static void main(String[] args); there wasn’t a build file to feed, either. @main marks an entry point, the compiler generates the JVM plumbing Java makes you write, and Unit is Scala’s void.
One step further, a taste of where Part I is heading. In tables.scala:
case class Table(number: Int, seats: Int)
@main def firstLook(): Unit =
val room = List(Table(1, 2), Table(7, 4), Table(9, 6))
val forFour = room.filter(_.seats >= 4)
println(s"tables that seat four: $forFour")
$ scala-cli run tables.scala
tables that seat four: List(Table(7,4), Table(9,6))
That case class line replaces the constructor, getters, equals, hashCode and toString you’d write in Java, and the printout at the end is the free toString proving it. The filter line treats the room as data to query rather than something to loop over, and the _.seats >= 4 inside it is an anonymous function: _ stands for each table in turn, so the line reads "keep the tables whose seats are at least four". Chapter 2 explains what the compiler generated; chapter 3 is entirely about that style of collection work and makes the underscore notation official. For now the point is narrower: two files, two commands, no scaffolding, and you’ve run Scala.
There’s also a REPL when you want to try something without a file:
$ scala-cli repl
scala> List(1, 2, 3).map(_ * 2)
val res0: List[Int] = List(2, 4, 6)
1.7. How This Book Works
Some ground rules, so you know what you’re holding/scrolling.
Every code example compiles. The companion repository carries all of them, organised per chapter, and a CI job compiles the lot against the pinned versions: Scala 3.8 (the feature set of the 3.9 LTS line) on JDK 21. Where a chapter uses a library, the version is pinned in the sample sources, so what you read is what ran.
Chapters end with two or three exercises. They aren’t homework padding; each one exists because doing it teaches something the prose can’t. Solutions live in the repository, and looking at them is reading, not cheating.
I assume you’re fluent in Java or another mainstream language and know your way around the JVM. I assume no Scala at all. Chapters build on each other within a part, and the case study threads through the whole book, but every chapter states what it needs and the repository checkpoints let you join anywhere.
One promise back the other way: when something in the ecosystem is contested, pre-1.0, declining, or just a matter of taste, the book says so instead of selling you a stack. You’ll finish with opinions; they should be yours, formed with every tradeoff in plain view.
1.8. The Deal
Flip back to the two functions that opened this chapter. The first one you can already read; by the end of Part I you’ll write it better than most Java-turned-Scala developers do, because you’ll know which Java habits to keep and which to delete. The second one stops being alien in Part III, and, more to the point, you’ll have built a small version of IO yourself before anyone asks you to trust the real one.
Between here and there is chapter 2, which takes the Java you already write and starts deleting ceremony.
1.9. Exercises
Solutions are in the companion repo under ch01/exercises.
-
Proof of life. Install the toolchain, run both files from this chapter, and then make
firstLookanswer a different question: which tables could seat a party of six, and how many seats would go spare at each? You’ll wantmapas well asfilter; guess the syntax from what you’ve seen before looking anything up. -
REPL archaeology. In the REPL, type
List(3, 1, 2).and press Tab. Somewhere in that list of methods aresorted,max,sumand roughly two hundred more. Spend five minutes; find one method whose behaviour surprises you. This is how working Scala developers explore an unfamiliar API, and it costs nothing. -
Arguments, typed. Change the entry point to
@main def hello(name: String): Unitand greet whoever is named. Run it withscala-cli run hello.scala — Marchetti, then run it with no argument at all and read what happens. Command-line arguments arrive parsed and typed; compare that with theString[] argsyou’re used to slicing by hand.
2. From Java to Scala
Here is a class you have written a hundred times:
public final class Booking {
private final String reference;
private final String partyName;
private final int partySize;
public Booking(String reference, String partyName, int partySize) {
this.reference = reference;
this.partyName = partyName;
this.partySize = partySize;
}
public String getReference() { return reference; }
public String getPartyName() { return partyName; }
public int getPartySize() { return partySize; }
// ...plus equals(), hashCode() and toString(): another thirty lines
}
And here it is in Scala:
case class Booking(reference: String, partyName: String, partySize: Int)
That’s this chapter in miniature. The designs in your head are fine; what Scala deletes is the ceremony Java charges you to write them down. We’ll work through where each piece of that ceremony went: fields and constructors, getters, equals, statics, the ternary operator, switch. By the end, Fully Booked has its first real domain model, and nothing about it will feel foreign, because you already think in these shapes.
|
Scala 3 structures code with indentation, and that’s what this book writes. Braces are still legal, and plenty of codebases use them, so you’ll read both. If a snippet in this book looks like it’s missing braces, it isn’t. |
2.1. Values First
Scala has two ways to introduce a name:
val covers = 42 // cannot be reassigned
var walkIns = 3 // can be reassigned
No type annotations, because the compiler infers them; covers is an Int and misusing it as anything else won’t compile. You can write the annotation when it helps a reader (val covers: Int = 42), and on public method signatures you always should, but inside function bodies the convention is to let inference work.
The important part is which of the two you reach for. In Java, immutability is a discipline: final on the field, defensive copies at the edges, a reviewer who notices when someone forgets. In Scala, val is the default reflex and var is the deliberate exception. That single habit removes an entire genre of bug, the value that changed under you from three calls away, and it’s why var in a pull request draws the same look that a raw Thread does in Java. Local, contained mutation is fine, and we’ll see honest uses in chapter 3. Shared mutation is the thing the culture pushes against.
2.2. Everything Is an Expression
Java divides the world into statements, which do things, and expressions, which have values. Scala doesn’t bother: nearly everything has a value, and code gets shorter and safer because of it.
The clearest example is if. In Java you either mutate a variable from both branches or reach for the ternary operator. In Scala, if returns its result:
val deposit =
if party.size >= 8 then BigDecimal(10) * party.size
else BigDecimal(0)
There is no ternary operator, because this is what if already does. The expression’s type covers both branches, and deposit can be a val because nothing needs to assign it twice.
Blocks are expressions too: the last line of a block is its value.
val confirmation =
val guests = party.size
s"${party.name}, table for $guests"
guests exists only inside the block, and confirmation receives the final expression. Anywhere Java forces you into the pattern of "declare it, then fill it in from several places", Scala lets you compute the value in one place and bind it once. match follows the same rule, and we’ll get to it shortly, because it deserves more than a passing mention.
2.3. Case Classes: The Class You Meant to Write
Our model starts with the shapes we sketched in chapter 1. The chapter opening flattened the party into two fields to match the Java bean; a party is a concept of its own, so it gets a type of its own:
case class Party(name: String, size: Int)
case class Table(number: Int, seats: Int)
case class Booking(reference: String, party: Party)
Each of those lines buys the full bean: a constructor, accessor methods, structural equals and hashCode, a readable toString, and two things a hand-written Java class doesn’t give you at any price, a copy method and first-class support in pattern matching. Construction needs no new, and arguments can be named:
val marchetti = Booking(reference = "FB-1042", party = Party("Marchetti", 4))
If you’re on a recent Java you’re thinking of records, and you should be: records were Java conceding that this ceremony was a mistake. Case classes remain ahead on the working details. Records have no copy, no named or default arguments, and their pattern support was retrofitted onto instanceof and switch years later rather than designed in from the start. The gap has narrowed; it hasn’t closed.
copy is the one to internalise, because it’s how you update immutable data: build a new value that differs where you say and shares everything else.
val marchettiPlusTwo = marchetti.copy(party = marchetti.party.copy(size = 6))
Nobody who held a reference to marchetti sees anything change, which is the point. Nested updates like this one get wordy as models deepen; live with it for now, it stays manageable at the scale we’ll work at.
Parameters can carry defaults, on case classes and ordinary methods alike:
def bookingLabel(booking: Booking, formal: Boolean = false): String =
if formal then s"${booking.party.name}, party of ${booking.party.size}"
else booking.party.name
Defaults belong where a value genuinely is the norm, and they’re one reason you’ll see far fewer overloads in Scala than in Java: most overload sets are one method with defaults trying to get out.
That s"…" syntax is string interpolation: prefix the literal with s and splice values with $name or arbitrary expressions with ${…}. We’ve been using it since chapter 1; now it’s official. There’s also f"…" with printf-style format specifiers for when formatting matters: f"deposit due: £$deposit%.2f" renders the deposit to two decimal places.
|
In older codebases you’ll see |
2.4. Enums and Pattern Matching
A booking has a lifecycle, and its states deserve names:
enum BookingStatus:
case Pending, Confirmed, Cancelled
Java’s enum is close, and everything you’d expect (values, valueOf, ordinal) is here. One scoping difference will bite you once: Java’s switch lets you write bare constant names, but in Scala the cases live inside the enum’s namespace everywhere, so you either qualify them (BookingStatus.Pending) or import them. With that, we can meet match properly:
import BookingStatus.*
def describe(status: BookingStatus): String = status match
case Pending => "awaiting confirmation"
case Confirmed => "confirmed"
case Cancelled => "cancelled"
match is an expression, so describe is a single expression with no return and no fall-through, and there’s no break because cases don’t bleed into each other. But the compiler’s exhaustiveness checking is the real feature. Delete the Cancelled line and compilation warns:
match may not be exhaustive.
It would fail on pattern case: Cancelled
Now add a Seated case to the enum, and every incomplete match in the codebase raises its hand at compile time. This is the workflow that makes evolving a domain model safe: change the type, follow the warnings, done. Java’s switch finally does this too, on sealed interfaces from Java 21; Scala teams have been leaning on it for two decades, and chapter 7 builds a whole modelling style out of it.
An enum can also carry methods of its own, with this being whichever case they’re called on:
enum BookingStatus:
case Pending, Confirmed, Cancelled
def active: Boolean = this match
case Cancelled => false
case _ => true
BookingStatus.Confirmed.active is true. File the shape away, an enum with methods that match on this: you’ll be asked to build one yourself before Part I is out.
Patterns go deeper than enum cases. They destructure case classes, nest, and take guards:
def label(booking: Booking): String = booking match
case Booking(ref, Party(_, size)) if size > Party.MaxSize =>
s"$ref: needs a manager's sign-off"
case Booking(ref, Party(name, size)) =>
s"$ref: $name, party of $size"
The first case reads: a Booking whose Party’s size exceeds the maximum, and I don’t care about the name. In a pattern, `_ means "anything"; expressions give the underscore a second, different job, which chapter 3 takes up. Order matters and the first match wins.
Two more shapes finish the everyday kit. Alternatives share an arm, and @ binds the whole value even while the pattern reaches inside it:
def statusNote(status: BookingStatus): String = status match
case Pending | Confirmed => "on the books"
case Cancelled => "released"
def doorNote(booking: Booking): String = booking match
case b @ Booking(_, Party(_, size)) if size >= 5 =>
s"${b.reference}: hold the big table"
case Booking(ref, _) => s"$ref: seat anywhere"
Party.MaxSize back in label is a constant we’re about to define, which brings us to statics.
2.5. Where Statics Went
Scala has no static keyword. Two things replace it, and both are simpler than they sound.
First, definitions can live at the top level of a file: describe and label above belong to no class, and neither did anything in chapter 1. Utility methods stop needing a StringUtils class to squat in.
Second, when you want a named singleton, object declares one:
object Party:
val MaxSize = 12
def capped(name: String, size: Int): Party =
if size > MaxSize then Party(name, MaxSize) else Party(name, size)
An object sharing a name with a class is its companion, the conventional home for what Java would make static members: constants, factory methods, and later some machinery we’ll care about in chapter 9. Party.MaxSize reads exactly like the static access it replaces; the difference is that an object is a real value, it can extend traits, be passed around, and generally participate in the language instead of standing outside it.
2.6. Traits, Briefly
Java interfaces map to Scala traits, and if you know default methods you already know most of it:
trait Notifiable:
def contactName: String
def greeting: String = s"Dear $contactName"
class EmailContact(val contactName: String, val address: String) extends Notifiable
Traits take abstract members and concrete members with implementations, and classes mix in several with commas: extends Notifiable, Auditable. Traits can also carry state, which interfaces can’t. That’s genuinely all you need for now; traits have a second life as the foundation of type-driven design, and that story starts in Part II where it can be told properly.
2.7. Named Tuples
New in recent Scala (3.7+): tuples with named fields, for the cases where a method needs to return two things and a full case class feels like filing paperwork.
def headcount(bookings: List[Booking]): (count: Int, covers: Int) =
(count = bookings.size, covers = bookings.map(_.party.size).sum)
val tonight = headcount(bookings)
println(s"${tonight.count} bookings, ${tonight.covers} covers")
(List[Booking], while we’re here: square brackets are Scala’s angle brackets.) The fields are accessed by name, the compiler checks them, and no class was declared anywhere. At runtime a named tuple is an ordinary tuple, the names existing only for the compiler, so the convenience is free. The judgement call between this and a case class: named tuples are for local plumbing, case classes are for concepts. If the shape has behaviour, appears in more than a couple of signatures, or crosses a module boundary, give it a name and make it a case class. `headcount’s return type is plumbing, so a tuple it is.
2.8. Fully Booked So Far
The model as it stands, which is also ch02/ in the companion repo:
case class Party(name: String, size: Int)
object Party:
val MaxSize = 12
def capped(name: String, size: Int): Party =
if size > MaxSize then Party(name, MaxSize) else Party(name, size)
case class Table(number: Int, seats: Int)
case class Booking(reference: String, party: Party)
enum BookingStatus:
case Pending, Confirmed, Cancelled
Three case classes and an enum: the restaurant’s entire written-down knowledge so far, in roughly the line count of one Java getter block. The status enum is deliberately underpowered, a flat list of names with no rules about which can follow which; chapter 7 grows it into a proper state machine. And you may have noticed the model has no way to say a booking lacks a phone number, which is chapter 4’s problem to solve.
2.9. The Ledger
Take stock of what you didn’t write in this chapter. No constructors, no getters, no equals/hashCode pairs, no toString. No static, no ternary, no break. Every deletion was replaced by something the compiler now does for you, and that’s the pattern to watch as the book goes on: Scala’s wins are rarely new capabilities and usually old costs removed.
Nothing here changed how you think yet. These are your existing designs at a lower price. The change of thinking starts in chapter 3, where the for loop goes the way of the getter.
2.10. Exercises
Solutions are in the companion repo under ch02/exercises.
-
The rota. Model the front-of-house staff: a
Servercase class (name and section number) and aShiftenum (Lunch,Dinner,Double). WriteonDuty(server, shift)returning a one-line description viamatch. Then add aBreakcase toShift, recompile, and watch the compiler point at every match you now need to revisit. That workflow is the exercise. -
Two more Marchettis. The Marchettis call: two friends are joining. Write
amend(booking: Booking, newSize: Int): Bookingusingcopy, remembering the size lives inside the nestedParty. Check the original booking is untouched afterwards, and notice that the compiler never let you mutate it in the first place. -
Room capacity. Write
tableSummary(room: List[Table]): (tables: Int, seats: Int)using a named tuple, in the style ofheadcount. Then decide: should this have been a case class? Justify your answer to an imaginary reviewer; there’s a defensible case both ways.
3. Collections That Do the Work
Chapter 2 ended with a promise about the for loop. Here it is, Java’s workhorse, totalling tonight’s covers (a cover is the trade’s word for one seated diner):
int covers = 0;
for (Booking b : bookings) {
covers += b.getPartySize();
}
This book writes it differently:
val covers = bookings.map(_.party.size).sum
The Java version narrates a mechanism: a counter initialised, then updated once per element. The Scala version states a result: the party sizes, summed. That difference in stance, treating a collection as data to query rather than storage to walk, is the whole chapter. Fully Booked needs a day’s schedule, availability answers and a covers report, and we’ll build all three without writing a single loop that says how.
3.1. The Toolkit
Three immutable types do most of the work in everyday Scala.
List is the default sequence. Where Java reaches for ArrayList, Scala reaches here first:
val tables = List(Table(1, 2), Table(7, 4), Table(9, 6))
A List is a chain: fast to take the front of, fast to add to the front of, and prepending is common enough to have an operator, :::
val withNewTable = Table(12, 2) :: tables
tables is unchanged by that line; withNewTable is a new list. Every operation in this chapter behaves that way, and the section on structural sharing below explains why that isn’t the performance disaster your Java instincts suspect.
The expensive direction is the one Java hands reach for first: :+ appends, and appending to a chain means walking all of it. Vector is the indexed sequence, with effectively constant-time access and append, the right pick when you index or the data is large. Swapping one for the other rarely changes any other line, so start with List and switch when you have a reason. And the mapping your fingers are asking about: ArrayList becomes List (or Vector when you index), HashMap becomes Map, HashSet becomes Set.
Map associates keys with values, and it’s where our schedule will live:
val sections = Map(1 -> "window", 7 -> "main floor", 9 -> "back room")
There’s also Set for membership tests, and ranges, which are collections too:
val doubles = (17 to 21).map(_ * 2)
All of these live in scala.collection.immutable and are imported by default. The mutable variants exist, live in scala.collection.mutable, and get their own honest section later in this chapter.
3.2. Transformations, Not Iterations
First, the notation this whole chapter runs on. An anonymous function is written table ⇒ table.seats >= 4: parameter, arrow, body, Java’s → with one more dash. When the parameter is used exactly once, _ can stand in for it, so _.seats >= 4 is the same function. Each underscore is a fresh parameter, which is how _ + _ means (a, b) ⇒ a + b. And to close an ambiguity chapter 2 left open: in a pattern, _ means "anything"; in an expression, it means "the argument". Same character, two jobs, and you’ll stop noticing within the week.
Everything interesting happens with three methods, and you’ve already met two. map transforms every element and gives you the results. filter keeps the elements that pass a test. They chain, and the chain reads as a sentence:
val bigTableNumbers = tables.filter(_.seats >= 4).map(_.number)
The third is flatMap, for when the transformation itself produces collections and you want one flat result rather than a list of lists:
import java.time.LocalTime
val slots = (17 to 21).toList.flatMap(hour =>
List(LocalTime.of(hour, 0), LocalTime.of(hour, 30)))
// List(17:00, 17:30, 18:00, ..., 21:30)
Each hour became two sitting times, and flatMap flattened the pairs into one list of ten. That’s java.time.LocalTime doing the time-keeping, used directly: Java’s libraries are Scala’s libraries, no wrapping required. If that name rings a bell, it should: chapter 4 makes flatMap the centre of a much bigger idea, and it helps to have used it on plain lists first.
If you’re thinking Java has streams for this, it does, and they were Java admitting this style had won. The difference is defaults. A stream is something you opt into, pipe through and collect back out of (stream(), then .collect(Collectors.toList()) to leave); in Scala the collections themselves have these methods, the results are already immutable collections, and there’s no ceremony at either end. This is the last time the point needs a Java citation: from here on, pipelines are just how code looks.
3.3. The Schedule
Fully Booked needs to know what’s happening tonight. A booking, as chapter 2 left it, is a reference and a party; the time belongs to the schedule, not the booking, so we’ll model the evening as requests pairing a sitting time with a booking. Named tuples, from chapter 2, are exactly the right weight:
val requests: List[(slot: LocalTime, booking: Booking)] = List(
(LocalTime.of(19, 0), Booking("FB-1040", Party("Adeyemi", 2))),
(LocalTime.of(19, 0), Booking("FB-1041", Party("Okafor", 2))),
(LocalTime.of(20, 0), Booking("FB-1042", Party("Marchetti", 4))),
(LocalTime.of(20, 0), Booking("FB-1043", Party("Chen", 6))),
(LocalTime.of(20, 0), Booking("FB-1044", Party("Dubois", 5))),
(LocalTime.of(20, 30), Booking("FB-1045", Party("Novak", 3)))
)
A flat list of requests is honest input, but the front desk thinks in sittings, the evening’s seating turns: who’s arriving at eight? groupBy reshapes a collection into a Map keyed by whatever you say:
val grouped = requests.groupBy(_.slot)
Close: that’s a Map[LocalTime, List[…]], but the values are the whole request pairs, still carrying the slot we grouped by. groupMap does the grouping and a transformation in one pass:
type Schedule = Map[LocalTime, List[Booking]]
val schedule: Schedule =
requests.groupMap(_.slot)(_.booking)
Two small things arrived with that. groupMap takes its arguments in two bracket groups, the first saying how to key and the second what to keep of each element; several library methods do this, and until chapter 9 explains why, read f(a)(b) as f(a, b) with better type inference. And type Schedule is an alias: it names a shape without creating anything new, so every signature from here on can say Schedule and mean exactly that Map. That’s the schedule: sitting time to bookings. Reading from it has one wrinkle worth learning immediately. Looking up a key that isn’t there can’t return a bare value (there isn’t one), so Map offers getOrElse with a default:
val atEight = schedule.getOrElse(LocalTime.of(20, 0), Nil)
val atTen = schedule.getOrElse(LocalTime.of(22, 0), Nil) // Nil: empty list
A quiet ten o’clock is an empty list of bookings, and every query downstream handles it without a special case. Now the questions answer themselves:
def coversAt(schedule: Schedule, slot: LocalTime): Int =
schedule.getOrElse(slot, Nil).map(_.party.size).sum
def coversReport(schedule: Schedule): List[String] =
schedule.toList
.sortBy((slot, _) => slot)
.map((slot, bookings) => s"$slot: ${bookings.map(_.party.size).sum} covers")
Two things worth noticing in coversReport. A Map turned into a list becomes a list of pairs, and lambda parameters destructure pairs positionally, so (slot, bookings) ⇒ names both halves without ceremony. And sortBy needed no comparator: LocalTime already knows how to order itself, and the compiler finds that knowledge, a mechanism chapter 9 has a lot more to say about. One more property, easy to miss: slots with no bookings don’t appear in the report at all, because absent keys are absent rather than present-with-zero. Exercise 1 pokes at exactly that. We’ll run the report at the end of the chapter.
3.4. One Answer from Many Values
sum back in the opening line is one of a family: count, max, min and friends all collapse a collection into a single answer. They’re special cases of one general machine, foldLeft, which walks the collection carrying an accumulator:
val totalCovers = schedule.values.flatten.foldLeft(0)((total, b) => total + b.party.size)
(values hands over the map’s values, and flatten collapses the lists into one.) Read it as: start from 0, and for each booking, produce the next total. Any of the collapsing methods could be written this way, and when no ready-made method fits, foldLeft is what you reach for. That’s all we need from it today; folds turn out to be a load-bearing idea, and chapter 9 returns to them with better tools.
3.5. for Comprehensions: Queries over Queries
Chains of flatMap and map get hard to read past two levels of nesting, and Scala has syntax for exactly this. A for comprehension with multiple generators walks every combination:
val tonightListing =
for
slot <- slots
booking <- schedule.getOrElse(slot, Nil)
yield s"$slot ${booking.reference} ${booking.party.name}"
Every slot, and within each slot every booking, flattened into one list of lines. This is not a loop, although it reads like one: the compiler translates it into the flatMap and map calls you’d have written by hand, and yield marks what each combination produces. Here is that translation, performed by hand once so you never have to take it on faith:
val tonightListing2 =
slots.flatMap(slot =>
schedule.getOrElse(slot, Nil).map(booking =>
s"$slot ${booking.reference} ${booking.party.name}"))
Same result, same types. Every for in this book compiles down to calls like these, which is why for will keep working, later, on things that are nothing like collections.
Guards filter combinations inline, and to ask the next question we’ll give the room a type of its own, since half this chapter’s questions are about it:
case class Room(tables: List[Table])
val room = Room(tables)
def candidateTables(room: Room, party: Party): List[Int] =
for
table <- room.tables
if table.seats >= party.size
yield table.number
Which tables could take this party? A query, written as one.
3.6. find, and a Shape Worth Noticing
One everyday operation needs a moment of care. find returns the first element passing a test:
val sixTop = room.tables.find(_.seats >= 6)
// Some(Table(9,6))
val twelveTop = room.tables.find(_.seats >= 12)
// None
Notice what came back: not a Table, but a Some(Table(9,6)), and when nothing matched, a value called None rather than a null or an exception. find can fail, and its return type says so. The standard library does this everywhere an operation might have nothing to give you: headOption, maxByOption, Map’s plain `get. For today, pattern match on the result or supply a fallback with getOrElse, both of which you already know how to read. What this type is and how to work with it fluently is chapter 4, in its entirety. Consider this a sighting.
3.7. The Wider Toolkit
You’ve now met the methods the case study needs. A few more turn up constantly in other people’s code, so here they are, each in one line on data we already have.
collect is filter and map in one move. Its argument is a function defined by patterns, braces and case, and anything that matches no pattern is dropped:
val bigPartyRefs = bookings.collect {
case Booking(ref, Party(_, size)) if size >= 5 => ref
}
// List(FB-1043, FB-1044)
partition splits by a test in one pass, destructuring straight into two names:
val (couples, groups) = bookings.partition(_.party.size <= 2)
zipWithIndex pairs each element with its position, which with mkString turns the evening into a printable run sheet:
val runSheet = tonightListing.zipWithIndex
.map((line, i) => s"${i + 1}. $line")
.mkString("\n")
exists asks whether any element passes (Java’s anyMatch), and forall asks whether they all do:
val hasSixTop = room.tables.exists(_.seats >= 6)
val everyTableSeatsTwo = room.tables.forall(_.seats >= 2)
One behaviour to know before it bites: on an empty list, forall answers true and exists answers false. An empty room contains no table that fails the seats test, so the claim stands unopposed; logicians call this vacuous truth, and Java’s allMatch behaves the same way. The bite comes in reports: "every booking tonight is confirmed" is a proud sentence to print over zero bookings, so when emptiness deserves a different answer from success, ask nonEmpty first.
Finally, lists destructure in patterns with the same :: that builds them:
val firstUp = tonightListing match
case first :: _ => first
case Nil => "a quiet night"
You’ll read that shape often in other people’s code; day to day, headOption and the combinators do the same jobs with less ceremony.
All of it is at its best in the REPL, where every experiment comes back with its type attached:
scala> room.tables.map(_.seats)
val res0: List[Int] = List(2, 4, 6)
scala> room.tables.map(_.seats).sum
val res1: Int = 12
3.8. The Copying Question
Every operation so far returned a new collection, and your Java instincts have been objecting for pages: surely all this copying is ruinous.
It would be, if it were copying. Immutable collections share structure instead. Prepending to a List allocates one node pointing at the existing chain, and both lists share everything but the new head, safely, because neither can ever change. An updated on a Map rebuilds the handful of nodes on the path to the change, roughly the logarithm of its size, and shares the rest of the tree. Sharing is only sound because mutation is impossible, which is the neat trick of the whole design: immutability is what makes not copying safe.
Table(12, 2) :: tables actually allocates: one cell. Both lists share the rest.The costs that do exist are honest ones: List is a chain, so walking to the middle is linear, which is why Vector exists. When a measured hot path wants an array, use an array. Which brings us to mutation.
3.9. When Mutable Is Fine
Immutability is the default, and defaults have exceptions. Scala’s mutable collections are real, supported and sometimes the right call; the discipline is containment.
import scala.collection.mutable
def sizeTally(bookings: List[Booking]): Map[Int, Int] =
val counts = mutable.Map.empty[Int, Int].withDefaultValue(0)
for b <- bookings do counts(b.party.size) += 1
counts.toMap
A mutable map, filled by a for … do loop (do instead of yield: no result, only effects), then sealed with .toMap before it leaves. Callers receive an immutable value and can’t tell a mutable builder was ever involved, which is precisely the point: mutation that never escapes a function is an implementation detail. Shared mutation, a structure two parts of a program can both see and both change, is the thing all the defaults are protecting you from. The convention of importing mutable and writing mutable.Map at use sites keeps the choice visible to reviewers.
For the record, the library would have done it anyway:
val tally = requests.map(_.booking).groupMapReduce(_.party.size)(_ => 1)(_ + _)
Three bracket groups this time: keyed by party size, each booking contributing a 1, the ones summed. Worth knowing both: the builder pattern for when logic gets genuinely stateful, the one-liner for when it doesn’t.
3.10. The Last Table, Naively
We can now write the front desk’s core operation. Can we seat this party at this time, and if so, record it:
def canSeat(schedule: Schedule, room: Room,
slot: LocalTime, party: Party): Boolean =
val taken = schedule.getOrElse(slot, Nil).size
taken < room.tables.size && room.tables.exists(_.seats >= party.size)
def book(schedule: Schedule, room: Room,
slot: LocalTime, booking: Booking): Schedule =
if canSeat(schedule, room, slot, booking.party) then
schedule.updated(slot, booking :: schedule.getOrElse(slot, Nil))
else schedule
canSeat treats every table as interchangeable, one booking per table, which is a simplification we’ll live with; matching parties to specific tables stays the door’s job until chapter 4 gives us the tools. Note also that book returns the schedule unchanged when it can’t seat the party, which quietly swallows the request. Representing "this didn’t work, and here’s why" as data is chapters 4 and 8’s business, and this function will be embarrassed by then.
|
|
3.11. Fully Booked So Far
The evening, assembled, which is also ch03/ in the companion repo:
val room = Room(List(Table(1, 2), Table(7, 4), Table(9, 6)))
val schedule = requests.groupMap(_.slot)(_.booking)
coversReport(schedule).foreach(println)
// 19:00: 4 covers
// 20:00: 15 covers
// 20:30: 3 covers
val walkIn = Party("Byrne", 2)
canSeat(schedule, room, LocalTime.of(20, 0), walkIn)
// false: three bookings at eight, three tables
canSeat(schedule, room, LocalTime.of(20, 30), walkIn)
// true: only the Novaks so far
The restaurant can now answer what its evening looks like, in code that states questions rather than mechanisms. Loops as the default didn’t survive contact with this chapter; neither did the null checks a Java schedule would carry, though you may not have noticed their absence, which is rather the point. What you did see was find handing back Some and None, values from a type we’ve been stepping around all chapter. Time to stop stepping around it.
3.12. Exercises
Solutions are in the companion repo under ch03/exercises.
-
The quiet sittings. The kitchen wants warning about dead slots. Write
quietSlots(schedule, allSlots)returning the sitting times with no bookings at all, in time order. Then flip it: which slot is busiest by covers? The busiest-slot answer can’t be a bareLocalTime(the schedule could be empty); let the method the library offers you tell you what comes back, and handle it withgetOrElseor a match. -
The full listing. Produce the printed evening: one line per booking,
"20:00 FB-1042 Marchetti (4)", ordered by slot then by reference. Aforcomprehension andsortBywill do it; getting the ordering right is the exercise. -
Tally, two ways. Write
tablePressure(schedule, room): for each slot, the number of bookings as a share of tables, e.g.19:00 → 2 of 3. Do it once with a mutable builder that doesn’t escape, and once as a pure pipeline. Decide which you’d rather review, and be honest about whether the answer depends on the size of the function.
4. Absence, Encoded
Somewhere in every Java codebase there is a method that returns a String and sometimes doesn’t.
The signature won’t tell you which kind you’ve got. String getPhoneNumber() reads like a promise, but whether it holds is decided at runtime, in production. Java folklore calls null the "billion-dollar mistake": Tony Hoare’s own words for his 1965 invention of null references. The estimate has not kept pace with inflation.
Java teams know this and build defences: Objects.requireNonNull at the boundaries, or @Nullable annotations that the IDE reads and the compiler doesn’t enforce. These help, but they don’t fix the underlying problem. Absence is real information, getPhoneNumber genuinely may have nothing to give you, and Java’s type system cannot express that. So the information ends up in Javadoc, where nothing can check it.
Scala expresses it in the type. This chapter is about Option, which turns "maybe there’s a value" from a runtime surprise into a compile-time fact. It’s a small type, and it changes how you design everything around it.
4.1. The Hole in the Types
Fully Booked needs to look up a booking by its reference. Here’s the Java version, as found in the wild:
// Returns the booking, or null if the reference is unknown.
Booking findBooking(String reference)
The comment is doing the type system’s job, and comments don’t get compiled. Callers have to remember to check. Some will.
There’s an obvious objection here: Java has had java.util.Optional since 2014. True, and if your codebase uses it at every boundary, you already believe this chapter’s argument. But Optional arrived twenty years into Java’s life, and it shows. The collections API predates it, so lookups still return null. Java’s own guidance warns against using it for fields and parameters. Strangest of all, a method declared to return Optional<Booking> can still return null: the type that exists to end null can itself be null.
Scala’s Option wins as a commitment more than as a design. It has been in the standard library from the start, so the APIs that can come up empty all return it, and the ecosystem grew up assuming they would. When there is exactly one way to say "nothing", saying it stops being a risk.
4.2. Option: Absence With a Type
An Option[A] either holds an A or holds nothing. Conceptually:
enum Option[+A]:
case Some(value: A)
case None
(The + in [+A] marks the type parameter as covariant, which is what lets the single None value serve every kind of Option. Copy it on faith for now; Part II earns it.)
An Option[Booking] is either Some(booking) or None. That’s the entire mechanism. Absence used to be a property of the reference, invisible until you dereferenced it; now it’s a value with a type. The compiler won’t let you treat an Option[Booking] as a Booking for the same reason it won’t let you treat an Int as one: it isn’t one.
Our model grows some honest fields. A booking has a party, and it may also have contact details and a table preference. Not every diner hands over every detail, and now the model says so:
case class Party(name: String, size: Int)
case class Booking(
reference: String,
party: Party,
phone: Option[String],
email: Option[String],
preferredTable: Option[Int]
)
case class Table(number: Int, seats: Int)
case class Room(tables: List[Table])
In the Java version of this model, phone is a String that’s sometimes null, and the code that formats it works until the first customer who didn’t leave a number. Here, phone: Option[String] is documentation the compiler reads.
Constructing options is unexciting, which is the point:
val okafors = Booking("FB-1041", Party("Okafor", 2), None, None, None)
val regular = Booking(
"FB-1042",
Party("Marchetti", 4),
phone = Some("020 7946 0958"),
email = None,
preferredTable = Some(7)
)
val bookings = List(okafors, regular)
And the lookup that opened this chapter no longer needs its comment:
def findBooking(reference: String, bookings: List[Booking]): Option[Booking] =
bookings.find(_.reference == reference)
We used find in chapter 3, and its return type is why this method is one line: a search can come up empty, so find returns Option. The standard library already works the way this chapter is arguing you should.
Because an Option is an ordinary value, pattern matching works on it, exactly as it did on the enums in chapter 2:
def greeting(reference: String, bookings: List[Booking]): String =
findBooking(reference, bookings) match
case Some(booking) => s"Welcome back, ${booking.party.name}"
case None => "I can't find that reference"
Handle both cases or, with warnings enabled, the compiler objects. The forgot-to-check-for-null bug is no longer something you can type.
4.3. Working Inside the Box
The first instinct is to get the value out of the Option as fast as possible and carry on as before. There’s even a method for it: .get. Resist it. Calling .get on a None throws, so every .get quietly reintroduces the exact gamble Option exists to end.
The idiomatic move is the opposite one: leave the value where it is and transform it in place. map applies a function to the contents if there are any, and does nothing otherwise:
val partySize: Option[Int] =
findBooking("FB-1042", bookings).map(_.party.size)
If the booking was found, this is Some(4). If not, the None flows through untouched. Absence stays encoded and travels with the value, so nothing downstream has to re-check.
Chaining is where this starts to pay. The reminder service wants the digits of a booking’s phone number. The booking may not exist, and if it does, the phone may not be on file:
def phoneDigits(reference: String, bookings: List[Booking]): Option[String] =
findBooking(reference, bookings)
.flatMap(_.phone)
.map(_.filter(_.isDigit))
Why flatMap in the middle? _.phone is itself an Option[String], so plain map would produce an Option[Option[String]] and you’d have two layers to unwrap. flatMap transforms and flattens in one step: if any link in the chain is None, the result is None; otherwise the value carries through every step. Remember this shape. We’ll meet it again.
Here’s the same logic in defensive Java:
Booking booking = findBooking("FB-1042");
if (booking != null) {
String phone = booking.getPhone();
if (phone != null) {
return phone.replaceAll("[^0-9]", "");
}
}
return null;
The Scala version is shorter, but that isn’t the argument. The Java version checks for absence twice in the middle of its logic, and then returns null anyway, handing the same problem to its own caller. The Scala version checks zero times, because the type does the checking, and it returns an Option, so its caller knows exactly what it’s holding.
4.4. for Comprehensions over Option
Chains of flatMap compose fine, but past two or three steps they become arduous to read and reason about. In chapter 3 we used for comprehensions over collections; the same syntax works on Option, because for in Scala is not loop syntax. It’s a readable spelling for exactly these flatMap and map chains, and the compiler translates one into the other.
The confirmation service prefers to text people:
enum Contact:
case Sms(number: String)
case Email(address: String)
def confirmationContact(
reference: String,
bookings: List[Booking]
): Option[Contact] =
for
booking <- findBooking(reference, bookings)
phone <- booking.phone
yield Contact.Sms(phone)
Read each ← as "and this also has to be there". If every step produces a value, the yield runs. If any step is None, the comprehension stops and the whole result is None. It’s the nested null-check pyramid from the Java example, flattened, with the checking moved into the type.
Guards work too. Honouring a table preference means the preferred table has to exist and has to fit the party:
def resolveTable(booking: Booking, room: Room): Option[Table] =
for
preferred <- booking.preferredTable
table <- room.tables.find(_.number == preferred)
if table.seats >= booking.party.size
yield table
There are three distinct ways for this to produce None, and the body handles none of them explicitly. It describes the successful path; the signature admits everything else.
A preference is a preference, though. If we can’t honour it, any table big enough will do, and orElse expresses the fallback:
def assignTable(booking: Booking, room: Room): Option[Table] =
resolveTable(booking, room)
.orElse(room.tables.find(_.seats >= booking.party.size))
Note that orElse returns another Option. On a full night the fallback fails too, and the type won’t let anyone pretend otherwise.
4.5. Getting Back Out
Eventually a value has to leave the box, because something concrete needs producing: a rendered email, an HTTP response. At that point "maybe a string" has to become an actual string, and the everyday tool is getOrElse:
val phoneLabel = regular.phone.getOrElse("no phone on file")
Every getOrElse is a small design decision, because the default you supply is data and you are its author. Pick defaults that are obviously defaults, like "no phone on file". Don’t pick defaults that could pass for real data. The empty string is the classic mistake: absence dressed up as content, and it will sail through checks that null would have failed loudly.
When the present and absent cases both need real work, fold covers them in one expression:
val reminderStatus =
regular.phone.fold("no reminder sent")(p => s"reminder sent to $p")
The default comes first and the function second, and yes, everyone looks up the order for the first month. If fold reads cryptic to you, a pattern match does the same job in more lines, and there is nothing wrong with that.
|
|
4.6. Questions Through the Box
One more family, for interrogating an Option without ever unwrapping it:
val mobile = regular.phone.exists(_.startsWith("07"))
val wantsSeven = regular.preferredTable.contains(7)
val deposit = Option.when(regular.party.size >= 8)(BigDecimal(40))
exists runs a test on the contents and is false for None; contains compares against a value directly. Option.when(condition)(value) builds an Option from a condition, replacing the if-Some-else-None dance you would otherwise be writing by day three. All three appear constantly in working code.
4.7. Signatures That Tell the Truth
Once Option is in your vocabulary, API design gains a near-mechanical rule: if absence is a normal outcome, put it in the return type.
The standard library applies this rule everywhere. Map#get returns Option because missing keys are normal. head on an empty list throws, so headOption exists; the same pairing repeats in min/minOption and max/maxOption. The suffix is the library pointing you at the safer signature.
Two refinements to the rule.
First, Option answers "was there one?" and answers nothing else. If the caller deserves to know why there’s nothing, None carries no explanation, and Option is the wrong tool. Errors that come with reasons are chapter 8’s subject, and the type that handles them will look familiar by the time we get there.
Second, be more suspicious of Option in parameter lists than in return types or fields. An Option field on a domain model is honest data; that’s exactly what phone: Option[String] is doing for us. An Option parameter, though, often means one method is doing two jobs: one for callers that have the value and one for callers that don’t. Scala’s default parameters usually express that split better. This is a guideline rather than a law, but when you write an Option parameter, check whether two clearer methods are hiding inside the one you’ve got.
4.8. The Border Crossing
None of this makes the JVM go away. Java libraries still return null, System.getenv still returns null, and your Scala code will call plenty of both. The rule that keeps the model intact: null stops at the border.
The Option constructor is the checkpoint. Option(x) returns None when x is null and Some(x) otherwise:
val configuredHost: Option[String] =
Option(System.getenv("FULLY_BOOKED_HOST"))
Wrap a nullable Java API at the point of contact, and absence is encoded from that line onward. Be careful to use Option(…) and not Some(…) for this. Some(null) is legal, means "definitely present: nothing", and is never what you want. When a value’s provenance is Java, it’s Option(…) every time.
For Java’s Optional, the standard library ships converters:
import scala.jdk.OptionConverters.*
val fromJava: Option[String] = javaOptionalValue.toScala
val toJava: java.util.Optional[String] = configuredHost.toJava
|
Scala does have null: the language runs on the JVM and inherits it. Idiomatic Scala never writes it, and the border discipline above is what makes that sustainable. The compiler can go further. With the |
4.9. Fully Booked So Far
Here are the chapter’s pieces assembled, which is also the state of the case study in the companion repo (ch04/):
val bookings = List(
Booking("FB-1041", Party("Okafor", 2), None, None, None),
Booking("FB-1042", Party("Marchetti", 4),
Some("020 7946 0958"), None, Some(7))
)
val room = Room(List(Table(1, 2), Table(7, 4), Table(9, 6)))
assignTable(regular, room)
// Some(Table(7,4)): preference honoured
confirmationContact("FB-1042", bookings)
// Some(Sms(020 7946 0958))
confirmationContact("FB-1041", bookings)
// None: the Okafors left no phone number, and nothing crashed
Every place the domain can come up empty is now recorded in a signature instead of a comment. Nothing in Fully Booked can trip over an absent value without the compiler having first made someone acknowledge that the value might be absent.
4.10. The Trade
Option is the first example of this book’s central trade: take a discipline that Java enforces through convention and code review, and hand it to the compiler. The cost is a little ceremony at the boundaries. The return is that a whole category of production incident stops being possible to write. It’s also our first meeting with a shape, the chain of steps where any step can come up empty, that Scala keeps reusing: chapter 8 applies it to errors that carry reasons, and Part III applies it to entire programs.
Before either of those, chapter 5 puts a test harness under what we’ve built, because "it compiles" is a necessary standard, not a sufficient one, even here.
4.11. Exercises
Solutions are in the companion repo under ch04/exercises.
-
Preferred channels. Write
def contactFor(booking: Booking): Option[Contact]for the confirmation service: text the phone number if there is one, fall back to email, and returnNoneif the booking has neither. One expression; no pattern match needed. -
The walk-in. A party arrives without a booking. Write
def seatWalkIn(party: Party, room: Room): Option[Table]that seats them at the smallest table that fits; nobody gives a table for six to a party of two. There’s a standard library method that makes this a one-liner, and this chapter’s suffix pattern tells you roughly what it’s called. -
No magic. Prove
Optionisn’t compiler magic by building it yourself. Defineenum Maybe[A]` with cases `Just` and `Empty` (copy the `exactly as you saw it onOption), and give itmap,flatMap, andgetOrElse. It fits in twenty lines. Keep your implementation: in Part III we’ll do the same exercise to a far more interesting type.
5. Tests from Day One
Chapter 4 ended by calling "it compiles" a necessary standard, not a sufficient one. Here’s the gap between the two, in code we shipped two chapters ago:
def canSeat(schedule: Schedule, room: Room,
slot: LocalTime, party: Party): Boolean =
val taken = schedule.getOrElse(slot, Nil).size
taken < room.tables.size && room.tables.exists(_.seats >= party.size)
The types promise a Boolean. They say nothing about whether it’s the right one. Swap < for ⇐ and the restaurant overbooks every night, with the compiler’s full approval, because the compiler checks shape and tests check behaviour. Part I has leaned hard on the first; a professional codebase needs both, and this book would rather hand you the habit in chapter 5 than in an appendix.
There’s a payoff for coming to tests this early, too: everything we write from here on, in every remaining chapter, ships with tests in the companion repo. The book won’t always print them, but they’re there, and by the time the case study is a running service in Part IV, its suite will have grown up alongside it rather than being bolted on at the end like a smoke alarm fitted after the fire.
5.1. munit, and the First Test
Scala has several test frameworks. ScalaTest is the incumbent, with a family of DSL styles that let suites read like prose, and flexible enough that a long-lived codebase usually accumulates several of the styles at once; you’ll meet it in the wild. This book uses munit: it’s minimal, it looks like the JUnit you already know, scala-cli tests itself with it, and it saves its cleverness for failure output, which is where cleverness belongs.
Two things make tests run. The first is a dependency. Chapter 1 mentioned that scala-cli pins versions from comments in the source; the conventional home for those directives is a file called project.scala, which until now would have held one line, and now holds two:
//> using scala 3.8.4
//> using test.dep org.scalameta::munit::1.3.3
The second is a naming convention: scala-cli treats any file ending in .test.scala as test code, kept out of your production build. In availability.test.scala:
import java.time.LocalTime
class AvailabilityTests extends munit.FunSuite:
test("a full sitting refuses another party"):
assert(!canSeat(schedule, room, LocalTime.of(20, 0), Party("Byrne", 2)))
test("a quiet sitting accepts a walk-in"):
assert(canSeat(schedule, room, LocalTime.of(20, 30), Party("Byrne", 2)))
Each test(…) takes a name and a body, and the names are sentences about behaviour, not method names. canSeatReturnsFalseWhenFull tells a future reader nothing that the code doesn’t; "a full sitting refuses another party" tells them what the restaurant believes.
|
New syntax, worth naming: a colon at the end of a call, followed by an indented block, passes that block as the call’s last argument. It’s how Scala 3 writes what Java would wrap in a lambda’s braces, and test suites use it constantly. |
Run them:
$ scala-cli test .
Test run AvailabilityTests started
AvailabilityTests: finished 0.012s
Test run AvailabilityTests finished: 0 failed, 0 ignored, 2 total 0.019s
5.2. Failure Is the Feature
A passing suite is quiet: counts, not celebrations. The tool earns its keep when something breaks, so let’s break something on purpose: assert that eight o’clock holds 16 covers when the schedule says 15.
==> X AvailabilityTests.covers at eight 0.008s munit.ComparisonFailException:
availability.test.scala:12
11: test("covers at eight"):
12: assertEquals(coversAt(schedule, LocalTime.of(20, 0)), 16)
values are not the same
=> Obtained
15
=> Diff (- expected, + obtained)
-16
+15
That’s assertEquals failing: it prints the offending source line, both values, and a diff. On an Int the diff is comically unnecessary; on a nested case class with one wrong field buried three levels deep, it’s the difference between a ten-second fix and ten minutes of squinting, and it’s most of why this book prefers assertEquals(actual, expected) over a bare assert(actual == expected), which can only report false.
The everyday assertion kit is small: assert for booleans, assertEquals for values, intercept[SomeException] for code that should throw. Exceptions are rare guests in our code so far, and Part II is about keeping it that way, but the JVM ecosystem throws, and intercept is how a test pins that down.
5.3. Immutable Fixtures, Free of Charge
Test suites in Java grow @BeforeEach methods the way gardens grow weeds, because shared fixtures get mutated by one test and must be rebuilt for the next. Look at what our suite does instead: schedule, room and bookings are the same immutable values from chapters 3 and 4, declared once, shared by every test in any order. (They’re top-level definitions in the same project, so the suites see them without any import ceremony.) No test can corrupt them, because nothing can corrupt them. The discipline Part I has been selling pays a small dividend here and a much larger one in chapter 12, where the things under test start running at the same time.
Options, meanwhile, make the edge cases first-class test subjects. Chapter 4’s types forced us to admit which lookups can come up empty; the suite now pins down what "empty" means:
test("the Marchettis get their usual table"):
assertEquals(assignTable(regular, room), Some(Table(7, 4)))
test("no phone number means no text"):
assertEquals(confirmationContact("FB-1041", bookings), None)
No special matchers, no null-handling gymnastics: Some(Table(7, 4)) and None are ordinary values, so ordinary equality does the job. The Okafors not getting a text is now a documented decision rather than an accident of the data.
5.4. Red, Green, and the Compiler’s Version of Red
The test-first loop you may know from Java works the same way here, with one twist worth savouring. Fully Booked needs to issue booking references, so we start with the test:
test("references continue from the highest issued"):
val tonight = schedule.values.toList.flatten
assertEquals(nextReference(tonight), "FB-1046")
test("an empty book starts from FB-1001"):
assertEquals(nextReference(Nil), "FB-1001")
Run it, and the red isn’t a failing assertion:
[error] ./booking.test.scala:11:18
[error] Not found: nextReference
[error] assertEquals(nextReference(tonight), "FB-1046")
[error] ^^^^^^^^^^^^^
In Scala, red is very often a compile error, and that’s the system working: the test states a contract, and the compiler is the first reviewer to point out nobody has honoured it. Now the implementation, using two tools from chapter 4’s kit:
def nextReference(bookings: List[Booking]): String =
val numbers = bookings.map(_.reference.drop(3).toInt)
s"FB-${numbers.maxOption.getOrElse(1000) + 1}"
maxOption, because the maximum of an empty list is not a number, and getOrElse to seed the sequence. Run again, both green, loop closed. One honest confession while we’re here: toInt throws on a malformed reference, so this function trusts its input. Making bad references impossible to construct, rather than trusting, is Part II’s project, and this line gets revisited.
5.5. What We Don’t Test
Discipline includes knowing when to stop. We don’t test that copy copies or that equals compares; chapter 2 explained who wrote those methods, and the compiler’s homework doesn’t need marking. We don’t test the standard library: groupMap works. Tests go where decisions live, `canSeat’s threshold, `assignTable’s fallback, `nextReference’s seed, because decisions are where we’re allowed to be wrong.
Two names for later, so you know the ground we’re deliberately not covering yet. Property-based testing, where the machine generates hundreds of inputs hunting for the one that breaks your invariant, gets proper treatment in chapter 19, and it will love functions like Party.capped. And testing code that talks to databases and networks has its own chapter’s worth of honest tradeoffs, also chapter 19, once there’s a real service to point it at.
5.6. Fully Booked So Far
The case study now carries its first suite, ch05/ in the companion repo: availability, table assignment, contact preferences and reference issuing, seven tests pinning down every decision Part I has made, with the exercises adding eight more. Here’s the run for the chapter’s own two suites; the exercise suites join the output as you complete them:
$ scala-cli test .
Test run AvailabilityTests started
AvailabilityTests: finished 0.012s
Test run AvailabilityTests finished: 0 failed, 0 ignored, 3 total 0.019s
Test run BookingTests started
BookingTests: finished 0.002s
Test run BookingTests finished: 0 failed, 0 ignored, 4 total 0.002s
And that closes Part I. Take stock of where you stand: you write Scala that a "better Java" shop would merge without comment, immutable by default, modelled with case classes and enums, queried with collection pipelines, honest about absence, and tested. That’s the first Scala from chapter 1, and it’s real, productive Scala; plenty of professionals never leave it.
We’re leaving it. Part II is where the second Scala starts earning its reputation, and it begins with a question that sounds innocent: if a booking is a value you can pass around, why isn’t a policy? Functions are about to become data.
5.7. Exercises
Solutions are in the companion repo under ch05/exercises.
-
Pin the report.
coversReportfrom chapter 3 makes two promises the chapter stated in prose: output ordered by time, and no lines for empty slots. Prose isn’t enforcement. Write the two tests that are, including one against an empty schedule. -
Test-first cancellation. Fully Booked can’t yet cancel a booking. Write the tests first: cancelling a booked reference removes exactly that booking from its slot; cancelling an unknown reference returns the schedule unchanged; and decide, then pin down with a third test, what happens to a slot whose only booking is cancelled. Present-with-an-empty-list or absent? Chapter 3’s report behaves differently depending on your answer, which is what makes it a decision. Then implement
cancel(schedule, reference)to green. You’ll notice the unknown-reference case silently succeeds, the same smellbookhad in chapter 3; write it down somewhere, chapter 8 collects these. -
The suspicious assertion. Write a test asserting
Party.capped("Coach party", 30)produces a party of 12, and a second asserting the size 12 case passes through untouched. Then try to write a useful test for size 13. The boundary between "capped" and "passed through" is exactly where an off-by-one lives; make sure your suite would catch one.
Part II: Thinking in Types
6. Functions as Values
Part I ended on a question that sounded innocent: if a booking is a value you can pass around, why isn’t a policy?
Here’s the policy in question. Fully Booked charges a deposit on some bookings, and the rule changes: the owner wants large parties to pay, then wants regulars exempted, then December arrives and everything changes again. In Java this is the Strategy pattern, and you know the drill: a DepositPolicy interface, a class per variant, a factory to choose one, and a spring of XML or annotations to wire it through. Four files to represent one idea, because Java’s unit of behaviour is the class, and behaviour has to dress as an object to travel.
Scala’s unit of behaviour is the function, and a function is a value. It has a type, it sits in a val, it goes in a Map, it comes back from other functions. This chapter is where that stops being a slogan and starts being how Fully Booked prices an evening.
6.1. A Type for Behaviour
The arrow you’ve been writing in lambdas since chapter 3 is also a type:
val double: Int => Int = n => n * 2
val shout: String => String = s => s.toUpperCase
Int ⇒ Int reads "a function from Int to Int`", and it’s a type like any other: `double is a value of that type the way 4 is a value of Int. Calling it looks like calling anything else, double(21). Two-argument functions write the parameters in parentheses: (Int, Int) ⇒ Int.
That’s the entire mechanism, and the reason it matters is what it lets us name. A pricing adjustment takes a quote and returns a quote:
case class Quote(booking: Booking, total: BigDecimal, notes: List[String]):
def charge(amount: BigDecimal, note: String): Quote =
copy(total = total + amount, notes = notes :+ note)
type PricingRule = Quote => Quote
A Quote carries its booking, a running total, and the trail of what’s been applied so far; charge is a case-class method that returns the adjusted copy, chapter 2’s immutable update in its working clothes. And PricingRule is a type alias, exactly like chapter 3’s Schedule, except this one names a shape of behaviour. The owner’s rules are now values:
val largePartyCharge: PricingRule = q =>
if q.booking.party.size >= 6 then q.charge(BigDecimal(10), "large party")
else q
val windowTableHold: PricingRule = q =>
if q.booking.preferredTable.contains(9) then q.charge(BigDecimal(5), "window six-top held")
else q
Two policies, two `val`s. No interface, no implementing classes, no factory. When the rule doesn’t apply, it hands the quote back untouched, which will matter in a moment when rules start chaining.
6.2. Passing Behaviour In
A function that takes a function is nothing special to write; the parameter just has an arrow in its type. Cancellation refunds depend on how much notice the diner gave, and the policy for converting notice into a refund fraction is, of course, a function:
type CancellationPolicy = Int => BigDecimal
val standard: CancellationPolicy = hours =>
if hours >= 24 then BigDecimal(1)
else if hours >= 6 then BigDecimal("0.5")
else BigDecimal(0)
def refundDue(policy: CancellationPolicy, paid: BigDecimal, noticeHours: Int): BigDecimal =
paid * policy(noticeHours)
refundDue never knows which policy it’s applying, and that’s its whole value: the calculation is written once, and the policy varies per restaurant, per season, per whatever the owner dreams up next. You have been using this shape since chapter 3, on the other side: filter, map and sortBy all take functions, and now you’re writing your own.
One Java note, since this is the chapter where the comparison genuinely earns its keep: everything the Strategy pattern achieves, a function parameter achieves without the costume. The interface was only ever there to give one method a name to travel under.
6.3. Making Rules: Factories and Closures
December arrives. The owner wants a seasonal surcharge, but the amount changes yearly, so hard-coding it into a val is one December too clever. Write a function that returns the rule:
def seasonalSurcharge(amount: BigDecimal): PricingRule = q =>
q.charge(amount, "seasonal")
val december = seasonalSurcharge(BigDecimal(3))
seasonalSurcharge is a rule factory: call it with this year’s number and a PricingRule comes back. Look at what the returned function does, though: it uses amount, a parameter that belonged to a call that has already finished. The function closed over it, which is why this arrangement is called a closure. The rule carries its configuration inside itself, permanently, and no field, no constructor and no class was involved in the carrying.
Factories compose with everything else this chapter builds, and they’re the answer to a question you should be asking: "where did the config go?" It went into the function. That’s the point.
6.4. Composing Rules
Two rules make a pricing policy when one runs after the other, and function values come with the plumbing built in:
val eveningPolicy = largePartyCharge.andThen(windowTableHold).andThen(december)
andThen glues functions: f.andThen(g) is a new function that applies f, then g to the result. (Its mirror, compose, runs the right side first; this book writes andThen because it reads in execution order.) eveningPolicy is itself a PricingRule, indistinguishable from a hand-written one, which is composition’s quiet superpower: the combined thing is the same kind of thing as its parts.
The owner will not stop at three rules, so the general form takes a whole list:
def applyAll(rules: List[PricingRule]): PricingRule =
rules.foldLeft(identity[Quote])(_ andThen _)
Read it with chapter 3 eyes: start from identity, the function that returns its input unchanged (the standard library ships it; the square brackets pick which type’s identity you mean), and fold each rule on with andThen, the same andThen as above, written infix the way any alphabetic method may be. The accumulator isn’t a number this time, it’s a function, and foldLeft neither knows nor cares. If you want one image of what "functions as values" buys, it’s this: a fold whose running total is behaviour.
Order matters, incidentally. A percentage-based rule composed before a flat charge prices differently from one composed after it, and exercise 1 makes you feel that properly rather than nod at it here.
6.5. Methods Are Not Quite Functions
An honesty section, because you’ll trip on this within the month if nobody tells you. The `def`s you’ve written since chapter 2 are methods, and a method is not a function value; it’s a member of something, JVM heritage the compiler doesn’t hide. What the compiler does do is convert automatically whenever you use a method where a function is wanted:
def isLarge(p: Party): Boolean = p.size >= 6
val parties = bookings.map(_.party)
val bigOnes = parties.filter(isLarge)
filter wants a Party ⇒ Boolean, isLarge is a method, and the compiler builds the function value on the spot. The conversion has a name, eta-expansion, which is worth knowing only so that the word doesn’t frighten you in a code review. Day to day: write def for things with names and doc comments, write val lambdas for behaviour you’re about to hand somewhere, and let the compiler blur the difference.
6.6. Functions as Data
Values go in collections, and functions are values, so this compiles and quietly replaces a factory class:
val cancellationPolicies: Map[String, CancellationPolicy] = Map(
"standard" -> standard,
"festive" -> (hours => if hours >= 72 then BigDecimal(1) else BigDecimal(0)),
"no-shows-bite" -> (_ => BigDecimal(0))
)
def policyFor(name: String): CancellationPolicy =
cancellationPolicies.getOrElse(name, standard)
A Map from names to behaviour, and chapter 4’s lookup discipline applies unchanged, because there is nothing special about a value that happens to be callable. The "no-shows-bite" entry refunds nothing regardless of notice; _ => BigDecimal(0) ignores its argument, and by now that underscore reads itself.
Configuration-driven behaviour, runtime selection, pluggable policies: the phrases that fill Java architecture decks come down to "keep functions in a data structure and pick one". It’s fine to feel short-changed. It stays this simple.
6.7. Fully Booked: The Pricing Engine
Assembled, and in the companion repo under ch06/ with its test suite:
def quoteFor(booking: Booking, base: BigDecimal): Quote =
Quote(booking, base, Nil)
val houseRules = List(largePartyCharge, windowTableHold)
val chen = Booking("FB-1043", Party("Chen", 6), None, None, None)
val priced = applyAll(houseRules)(quoteFor(chen, BigDecimal(60)))
priced.total // 70
priced.notes // List(large party)
refundDue(policyFor("standard"), priced.total, noticeHours = 30)
// 70: full refund, more than a day's notice
refundDue(policyFor("festive"), priced.total, noticeHours = 30)
// 0: December is unforgiving
The Chen party of six picks up the large-party charge and not the window hold (they didn’t ask for table 9), the note trail says exactly what happened, and the same paid total refunds differently under different policies selected by name. Every piece is a value; the whole engine fits on one screen.
6.8. The Shape of Part II
Something changed in this chapter, quietly. In Part I, values were data: bookings, tables, schedules. Now behaviour is a value too, and every tool you already own, Map, foldLeft, Option, works on it unchanged. That’s the type-driven middle ground chapter 1 promised, and it’s also the first step of the staircase’s upper flight.
But notice what the compiler still can’t see. Nothing stops a PricingRule that charges a negative amount, a Party of size zero, or a cancellation policy that refunds more than was paid, because our types so far describe shapes, and shapes admit nonsense. Making the nonsense unrepresentable is chapter 7’s job, and it’s where BookingStatus finally grows up.
6.9. Exercises
Solutions are in the companion repo under ch06/exercises.
-
Order of operations. Write
percentageService(pct: Int): PricingRule(a factory adding a service charge as a percentage of the current total) and compose it withseasonalSurcharge(BigDecimal(3))in both orders. Write a test proving the two orders produce different totals from the same base quote, then decide, as the owner, which order is house policy and pin it with a second test. -
The policy that isn’t there.
policyForsilently substitutesstandardfor a name it doesn’t recognise, which you might recognise as chapter 3’s silent-swallow smell wearing a nicer jumper. WritepolicyNamed(name: String): Option[CancellationPolicy]that admits absence honestly, and rewrite `refundDue’s caller to decide the fallback at the edge instead. Chapter 8 will give the "why was it missing" half of this story. -
The no-show fee. A no-show forfeits the deposit and nothing else. Write
noShowFee(deposit: BigDecimal): PricingRuleas a factory, add it tohouseRules, run the lot throughapplyAll, and write the test asserting the note trail records every rule that fired, in order. The trail is the engine’s audit log, and the test is cheaper than the argument with the diner.
7. Modelling the Domain
Chapter 6 left a bill unpaid. Nothing in our types stops a Party of size zero, a negative charge, or a refund larger than the payment. The compiler has been checking shapes, and shapes admit nonsense. This chapter is about making the nonsense stop compiling, and it’s where the type system goes from pleasant to load-bearing.
The tools are ones you already hold. Case classes say "this AND this": a Booking is a reference and a party and two optional contact details and a table preference, all present at once. Enums say "this OR this": a Contact is Sms or Email, never both. Compose the two and you can describe most domains exactly, a combination the literature calls algebraic data types, ADTs when anyone’s in a hurry. The algebra doesn’t matter yet. What matters is the design habit: model the domain so that every value that compiles is a value that makes sense.
7.1. The Lifecycle, Done Wrong
A booking has a life: requested, confirmed, seated, and eventually concluded, cancelled, or a no-show. Here’s how that knowledge usually gets recorded, in Scala shops as much as Java ones:
case class BookingRecord(
reference: String,
confirmed: Boolean,
seated: Boolean,
cancelled: Boolean,
assignedTable: Option[Table],
cancellationNotice: Option[Int]
)
Count the states. Three booleans and two options make thirty-two combinations, and the domain has about six. confirmed = false, seated = true is a diner who sat down at a table nobody gave them. cancelled = true with seated = true is a ghost eating dinner. assignedTable = None with seated = true seats a party on the floor. Every one of those compiles, so every one of them will eventually be constructed by a bug, and the code that reads this record grows if guards like scar tissue, each one re-checking an invariant the last function also re-checked, because no function can trust the shape it was handed.
The flags aren’t the crime. The crime is that the type admits combinations the business forbids, which outsources the business rules to the discipline of every future maintainer.
7.2. The Lifecycle, Done Right
Chapter 2’s BookingStatus was a flat list of names, and we said it would grow up. Here’s adulthood: one case per real state, each carrying exactly the data that exists in that state.
enum BookingStatus:
case Pending
case Confirmed(table: Table)
case Seated(table: Table, from: LocalTime)
case Concluded(totalSpent: BigDecimal)
case Cancelled(noticeHours: Int)
case NoShow
def active: Boolean = this match
case Pending | Confirmed(_) | Seated(_, _) => true
case Cancelled(_) | NoShow | Concluded(_) => false
Enum cases can carry fields, exactly as Contact.Sms(number) did in chapter 4, and the payloads do the real modelling work. A table is held from confirmation, so Pending doesn’t have one and Confirmed can’t lack one. Cancellation notice exists only on cancellations, so the Option[Int] from the flags version disappears: not because we handled None well, but because the question "what’s the notice on this seated booking?" can no longer be asked. The thirty-two states are now six, and all six are true.
This is the single most transferable design move in the book. When you find yourself writing a case class where certain fields are only meaningful when certain booleans are set, you are looking at a sum type folded flat, and unfolding it deletes bugs you haven’t written yet.
7.3. Transitions as Functions
States are half a state machine; the other half is which moves are legal. Functions own that:
import BookingStatus.*
def confirm(status: BookingStatus, table: Table): Option[BookingStatus] =
status match
case Pending => Some(Confirmed(table))
case _ => None
def seat(status: BookingStatus, at: LocalTime): Option[BookingStatus] =
status match
case Confirmed(table) => Some(Seated(table, at))
case _ => None
def cancel(status: BookingStatus, noticeHours: Int): Option[BookingStatus] =
status match
case Pending | Confirmed(_) => Some(Cancelled(noticeHours))
case _ => None
def conclude(status: BookingStatus, bill: BigDecimal): Option[BookingStatus] =
status match
case Seated(_, _) => Some(Concluded(bill))
case _ => None
def noShow(status: BookingStatus): Option[BookingStatus] =
status match
case Confirmed(_) => Some(NoShow)
case _ => None
Each transition pattern-matches on the states it accepts and refuses the rest, so the machine’s rules live in one screen of code instead of scattered guards. You can’t seat an unconfirmed booking because seat won’t have it; the Confirmed(table) pattern even hands over the table for the Seated state, data flowing along the transition. Note cancel accepting two states through one alternation arm, chapter 2’s | earning its keep.
The Option return is doing honest work meanwhile: a refused transition is a normal outcome, so it’s in the signature. Hold that thought for the chapter’s end, because "no" is only half an answer.
And the cancelled state now hands chapter 6 its input on a plate:
def refundFor(status: BookingStatus, paid: BigDecimal,
policy: CancellationPolicy): Option[BigDecimal] =
status match
case Cancelled(notice) => Some(refundDue(policy, paid, notice))
case _ => None
The notice hours were captured when the cancellation happened, typed, impossible to lose, and the pricing engine from last chapter consumes them without either chapter knowing the other’s internals. Models and functions composing across chapters is what "the type-driven middle ground" was pointing at.
7.4. Exhaustiveness as a Design Tool
You met the compiler’s exhaustiveness warning in chapter 2 as a safety net. At model scale it becomes something better: a work list.
Suppose the owner adds a waiting list, so Waitlisted joins the enum. The moment it does, every match over BookingStatus that names its cases raises the chapter 2 warning with the missing case listed: active above, and every piece of reporting or display logic we write from here. The compiler walks you through your own codebase pointing at each place the new business rule needs a decision. Change the type, follow the warnings, done: the same workflow, now maintaining a domain model instead of a toy.
Notice who stays quiet, though: all five transitions, because each ends in case _ ⇒ None. Every wildcard in a match over an ADT is a place the work list goes silent, a decision made in advance for states that don’t exist yet. In transitions that’s defensible, "everything else refuses" being the actual rule, and Waitlisted refusing to seat is probably right. In anything that interprets states, reporting, display, billing, prefer listing the cases, so that growth knocks on the right doors. Where you spend the wildcard is where you’ve pre-signed the decision.
7.5. Sealed Traits, the General Form
An enum is compact syntax over an older shape you’ll meet constantly in the wild:
sealed trait PaymentMethod
case class Card(last4: String) extends PaymentMethod
case class Voucher(code: String, worth: BigDecimal) extends PaymentMethod
case object CashOnTheNight extends PaymentMethod
sealed means "all direct subtypes live in this file", which is what makes exhaustiveness checkable: the compiler can see the whole family. A case object is a case with no fields, singleton by construction. Everything this chapter does with enums works identically here, and the sealed-trait spelling buys flexibility the compact form lacks when cases need their own companions, or a method implemented differently per case rather than matched in one place.
If you’re arriving from recent Java, this whole chapter has been half-familiar: sealed interfaces (17), records (16) and exhaustive switch (21) are this exact toolkit, delivered in instalments. The difference is ecosystem age. Scala’s libraries, its collections and its idioms have assumed ADT-shaped modelling for two decades, so the technique here is the default, not the new feature you have to argue for. This is the last time Part II will need Java for scale.
7.6. When Structure Isn’t Enough
Some rules aren’t structural. A party size must be at least one and at most Party.MaxSize, and no arrangement of cases expresses "an Int between 1 and 12". For these, the move is a smart constructor: make the raw constructor private and expose one gatekeeper that admits only valid values.
case class PartySize private (value: Int)
object PartySize:
def from(n: Int): Option[PartySize] =
Option.when(n >= 1 && n <= Party.MaxSize)(PartySize(n))
PartySize(0) no longer compiles outside the companion; PartySize.from(0) compiles and honestly returns None. Every PartySize that exists is valid, so functions receiving one check nothing, the chapter’s theme again by other means.
The rest of chapter 6’s unpaid bill falls to the same move: a charge that can’t go negative or a refund fraction that can’t exceed one is a small type with a gatekeeper, built exactly like PartySize. An opinion on dosage, because this technique invites zealotry: wrap the values where an invalid one costs real money or real sleep, sizes, quantities of money, identifiers with formats, and leave ordinary strings and counts alone. A codebase where every Int wears a costume is as unreadable as one where none does. Fully Booked will adopt PartySize at its input boundary when chapter 8 builds one; retrofitting it through five chapters of existing model tonight would churn everything behind us for no new safety, and recognising that tradeoff is part of the technique.
7.7. Fully Booked So Far
The state machine, run through an evening, ch07/ in the repo:
val states = Map(
"FB-1042" -> Confirmed(Table(7, 4)),
"FB-1043" -> Pending,
"FB-1045" -> Cancelled(noticeHours = 40)
)
confirm(states("FB-1043"), Table(9, 6))
// Some(Confirmed(Table(9,6)))
seat(states("FB-1043"), LocalTime.of(20, 0))
// None: still Pending, nobody has confirmed a table
refundFor(states("FB-1045"), paid = BigDecimal(20), policy = standard)
// Some(20): 40 hours' notice refunds in full
The Marchettis hold a confirmed table, the Chens can be confirmed but not seated, and the Novaks' cancellation carries its own notice into the refund calculation. Six states, five transitions, no booleans, and no function in the file re-checks what its input’s type already guarantees.
7.8. The Missing Why
One dishonesty remains, and it’s deliberate, and it’s next. Every refusal in this chapter is a None. Seat a Pending booking: None. Cancel a Concluded one: None. Ask for a refund on a no-show: None. The caller learns "no" and nothing else, and somewhere a front-of-house screen wants to say why: already cancelled, never confirmed, too late. Chapter 3’s book swallowed failures silently; chapter 5 wrote the smell down; this chapter has upgraded the swallow to a polite shrug. Chapter 8 gives failures their own types, and the shrug gets a voice.
7.9. Exercises
Solutions are in the companion repo under ch07/exercises.
-
The waiting list. Add
WaitlistedtoBookingStatusand recompile. Count the warnings:activeknocks, and the five transitions stay silent, because their wildcards pre-signed a decision. For each silent site, decide whether "refuse" is right for a waitlisted booking (can it be cancelled? confirmed?) and add an explicit arm where it isn’t. The point is the workflow, warnings and the places wildcards kept quiet: together they’re the impact analysis for a business change. -
Where’s my table? Write
def table(status: BookingStatus): Option[Table]returning the table for exactly the states that have one. Your first instinct will be one alternation arm,case Confirmed(t) | Seated(t, _); try it, and meet a compiler rule this chapter didn’t mention: alternatives may not bind variables. Two arms it is. Then decide whether your fallback case is a wildcard, and whether the function should survive exercise 1’s new state without editing; defend the answer in a comment. -
Payment grows up. Model payment as its own machine:
Unpaid,DepositHeld(amount),Settled(total). WritetakeDepositandsettletransitions with the sameOptionshape as the chapter’s, and a test walking a payment fromUnpaidtoSettled. Then constructSettled(BigDecimal(84))directly, out of thin air, and notice that nothing stops you: enum cases are public constructors, and the transition functions are advice until something makes them the only door. What that something costs, a private constructor here, and where the checking honestly belongs is worth five minutes of thought; chapter 8 picks the question up.
8. Errors as Values
The smell log comes due. Chapter 3’s book returned the schedule unchanged when it couldn’t seat a party, quietly dropping the request. Chapter 5’s cancel did the same for unknown references, and we wrote it down. Chapter 6’s policyFor substituted a default for names it didn’t recognise. Chapter 7 upgraded the silence to a shrug: every refused transition answers None, which is honest about whether and mute about why. Somewhere a front-of-house screen wants to tell a diner "that booking was already cancelled", and all our types can offer is "no".
Option was the right tool for chapter 4’s question, because "was there one?" genuinely has two answers. Failure is different: it has as many answers as there are ways to fail, and the caller usually needs to know which one happened. This chapter gives failures their own types, and it starts with the type you were promised would look familiar.
8.1. Either: Option with a Reason
Either[E, A] holds one of two values: a Left(e), which by convention carries the failure, or a Right(a), which carries the success. The conceptual shape:
enum Either[+E, +A]:
case Left(error: E)
case Right(value: A)
Where Option had a hole, Either has a second compartment, and everything you learned in chapter 4 transfers. map transforms the Right and leaves a Left untouched. flatMap chains steps that can fail, and the first Left wins: later steps never run, exactly as the first None ended an Option chain. Which means for comprehensions work, because chapter 3 told you they would: for runs on anything with flatMap and map.
def parseSize(raw: String): Either[String, Int] =
raw.toIntOption.toRight(s"'$raw' is not a number")
val size =
for
n <- parseSize("6")
ok <- if n >= 1 then Right(n) else Left("party of nothing")
yield ok
// Right(6)
Two new tools slipped in there. toIntOption is the standard library’s honest integer parse, Option[Int] instead of the exception toInt throws, and it starts paying down a debt: chapter 5’s nextReference trusted toInt on data it didn’t control, and we said that line would be revisited. The settlement comes shortly. And toRight is the bridge between the two worlds: an Option plus the reason it might be None makes an Either. Absence becomes a named failure at exactly the point you know what the absence means.
One habit to set now: the error type E is real API. Either[String, A] is fine at a REPL and lazy in a codebase, because strings can’t be matched exhaustively, carry no data, and drift with every rewording. Errors deserve what chapter 7 gave states.
8.2. Errors Deserve Types
Fully Booked takes booking requests from the outside world, and the outside world sends nonsense. Enumerate the nonsense:
enum BookingError:
case MalformedReference(raw: String)
case PartyNotANumber(raw: String)
case PartyOutOfRange(size: Int)
case UnknownSlot(raw: String)
case SlotFull(slot: LocalTime)
def message: String = this match
case MalformedReference(raw) => s"'$raw' doesn't look like a booking reference"
case PartyNotANumber(raw) => s"'$raw' isn't a party size"
case PartyOutOfRange(size) => s"we can't seat a party of $size"
case UnknownSlot(raw) => s"'$raw' isn't a sitting we recognise"
case SlotFull(slot) => s"$slot is fully booked"
An error ADT, built with chapter 7’s toolkit and enjoying chapter 7’s benefits: each failure carries its own evidence (SlotFull knows the slot; PartyOutOfRange knows the size), and message matches exhaustively, so when a new failure mode joins the enum, the compiler points at every renderer that hasn’t decided what to say. The failure channel is a domain model. Treat it with the same respect as the success channel and half of error handling stops being handling at all.
8.3. Validating the Boundary
Chapter 7 promised that PartySize and friends would earn their keep at the input boundary. Here is the boundary. A raw request arrives, all strings and hope:
case class RawRequest(name: String, size: String, slot: String)
case class ValidBooking(reference: Reference, party: Party, slot: LocalTime)
Reference is a smart constructor in the PartySize mould, and it settles chapter 5’s toInt debt for good:
case class Reference private (value: String)
object Reference:
def next(existing: List[Reference]): Reference =
val numbers = existing.map(_.value.drop(3).toInt)
Reference(s"FB-${numbers.maxOption.getOrElse(1000) + 1}")
def parse(raw: String): Either[BookingError, Reference] =
if raw.startsWith("FB-") && raw.drop(3).toIntOption.isDefined
then Right(Reference(raw))
else Left(BookingError.MalformedReference(raw))
(isDefined reads as it sounds: "is it a Some?".) next still calls toInt, and now it’s allowed to: every Reference in existence passed parse or was built by next, so the format holds by construction. The trust chapter 5 confessed to is now a guarantee, which is what "make illegal states unrepresentable" buys at a boundary: the checking happens once, where the data is dirty, and nowhere else, because past this line the types are clean.
Validation is a for over the failure-carrying steps, and one line inside deserves a beat before you read it: PartySize.from(size) hands back chapter 4’s Option, the map after it is Option’s own, and the toRight on its tail lifts the result into the Either the rest of the for speaks. (parseSlot turns the raw slot string into a vetted LocalTime or an UnknownSlot; it’s defined two sections down, where it belongs, so take its signature on credit for a page.)
def validate(req: RawRequest, existing: List[Reference],
slots: List[LocalTime]): Either[BookingError, ValidBooking] =
for
size <- req.size.toIntOption.toRight(BookingError.PartyNotANumber(req.size))
party <- PartySize.from(size)
.map(ps => Party(req.name, ps.value))
.toRight(BookingError.PartyOutOfRange(size))
slot <- parseSlot(req.slot, slots)
yield ValidBooking(Reference.next(existing), party, slot)
Read the shape: steps that can fail, chained, first failure wins, happy path down the left margin. Chapter 4 chained `Option`s this way; chapter 7 built failing steps and left them unchained; this chapter chains them with reasons attached. The costume changes; the shape doesn’t.
8.4. When You Want All the Errors
First-failure-wins is right for pipelines and wrong for forms. A diner who mistyped the size and the slot deserves both complaints in one round trip, not a correction loop. Accumulation is a different fold over the same checks:
def validateAll(req: RawRequest, slots: List[LocalTime])
: Either[List[BookingError], (Party, LocalTime)] =
val sizeCheck =
req.size.toIntOption.toRight(BookingError.PartyNotANumber(req.size))
.flatMap(n => PartySize.from(n).toRight(BookingError.PartyOutOfRange(n)))
.map(ps => Party(req.name, ps.value))
val slotCheck = parseSlot(req.slot, slots)
(sizeCheck, slotCheck) match
case (Right(party), Right(slot)) => Right((party, slot))
case (a, b) =>
Left(List(a, b).collect { case Left(e) => e })
No new machinery: a tuple of independently-run checks, a match on the combinations, and chapter 3’s collect harvesting whichever Left`s exist. It scales inelegantly past three or four fields, and the ecosystem knows it: the Cats library ships `Validated, a polished, general version of exactly this idea, and it will turn up naturally when Part IV’s HTTP layer validates real requests. Today’s point is that you’ve now built the idea it polishes, which is this book’s standing bargain.
The two strategies answer different questions, so keep both spellings: sequential for when later checks depend on earlier ones, tupled accumulation when the checks are independent and the audience is human.
8.5. Exceptions at the Border
None of this abolishes exceptions, for the same reason chapter 4 didn’t abolish null: the JVM is out there. Java libraries throw; LocalTime.parse throws; JDBC, when we reach it, throws at every opportunity. The rule is the null rule verbatim: exceptions stop at the border.
Try is the border guard, `Option’s cousin for code that throws:
import scala.util.Try
def parseSlot(raw: String, slots: List[LocalTime]): Either[BookingError, LocalTime] =
Try(LocalTime.parse(raw)).toOption
.filter(slots.contains)
.toRight(BookingError.UnknownSlot(raw))
Try(expression) runs the expression and catches anything non-fatal (the JVM’s genuinely broken states, like running out of memory, still propagate, as they should), packaging success or the thrown exception as a value; .toOption keeps the success, and the existing toRight bridge names the failure on the way into our world. One line of border control, and everything downstream of it composes.
Now the opinion this chapter has been building towards. Java’s checked exceptions were the right idea: failure modes belong in signatures, visible to callers, enforced by the compiler. The mechanism sank it. Checked exceptions don’t compose (streams and lambdas simply gave up on them), don’t carry through higher-order code, and punish every caller into catch-and-wrap rituals until throws SQLException becomes throws ServiceException becomes nothing at all. Either is the same promise kept by ordinary values: failure in the signature, but it maps, folds, chains and accumulates with the same tools as every other value in the language. Checked exceptions that compose. That’s the pitch, and unlike the 1996 version, this one has survived contact with higher-order functions.
8.6. Fully Booked So Far
The boundary, end to end, ch08/ in the repo:
val sittings = List(LocalTime.of(19, 0), LocalTime.of(20, 0))
validate(RawRequest("Okafor", "2", "19:00"), Nil, sittings)
// Right(ValidBooking(Reference(FB-1001),Party(Okafor,2),19:00))
validate(RawRequest("Marchetti", "forty", "19:00"), Nil, sittings)
// Left(PartyNotANumber(forty))
validateAll(RawRequest("Chen", "0", "3pm"), sittings)
// Left(List(PartyOutOfRange(0), UnknownSlot(3pm)))
A good request becomes a ValidBooking with a well-formed reference minted for it. A bad one comes back with a reason, and the form-shaped path comes back with every reason at once. Nothing downstream of validate will ever re-check a party size or re-parse a slot: the checking happened once, at the boundary, and the Reference inside is unforgeable by construction.
8.7. The Question for Keeps Dodging
Twice now, a type with a hole in it has handed us flatMap, and for has made the chains readable: Option in chapter 4, Either here. Chapter 3 promised that for "will keep working, later, on things that are nothing like collections", and it has, twice, without ever explaining what the compiler is actually looking for. There’s a quieter debt from the same chapter: sortBy ordered `LocalTime`s because "the compiler finds that knowledge", and we never said how. Both dodges end in chapter 9, which is about the machinery Scala uses to give capabilities to types, and it’s the chapter where the second Scala’s characteristic move, abstracting over the shape itself, finally gets its proper name.
8.8. Exercises
Solutions are in the companion repo under ch08/exercises.
-
The front-of-house screen. Write
def apologise(error: BookingError): String, the diner-facing rendering, without a wildcard. Then addcase DuplicateBooking(ref: Reference)to the enum and let the compiler hand you the work list:message,apologise, and whatever else you’ve written. Chapter 7’s workflow, on the failure channel. -
The machine learns to speak. Upgrade chapter 7’s
seattoEither[TransitionError, BookingStatus], whereTransitionErrorcarries the state that refused (CannotSeat(from: BookingStatus)). Write the test proving the error names the actual state, and notice the front desk can now say "that booking was cancelled" instead of shrugging. -
All the complaints. Build a three-check form validator in an error enum of your own: names must be non-blank (
NameMissing), plus the size and slot checks you’ve seen. Three checks means the tuple-match grows arms; write it, feel the scaling pain honestly, and record in a comment how many fields you’d tolerate before reaching for the library version.
9. Abstraction Without Apology
You’ve been reading type parameters since chapter 2 and writing none. Square brackets have appeared on everything, List[Booking], Option[Table], Either[BookingError, ValidBooking], Map[LocalTime, List[Booking]], always someone else’s. This chapter hands you the machinery: how to write generics, how the compiler finds capabilities like the Ordering that sorted chapter 3’s report, and how to build a typeclass of your own. It’s the most abstract chapter in the book, and every abstraction in it is extracted from duplication Fully Booked actually has, because that’s what abstraction is for. Not cleverness. Deduplication with proofs.
9.1. Writing Type Parameters
The restaurant stores bookings by reference, tables by number, and, since chapter 6, policies by name, and every one of those wants the same four operations. Written three times, that’s a bug farm; written once, it’s this:
case class Store[K, V](entries: Map[K, V]):
def get(key: K): Option[V] = entries.get(key)
def put(key: K, value: V): Store[K, V] = copy(entries = entries.updated(key, value))
def remove(key: K): Store[K, V] = copy(entries = entries.removed(key))
def all: List[V] = entries.values.toList
object Store:
def empty[K, V]: Store[K, V] = Store(Map.empty)
[K, V] declares two type parameters, and the compiler holds you to them: get on a Store[Reference, Booking] takes a Reference and returns Option[Booking], and handing it a table number won’t compile. The class is written once, ignorant of what it stores, and every use site is fully typed. Functions take type parameters the same way:
def firstMatching[A](items: List[A], keep: A => Boolean): Option[A] =
items.find(keep)
The rule of thumb for when to reach for [A]: the moment a function would be copy-pasted with only the types changed, the types are the parameter.
9.2. Variance, Earned
Chapter 4 asked you to copy the ` in `Option[+A]` on faith. Payment time. The ` answers a substitution question: if a Seated is a BookingStatus, is a List[Seated] a List[BookingStatus]?
For immutable containers the answer is yes, and +A is how the container says so: List, Option and friends are covariant, safe because they only ever produce their A`s. A `List[Seated] handed to code wanting List[BookingStatus] can only disappoint by producing a Seated, which is fine, that IS a BookingStatus.
Function parameters run the other way, and a concrete function makes the flip traceable. Take val describe: BookingStatus ⇒ String, a formatter that copes with any status. Code that asks for a Seated ⇒ String only ever plans to feed it Seated values, and describe handles a Seated and more, so handing it over is safe. Scala’s function type declares exactly this: Function1[-A, +B], contravariant in what it consumes, covariant in what it produces. The general-eater substitutes for the picky one; consumption flips the direction. And types that both take and give, like Store’s `V, stay invariant, no marker, no substitution, because either direction can go wrong.
That’s variance for working purposes: + on what comes out, - on what goes in, nothing on what does both. The compiler enforces the annotations against the positions, so lying about it doesn’t compile. When in doubt, leave it invariant and let use sites tell you.
9.3. What for Actually Needs
Chapter 8 promised the answer, and it’s disarmingly small. for is desugaring, chapter 3 showed the translation, and the compiler’s only requirement is that the methods it desugars to exist by name. flatMap and map, present on the type, with workable signatures. No interface, no registration, no blessing. Which means chapter 4’s exercise built more than you knew:
val outcome =
for
a <- Maybe.Just(4)
b <- Maybe.Just(6)
yield a + b
// Just(10)
Your twenty-line Maybe for-comprehends, because it has flatMap and map, and that was the entire entrance exam. (A guard in the for would add withFilter to the bill, and for … do wants foreach; the exam grows with the syntax you use, by name, all the way.)
This shape, a type with a hole and a law-abiding flatMap, is famous: the literature calls it a monad, a word with a reputation it doesn’t deserve. "Law-abiding" is doing quiet work in that sentence, so let it speak once. The laws ask two things of flatMap: chaining must associate, so that stringing three steps together means the same thing however you group them, and the do-nothing wrapper (Just for your Maybe, Some, Right, IO.pure when you meet it) must genuinely do nothing when chained past. That’s the whole rulebook, and you’ve been relying on it unknowingly every time a for block behaved the way it read. Hold the pattern, an operation that associates plus an element that does nothing, because this chapter’s final section is about to charge for it under a different name.
This book will keep calling the shape "the shape"; you’ve used it three times without incident, and in Part III you’ll build the most consequential instance of it yourself. If anyone at a meetup asks, you’ve been monading since chapter 4.
9.4. Ordering: How the Compiler Finds Knowledge
Chapter 3’s report sorted `LocalTime`s and we said "the compiler finds that knowledge" like it was nothing. Here’s the mechanism, and it’s Part II’s last and best trick.
sortBy needs to compare keys, so its signature asks for evidence, which in Scala 3 spelling reads:
def sortBy[B](f: A => B)(using ord: Ordering[B]): List[A]
Ordering[B] is an ordinary trait, "I know how to compare B`s", and `using marks a parameter the compiler supplies by searching for a given instance of that exact type. The standard library declares givens for Int, String and the rest; LocalTime arrives via a bridge from Java’s Comparable. When none exists, you write one:
given Ordering[Table] = Ordering.by(_.seats)
room.tables.sorted
// List(Table(1,2), Table(7,4), Table(9,6))
One line teaches the whole language to sort tables: sorted, sortBy, max, min, every ordered operation, everywhere, without threading a comparator through call sites the way Java does. That’s the trade given/using makes: you declare knowledge once, the compiler delivers it wherever it’s demanded, and the demand is visible in signatures rather than hidden in globals.
Where do instances live, so the compiler can find them? In the companion object of the type they serve, by convention, and the compiler searches companions automatically. That sentence settles an old account: chapter 2 promised companions held "machinery we’ll care about in chapter 9", and this is it. Ordering[LocalTime] was findable because of where it lives, not luck, and when you write your own typeclass instances, the companion is where they go so that nobody ever has to import them.
Chapter 3 left an IOU too, about methods that take arguments in multiple bracket groups, and it settles here: everything the first group fixes is known before the compiler reads the second, whether that’s typing a lambda’s parameters or, now you know they exist, searching for a given. The grouping isn’t ceremony; it’s the order the compiler learns things in.
Two smaller notes make the mechanism liveable in the wild. In older libraries, the standard library included, you’ll read implicit where this book writes given/using: same machinery, earlier syntax, and you now read both dialects of this, too. And the sugar you’ll meet everywhere: def largest[A: Ordering](items: List[A]) is a context bound, shorthand for a using Ordering[A] parameter list.
9.5. Monoid: A Typeclass of Your Own
A trait some types have instances of, delivered by given, demanded by using: that arrangement is called a typeclass, and Ordering was one all along. (Chapter 2 promised traits a second life as the foundation of type-driven design; you’re looking at it.) They’re at their best describing capabilities with laws, and the workhorse capability of every reporting system is "these things combine":
trait Monoid[A]:
def empty: A
def combine(x: A, y: A): A
given Monoid[Int] with
def empty = 0
def combine(x: Int, y: Int) = x + y
given Monoid[BigDecimal] with
def empty = BigDecimal(0)
def combine(x: BigDecimal, y: BigDecimal) = x + y
(The with opens an anonymous instance implementing the trait’s members, the second spelling of given: the alias form assigned an existing value, this form builds one in place.)
A monoid is an associative combiner with a do-nothing element, nothing more; the name comes out of 1940s algebra and, like the other one, is scarier than the idea. What it buys is the general fold chapter 3 promised you’d meet again:
def combineAll[A](items: List[A])(using m: Monoid[A]): A =
items.foldLeft(m.empty)(m.combine)
foldLeft was the machine; the monoid supplies its two knobs; combineAll now aggregates anything combinable. And notice the rulebook: an operation that associates plus an element that does nothing, the exact pattern the monad section told you to hold. Monoid demands it of values as `flatMap’s laws demanded it of chaining; the algebra rhymes, and it’s the same rhyme all the way up this language. Watch it earn dinner. The nightly report wants covers and takings per evening, so make the report line itself combinable:
case class Takings(covers: Int, revenue: BigDecimal)
object Takings:
given Monoid[Takings] with
def empty = Takings(0, BigDecimal(0))
def combine(x: Takings, y: Takings) =
Takings(combineAll(List(x.covers, y.covers)),
combineAll(List(x.revenue, y.revenue)))
The Takings monoid is built from the Int and BigDecimal monoids, instances composing out of instances, and it lives in the companion where the compiler will find it. Now the whole night is one line:
val service = List(Takings(2, BigDecimal(64)), Takings(6, BigDecimal(180)),
Takings(5, BigDecimal(155)))
combineAll(service)
// Takings(13,399)
Add a byHour: Map[LocalTime, Int] field next month and no aggregation code changes anywhere: the Takings monoid absorbs the new field (with a map-merging instance, which exercise 2 has you build), and every combineAll in the codebase picks it up. That is the leverage the second Scala keeps talking about, and notice what it wasn’t: no ideology, no category theory entrance fee, one trait, two givens, and a fold.
9.6. Two Scalas, Checkpoint
Part II is done, so stand on the step and look down. In chapter 1 the second function, the IO-returning one, was unreadable. Look at what’s happened since, without IO being mentioned once: behaviour became values you compose (chapter 6), states and failures became types the compiler polices (chapters 7 and 8), and capabilities became instances the compiler delivers (this chapter). Every move was motivated by a restaurant, not a whiteboard. When someone asks what the FP fuss is actually about, that list is the answer.
One abstraction remains, and it’s the big one. Every function in Fully Booked still just does things; the program runs top to bottom and whatever happens, happens. Part III makes the program itself a value, the way policies became values in chapter 6, and the payoff is the last-table problem from chapter 3, solved properly at last. You’ve built a Maybe. Next you build an IO.
9.7. Exercises
Solutions are in the companion repo under ch09/exercises.
-
House ordering. The runners' list shows tonight’s bookings largest-party-first. Write
given Ordering[Booking]ordering by party size, descending (Ordering.bygets you ascending; the library has a method for turning an ordering around, and its name is guessable), and produce the sorted list. Then note what happens if two givens for the same type are in scope, on purpose: make it happen, read the error, and keep the lesson. -
Merging tallies. Chapter 3’s
sizeTallyproduced aMap[Int, Int]per evening. Writegiven Monoid[Map[Int, Int]]that merges maps by combining values for shared keys, thencombineAlla week of tallies. The instance is four lines and instantly reusable for every counting map you’ll ever build; that’s rather the point. -
The shape, certified. Take your chapter 4
Maybeand write the test provingforruns on it, including aNone-style short-circuit throughEmpty. You built a monad in chapter 4; now you have the receipt. File the feeling away for Part III.
Part III: Effects
10. Build Your Own IO
Fully Booked has been suspiciously well behaved. Nine chapters of code and barely a side effect: no database yet, no network, the odd println in a demo. That wasn’t an accident, and it ends now, because a booking system that never texts anyone or saves anything is a very elaborate calculator. Part III is where the real world arrives, and this chapter is about arriving on our terms.
The plan is one you’ve run before: to trust Option, you first built one (Maybe, chapter 4’s closing exercise, the one chapter 9 handed you a receipt for). Before trusting Cats Effect’s IO in chapter 11, you’ll build a working one in this chapter, small enough to hold in your head, real enough to run the case study’s first side effects. By the end, the second function from chapter 1, the one that "doesn’t seem to run anything at all so much as describe something that will run later", stops being alien, because you’ll have written the type that makes it true.
10.1. Substitution, Broken
Start with the property we’re about to lose. Everywhere in this book so far, a name and its definition have been interchangeable:
val price = quoteFor(chen, BigDecimal(60)).total
val doubled = price + price
Replace price with its right-hand side, or hoist a repeated expression into a val, and nothing changes. That’s substitution, the property every refactoring you’ve ever done quietly relies on, and it’s why chapters 2 to 9 could reason about code by reading it. Now watch a side effect murder it:
def sendSms(number: String, text: String): Unit =
println(s"SMS to $number: $text")
val sent = sendSms("07700 900123", "Your table is confirmed")
val twice = (sent, sent) // one message sent
val twice2 = (sendSms("07700 900123", "Your table is confirmed"),
sendSms("07700 900123", "Your table is confirmed")) // two
twice and twice2 are the same code by every rule Part I taught, and the diner’s phone disagrees: one text against two. The moment effects run on evaluation, hoisting an expression into a val changes behaviour, which means every refactoring becomes a small gamble, which is precisely the itch you learned to stop having. The compiler sees Unit and shrugs; the type doesn’t even admit anything happened.
10.2. Laziness Defers
Chapter 6 taught the fix without telling you. A function is a value, and a function that hasn’t been called yet has done nothing:
val send: () => Unit = () => sendSms("07700 900123", "Your table is confirmed")
val twice = (send, send) // no messages sent at all
Wrap the effect in a thunk, a zero-argument function, and substitution comes back: send is a description of sending, copyable, passable, storable in a list, and the phone stays silent until somebody calls send(). The problem is that raw thunks are miserable to work with. You can’t map one, chaining two of them means nested calls, and nothing in the type distinguishes "dangerous thunk of side effects" from any other function. What we want is a proper type around the thunk, with the combinators this book has taught you to expect. So build it.
10.3. A Type for Programs
case class IO[A](unsafeRun: () => A):
def map[B](f: A => B): IO[B] =
IO(() => f(unsafeRun()))
def flatMap[B](f: A => IO[B]): IO[B] =
IO(() => f(unsafeRun()).unsafeRun())
object IO:
def delay[A](thunk: => A): IO[A] = IO(() => thunk)
def pure[A](a: A): IO[A] = IO(() => a)
A dozen lines, and read them slowly because they’re the whole trick. An IO[A] is a program that, when run, produces an A. map builds a bigger program: run mine, then transform the result; nothing runs now. flatMap builds a bigger program from a program-producing function: run mine, hand the result to f, run the program f returns. Both return descriptions. The only place anything ever happens is inside unsafeRun, and the name is a convention you should keep: the word unsafe marks the door to the real world.
The two constructors matter too. IO.delay captures an effect without running it (⇒ A is a by-name parameter, the thunk trick with nicer syntax). IO.pure lifts a value you already have; there’s nothing to defer.
And because methods called flatMap and map exist, chapter 9’s entrance exam is passed:
def confirmationText(booking: Booking, table: Table): IO[Unit] =
val notify = booking.phone match
case Some(number) => IO.delay(sendSms(number, s"Table ${table.number} is yours"))
case None => IO.delay(logLine(s"${booking.reference}: no phone on file; desk to confirm"))
for
_ <- notify
_ <- IO.delay(logLine(s"confirmed ${booking.reference}"))
yield ()
Look at notify before the for: a match on plain data selects a program, text the diner or queue a note for the desk, and the chosen program is just a value awaiting composition. Then the for builds one bigger value: a program that will notify and then write the log line, in that order, when and only when someone runs it. Call confirmationText(chen, Table(9, 6)) a hundred times and nothing happens a hundred times; you’ve manufactured a hundred identical descriptions. Substitution holds again, effects included, because the effects are data now, exactly as policies became data in chapter 6.
unsafeRun performs it10.4. The End of the World
A program made of descriptions still has to run eventually, and the discipline is where: once, at the outermost edge, conventionally called the end of the world:
@main def tonight(): Unit =
val program =
for
_ <- confirmationText(chen, Table(9, 6))
_ <- confirmationText(marchetti, Table(7, 4))
yield ()
program.unsafeRun()
Everything above the last line is pure assembly, testable by inspection, reorderable, refactorable with Part I confidence. The last line is the only place anything is performed; sendSms and logLine remain impure primitives, and every other line in the file merely describes arrangements of them. That separation, effects described everywhere and performed in one place, is the entire architecture of the second Scala, and you’ve now built it from a case class and two methods.
10.5. The Error Channel
Chapter 8’s rule was "exceptions stop at the border", and IO wraps exactly the code that throws. So give the type its own border control, a new method inside the case class (with scala.util.Try imported at the top of the file):
def attempt: IO[Either[Throwable, A]] =
IO(() => Try(unsafeRun()).toEither)
attempt turns a program that might explode into a program that returns honestly: run it, and you get a Right(a) or a Left(exception), chapter 8’s type carrying chapter 8’s promise into effectful code. A flaky SMS gateway becomes data:
def confirmWithFallback(booking: Booking, table: Table): IO[Unit] =
confirmationText(booking, table).attempt.flatMap {
case Right(_) => IO.pure(())
case Left(_) => IO.delay(logLine(s"SMS failed for ${booking.reference}; front desk to call"))
}
The failure handling is a pattern match on values, composable and testable, and no try/catch interrupts the flow of the program being assembled.
10.6. What We Just Ignored
Our IO works, and this section is the honesty that keeps it from being a lie. Three problems are waiting for anyone who ships it.
First, the stack. Every flatMap nests a call inside unsafeRun, so a long enough chain, a loop retrying a flaky gateway all night, say, dies with a StackOverflowError. Real implementations run a loop over a data structure instead of nesting calls (the technique is called trampolining), and it’s the difference between a teaching type and a runtime.
Second, concurrency. Our programs run one instruction after another on whatever thread calls unsafeRun. The last-table problem from chapter 3 is still sitting there, unsolved, waiting for two of these programs to run at once; nothing in a dozen lines addresses scheduling, and chapter 12 needs machinery that does.
Third, safety around resources: files, connections and sockets need closing even when the program between open and close fails, and our attempt is nowhere near enough to guarantee it.
Chapter 11 introduces Cats Effect’s IO, which is this chapter’s type with a decade of engineering where our gaps are: a trampolined, fiber-scheduling, resource-safe runtime behind the same map, flatMap, attempt face you just built. You now know exactly which parts are the idea and which parts are the engineering, which is the only respectable way to adopt a library this central.
10.7. Exercises
Solutions are in the companion repo under ch10/exercises.
-
Sequencing the night. Write
def sequence[A](programs: List[IO[A]]): IO[List[A]]that turns a list of programs into one program producing all the results, in order.foldLeftandflatMapwill do it; chapter 9’scombineAllis the moral prior art. Then send confirmations for a whole evening’s bookings with oneunsafeRun. -
Retry, described. Write
def retry[A](program: IO[A], attempts: Int): IO[A]that runs the program, and on failure tries again up to the limit, usingattemptand recursion. Test it with a program that fails twice then succeeds (avarin the test is honest containment, chapter 3’s rule). Then, for the lesson: make it retry a hundred thousand times against a program that always fails, and meet the stack problem from the honesty section personally. (Ten thousand survives on a default JVM stack; we checked, and now so can you.) -
Timing the kitchen. Write
def timed[A](program: IO[A]): IO[(A, Long)]returning the result alongside elapsed milliseconds.System.nanoTimeis a side effect, so it belongs inside the description; getting it on both sides of the inner program without running anything early is the exercise.
11. Cats Effect at Work
Chapter 10 ended with a confession: our IO melts under a hundred thousand retries, knows nothing of concurrency, and can’t guarantee a file gets closed. This chapter swaps it for the real one. Cats Effect is the library that took the idea you built and spent a decade on the engineering, and the reason we built the toy first is about to pay off: the face is the same. pure, delay, map, flatMap, attempt, a for that assembles programs, one run at the edge. What changes is everything behind the face.
One line in project.scala and one import:
//> using dep org.typelevel::cats-effect::3.7.0
import cats.effect.*
11.1. The Same Face
Everything from chapter 10 translates by renaming almost nothing:
val greet: IO[Unit] = IO.println("table for two, 8pm")
val lookup: IO[Int] = IO(computeCovers()) // IO.apply is our delay; computeCovers is any plain function
val ready: IO[Int] = IO.pure(42) // already have the value
def confirmationText(booking: Booking, table: Table): IO[Unit] =
val notify = booking.phone match
case Some(number) => IO(sendSms(number, s"Table ${table.number} is yours"))
case None => IO(logLine(s"${booking.reference}: no phone on file; desk to confirm"))
for
_ <- notify
_ <- IO(logLine(s"confirmed ${booking.reference}"))
yield ()
IO(…) is chapter 10’s delay under its everyday name, IO.pure is strict exactly as ours was, and the for builds a description exactly as ours did. Your mental model transfers whole; that was the point of building it. The first difference is the promise kept: take chapter 10’s retry exercise, port it verbatim to this IO, and the hundred-thousand-retry run that killed the toy completes without a murmur, because the real runtime executes flatMap chains as a loop over a data structure rather than nested calls. The stack problem is engineering, and the engineering is done.
11.2. IOApp: The End of the World, Institutionalised
Chapter 10 called unsafeRun() by hand. Cats Effect would rather you never did:
object Tonight extends IOApp.Simple:
def run: IO[Unit] =
for
_ <- confirmationText(chen, Table(9, 6))
_ <- confirmationText(marchetti, Table(7, 4))
yield ()
IOApp.Simple owns the edge: you hand it the program, it builds the runtime, runs the program, and tears everything down, including on Ctrl-C. The unsafe family still exists, and you’ll only ever call it in tests. Everywhere else, the discipline from chapter 10 hardens into a rule of thumb with teeth: if you’re calling unsafeRunSync outside a test or a main, you’re building a second end of the world, and one apocalypse per application is plenty.
11.3. Errors, Properly
attempt is here and does what yours did, IO[A] ⇒ IO[Either[Throwable, A]]. The real library adds the other direction and the recovery combinators:
val boom: IO[Int] = IO.raiseError(new RuntimeException("gateway down"))
val recovered: IO[Int] =
boom.handleErrorWith(_ => IO.pure(0))
val logged: IO[Int] =
boom.onError(e => IO(logLine(s"gateway trouble: ${e.getMessage}")))
raiseError builds a failed program as a value; handleErrorWith is flatMap for the failure channel; onError peeks without recovering. Use them for the exceptional layer, and keep chapter 8’s habit for the domain layer: business failures travel as Either[BookingError, A] inside the IO, typed and exhaustive, while `IO’s own channel carries the genuinely exceptional (the gateway caught fire). Two channels, two jobs, and the worst error-handling code you’ll ever read comes from mashing them into one. Chapter 13 makes a discipline of the rule, once concurrency has raised the stakes.
11.4. Resource: try-with-resources, as a Value
Java’s answer to "close the file even when things explode" is try-with-resources, a statement you must remember to write at every use site. Cats Effect’s answer is a value:
import java.io.PrintWriter
def bookingLog(path: String): Resource[IO, PrintWriter] =
Resource.make(
IO(new PrintWriter(path)) // acquire
)(writer => IO(writer.close())) // release: runs no matter what
A Resource[IO, A] packages acquisition and release as one value, and use brackets any program with them:
val record: IO[Unit] =
bookingLog("tonight.log").use { log =>
for
_ <- IO(log.println("FB-1042 confirmed"))
_ <- IO(log.println("FB-1043 confirmed"))
yield ()
}
The guarantee is total: release runs if the inner program succeeds, if it fails, and, once chapter 12 introduces cancellation, if it’s cancelled halfway through. Because Resource`s are values, they compose in a `for, and release runs in reverse order of acquisition, the way nested try-with-resources would:
case class SmsGateway(url: String)
def gateway(url: String): Resource[IO, SmsGateway] = ???
val everything: Resource[IO, (PrintWriter, SmsGateway)] =
for
log <- bookingLog("tonight.log")
sms <- gateway("sms://provider")
yield (log, sms)
Acquire the log, then the gateway; release the gateway, then the log. Last in, first out, guaranteed, and written once where the resources are defined instead of remembered at every call site. When Part IV opens database connections and HTTP servers, they’ll all be `Resource`s, and this section is why nothing in this book ever leaks.
11.5. The Runtime, at This Altitude
Two facts about what IOApp built for you, at the altitude this chapter needs.
First, your programs run on fibers: lightweight, runtime-managed threads-of-logic, thousands of which share a handful of JVM threads. Chapter 12 is entirely about them; today’s relevant consequence is that IO.sleep(30.seconds) parks a fiber without holding a thread hostage, which is why the retry-with-backoff chapter 13 builds costs nothing while it waits. (The duration syntax comes from import scala.concurrent.duration.*, which turns plain numbers into typed spans: 30.seconds, 250.millis. The type they make is FiniteDuration, the name to write in signatures.)
Second, the runtime assumes your programs don’t block threads, because fibers share so few of them. When you must call something that genuinely blocks, JDBC being Part IV’s headline offender, wrap it:
val rows: IO[Int] = IO.blocking(runBigJdbcQuery())
IO.blocking shifts the work to a thread pool built for waiting, and the compute pool stays free. The rule costs one word and prevents the classic production mystery of an application with 5% CPU that can’t serve requests. File it now, thank yourself in chapter 18.
11.6. Configuration Without Tears
Real services read config, and Fully Booked is about to need a gateway URL and a covers limit. The honest small tool is PureConfig, reading HOCON (the human-friendly JSON superset that application.conf files are written in) into case classes:
//> using dep com.github.pureconfig::pureconfig-core::0.17.9
# resources/application.conf (declare the folder once: //> using resourceDir resources)
gateway-url = "sms://provider.example"
max-covers = 40
import pureconfig.*
import pureconfig.error.ConfigReaderFailures
case class AppConfig(gatewayUrl: String, maxCovers: Int) derives ConfigReader
val config: Either[ConfigReaderFailures, AppConfig] =
ConfigSource.default.load[AppConfig]
Note the key spelling: PureConfig maps gatewayUrl to gateway-url, camelCase in code and kebab-case on disk, by convention. Get the file’s spelling wrong and the failure at least names the key it wanted.
derives ConfigReader is a new keyword on familiar machinery: it asks the compiler to write the ConfigReader given from the case class shape and leave it in the companion, where chapter 9 taught you instances are found. load returns an Either whose Left names every missing or mistyped key at once, chapter 8’s accumulation done by someone else this time. Wrap the load in IO at the edge, fail fast at startup, and configuration stops being the thing that breaks at 2am. (Ciris deserves its mention: a more FP-idiomatic config library, loved in Typelevel shops; PureConfig gets the nod here because HOCON is the format Java hands already know.)
11.7. Logging Is a Program Too
Chapter 10’s logLine was a println in a costume. The grown-up version is log4cats, where a logger is a value and every log line is an IO:
//> using dep org.typelevel::log4cats-slf4j::2.7.1
//> using dep org.slf4j:slf4j-simple:2.0.17
(The second line is a backend; SLF4J without one logs nothing and says so once, quietly. slf4j-simple does for development; production swaps in Logback without touching your code.)
import org.typelevel.log4cats.Logger
import org.typelevel.log4cats.slf4j.Slf4jLogger
def confirmAll(bookings: List[Booking], logger: Logger[IO]): IO[Unit] =
bookings.foldLeft(IO.unit) { (programSoFar, booking) =>
for
_ <- programSoFar
_ <- logger.info(s"confirming ${booking.reference}")
yield ()
}
logger.info(…) returns IO[Unit]: a log line you can compose, reorder, and decline to perform when a test runs the logic with a no-op logger. It rides on SLF4J underneath, so the operational half, appenders, levels, JSON output for the log aggregator, is the Logback configuration your ops team already speaks.
11.8. Fully Booked at Work
The assembled application, ch11/ in the repo:
object FullyBooked extends IOApp.Simple:
def run: IO[Unit] =
for
loaded <- IO(ConfigSource.default.load[AppConfig])
config <- IO.fromEither(
loaded.left.map(f => new RuntimeException(f.prettyPrint())))
logger <- Slf4jLogger.create[IO]
_ <- logger.info(s"gateway: ${config.gatewayUrl}, cap: ${config.maxCovers} covers")
_ <- bookingLog("tonight.log").use { log =>
confirmAll(List(chen, marchetti), logger)
.flatTap(_ => IO(log.println("evening confirmed")))
}
yield ()
Config loaded inside IO (reading files is an effect, and chapter 10 taught you to spot the eager version) and failed-fast, IO.fromEither promoting chapter 8’s type into the error channel. A logger built once and passed where it’s needed, a file that cannot leak, and the evening’s confirmations composed in between. One new combinator slipped in: flatTap runs an effect for its side effect and keeps the original result; it’s `flatMap’s quieter sibling, and it is everywhere in real codebases.
What this application still can’t do is take two bookings at the same time, and the front door opens in chapter 12.
11.9. Exercises
Solutions are in the companion repo under ch11/exercises.
-
Retry, verified. The runtime section claimed chapter 10’s
retry, ported verbatim, survives a hundred thousand failures where the toy died. Claims are cheap: port it, run it, time it, and record in a comment why the real runtime’sflatMaploop makes the difference. Retry grows backoff, jitter and deterministic tests in chapter 13. -
Release order, proven. Build two
Resource`s that log acquire and release into a mutable trail (a `varin the test, honestly contained), compose them in afor, and write the assertion proving last-in-first-out release, success and failure paths both. -
The config that lies. Give a config class an optional field (
Option[String]), then try givingmaxCoversa Scala default parameter and watch what happens when the key is absent. The discovery is the exercise: the derivation doesn’t honour Scala defaults, so model optionality withOptionand apply defaults in code, knowingly. Finish by pinning the failure message for a genuinely missing required key, because the 2am you deserves an error that names it.
12. Concurrency
Chapter 3 planted a bomb and this chapter finally defuses it. The naive book checked, then acted, and we promised that the moment Fully Booked became a web service, two parties would ask for the last 8pm table at the same instant, both would pass canSeat, and both would be booked. Nine chapters later the case study has types, errors, effects and a runtime, and it still can’t take two requests at once. Tonight it learns, and the bomb goes off first.
12.1. The Race, Reproduced
Cats Effect can run two programs at the same time, which means it can run our bug at the same time:
def bookNaively(store: Ref[IO, Schedule], booking: Booking): IO[Boolean] =
for
schedule <- store.get // snapshot: one table left at 8pm
free <- IO.pure(canSeat(schedule, room, eight, booking.party))
_ <- IO.sleep(10.millis) // the request does some work
result <- if free then store.set(book(schedule, room, eight, booking)).as(true)
else IO.pure(false)
yield result
def bothAtOnce(store: Ref[IO, Schedule]): IO[(Boolean, Boolean)] =
(bookNaively(store, chen), bookNaively(store, novak)).parTupled
(Ref gets its proper introduction two sections down; for now, store.get reads a shared cell and store.set writes it, and .as(true) replaces a result the way .void discards one. parTupled runs both programs concurrently and pairs the results.) Run it and the outcome is worse than a double-booking: both checks saw the same snapshot with one free slot, so both parties are told yes, and then the second write, built from that same stale snapshot, overwrites the first. One free table, two happy confirmation texts, and one of the two bookings has vanished from the schedule entirely. The maître d' doesn’t even get a warning to improvise around. Note what didn’t save us: the shared cell itself is thread-safe, every data structure in sight is immutable, and the bug shipped anyway. The flaw is the gap between check and act, and no amount of immutable data closes a gap in time.
The failing run is in the companion repo’s tests, deliberately, permanently: ch12/ keeps a test green by asserting the bug exists: two yeses, one lost update. It’s the book’s yardstick, and now it’s executable.
12.2. Fibers: Concurrency as Values
First, what parTupled stands on. The runtime runs every program on a fiber: a lightweight thread-of-logic that costs a few hundred bytes, not a megabyte of stack, so an application can hold hundreds of thousands of them. Starting one is an effect; the handle is a value:
val meanwhile: IO[Unit] =
for
fiber <- confirmationText(chen, Table(9, 6)).start
_ <- IO(logLine("front desk keeps moving"))
_ <- fiber.join.void
yield ()
start launches the program on its own fiber and hands back control immediately, plus a Fiber value you can join (wait for) or cancel. And cancellation is where fibers stop being a performance story and start being a correctness one: a cancelled fiber stops at the next safe point, and every Resource acquired inside it releases on the way out, chapter 11’s guarantee extending to programs that die young.
Day to day you’ll rarely touch start directly, for the same reason chapter 3 rarely wrote loops: the combinators say it better. (When you genuinely want a long-lived background fiber, the grown-up spelling is program.background, which is start wrapped in a Resource so the fiber’s lifetime is tied to a scope and cancellation comes free.)
val bothTexts: IO[Unit] =
(confirmationText(chen, Table(9, 6)),
confirmationText(marchetti, Table(7, 4))).parTupled.void
val firstAnswer: IO[Either[Int, Int]] =
IO.sleep(50.millis).as(1).race(IO.pure(2))
parTupled (and its N-ary family, parMapN) is structured concurrency: both fibers start together, and if either fails, the other is cancelled and cleaned up, no orphans. race runs two programs and keeps whichever finishes first, cancelling the loser; speed picks the winner, and the winner’s result, failure included, is what you get. (.as(1) above replaces a program’s result with a constant.) The discipline you get for free here took Java until Loom-era structured concurrency to name.
12.3. Ref: Shared State Without Tears
The race needs shared state that two fibers can touch safely. Cats Effect’s primitive is Ref, a mutable cell whose operations are atomic programs:
val counter: IO[Int] =
for
ref <- Ref.of[IO, Int](0)
_ <- List.fill(1000)(ref.update(_ + 1)).parSequence
total <- ref.get
yield total
// always 1000; update is atomic
(parSequence runs a whole list of programs concurrently, chapter 10’s sequence exercise with the brakes off.)
update takes a pure function and applies it atomically: no lost increments, no locks in your code. But atomic update alone doesn’t fix check-then-act, because our bug needs the check and the act inside one atom. That’s modify:
def bookSafely(store: Ref[IO, Schedule], booking: Booking): IO[Boolean] =
store.modify { schedule =>
if canSeat(schedule, room, eight, booking.party)
then (book(schedule, room, eight, booking), true)
else (schedule, false)
}
modify receives the current state and returns the new state plus a result, and the whole function runs as one atomic step. The check and the act can no longer be interleaved because they’re no longer two things: chapter 3’s pure canSeat and book slot in unchanged, which is the quiet payoff of writing them as pure functions nine chapters ago. Run the same two-parties-one-table assault a thousand times and exactly one booking lands, every time; the repo test does exactly that, a thousand rounds straight. Notice, too, what those thousand rounds share without a flicker of ceremony: the same immutable initialSchedule, handed to every round unguarded, because nothing can corrupt it. Chapter 5 promised the immutability discipline would pay a larger dividend once things ran at the same time; this is the dividend.
One honest boundary: Ref protects this process’s memory. When Fully Booked runs on two servers behind a load balancer, two `Ref`s can’t see each other, and the same race reappears one level up. That version of the bomb is chapter 18’s, and the database defuses it.
12.4. Deferred: Waiting for a Value
Ref is state; Deferred is a promise: a cell that starts empty, is completed exactly once, and parks any fiber that reads it until the value arrives. (One small spelling first: *> sequences two programs and keeps the second’s result, `flatMap’s "and then" for when the first result is uninteresting.)
def kitchenReady(signal: Deferred[IO, Unit]): IO[Unit] =
IO.sleep(100.millis) *> signal.complete(()).void
val serviceOpens: IO[String] =
for
signal <- Deferred[IO, Unit]
_ <- kitchenReady(signal).start
_ <- signal.get // parks, costs nothing
yield "doors open"
Between them, Ref and Deferred are the atoms of coordination; the ecosystem’s fancier tools, queues and semaphores among them, are built from these two, and now you can read those tools' documentation knowing what’s underneath.
12.5. Fully Booked, Concurrent at Last
The evening, under load, lives in ch12/ in the repo:
object OpenNight extends IOApp.Simple:
def run: IO[Unit] =
for
store <- Ref.of[IO, Schedule](Map.empty)
outcome <- rushOfRequests.parTraverse(b =>
bookSafely(store, b).map(b.reference -> _))
taken <- store.get
_ <- IO(logLine(s"${outcome.count(_._2)} seated, " +
s"${outcome.count(!_._2)} turned away, " +
s"${coversAt(taken, eight)} covers at eight"))
yield ()
parTraverse is parMapN’s list-sized sibling: one `bookSafely fiber per request, results collected in order, a rush of requests against shared state. The accounting always balances: seats booked plus refusals equals requests, and covers never exceed the room. Three chapters of Part III in one screen: programs as values, a runtime to race them, and state that can’t tear.
What the refusals still lack is a reason, and you know which chapter’s pattern fixes that; wiring BookingError through concurrent code changes nothing you’ve learned. There’s a subtler gap, too: tonight nothing broke. The gateway answered every time, no fiber died mid-booking, and the only failures were the polite kind the types already carry. Concurrency multiplies the ways a program can be mid-flight when something gives, which is why failure gets the next chapter to itself.
12.6. Exercises
Solutions are in the companion repo under ch12/exercises.
-
The overbooking detector. Write the property this chapter’s fix guarantees as a reusable test helper: run N concurrent
bookSafelyattempts against a schedule with K free slots and assert exactlymin(N, K)succeed. Then point it atbookNaivelyand watch it fail, which is the yardstick doing its job in both directions. -
Timeout at the door.
racea slow confirmation (IO.sleep) against a timeout, returningEither[Timeout, Confirmed]. Then find the combinator the library already ships for this, because after building it you’ve earned the shortcut, and its name is guessable. -
The doors open. Every evening starts with a signal. Build a
Deferred[IO, Unit]as the doors-open gate, start eight booking fibers that each wait on.getbefore callingbookSafely, then complete the gate once. Assert nothing books before the signal and that the accounting balances after; `Deferred’s one-shot guarantee is the exercise.
13. When Effects Fail
Chapter 12 ended on a suspicious note: nothing broke. Eight concurrent bookings raced through bookSafely and the only refusals were the polite kind, a false from a full slot, exactly the outcome the types promised. Real evenings are less tidy. The SMS gateway from chapter 10 was introduced as "flaky" and has been on its best behaviour ever since; a fiber can die halfway through its work; a confirmation can fail after the booking it confirms has already committed. Chapter 8 gave failure a type system. This chapter gives it a runtime.
The tools are mostly in hand. Chapter 10’s attempt turned an explosion into a value; chapter 11 added raiseError, handleErrorWith and onError, and stated a rule it didn’t fully defend: business failures travel as Either inside the IO, while `IO’s own channel carries the genuinely exceptional. This chapter defends the rule and puts it to work: the case study’s first subsystem that fails for a living, and an answer for the day a fiber is the thing that dies.
13.1. The Two Channels
An IO[A] can fail two ways, and they are different kinds of thing.
The first is the Throwable channel: raiseError, and everything the JVM throws on its own initiative. It is untyped in the way exceptions always were, invisible in the signature, exhaustiveness-proof, and it short-circuits a for the way the first Left did in chapter 8. The second is whatever failure type you put inside the IO: an IO[Either[NotificationError, Unit]] says, in its signature, that this program can fail in enumerable ways and hands the compiler the enumeration.
The design question is which failures earn which channel, and the rule this book uses is about audience. A failure the domain has words for, the slot is full, the booking has no phone number, the gateway said no, belongs in the typed channel, because some caller will match on it and do something different for each case. A failure the domain has no words for, the socket reset, the heap filled, the certificate expired, the DNS lookup hung, belongs in the Throwable channel, because no caller can do anything smarter than log it, retry it, or die, and those responses don’t need to know the exception’s name. Chapter 8 put checked exceptions on trial for making every caller handle what almost none of them could; the two-channel rule is the same verdict applied to effects. Type what callers can act on. Let the rest stay exceptional.
That rule decided, the rest of the chapter is spent keeping each channel honest.
13.2. A Subsystem That Fails for a Living
Fully Booked confirms bookings by text message, and the gateway that sends them is the least reliable component the restaurant owns. Enumerate what the domain can say about that:
enum NotificationError:
case GatewayTimeout
case GatewayRejected(code: Int)
case NoContactOnBooking(reference: String)
Chapter 7’s move, on the failure channel of an effectful subsystem. The raw gateway itself lives at the bottom, impure and throwing, in the manner chapter 10 taught you to expect; its documentation names two exceptions of its own, GatewayTimeout and GatewayRefusal(code). One border function maps each onto the enum and re-raises anything else (the _: GatewayTimeout arm matches by type, Java’s instanceof wearing pattern clothing):
def sendSms(gateway: SmsGateway, number: String, text: String)
: IO[Either[NotificationError, Unit]] =
rawSend(gateway, number, text).attempt.flatMap {
case Right(()) => IO.pure(Right(()))
case Left(_: GatewayTimeout) => IO.pure(Left(NotificationError.GatewayTimeout))
case Left(GatewayRefusal(code)) => IO.pure(Left(NotificationError.GatewayRejected(code)))
case Left(other) => IO.raiseError(other)
}
Read the last arm carefully, because it carries the design. attempt catches everything, and the temptation is to translate everything, a case Left(other) ⇒ Left(GatewayRejected(0)) to make the types tidy. Resist it. The two exception types the gateway documents become domain vocabulary; anything else is genuinely exceptional and goes back where it came from, raiseError returning it to the channel that suits it. The border translates what it understands and refuses to launder what it doesn’t. This is chapter 8’s boundary discipline restated for effects: checking happens once, where the dirt is, and the types are clean on the far side.
NoContactOnBooking is manufactured a level up, where the booking is in view:
def notifyDiner(gateway: SmsGateway, booking: Booking, text: String)
: IO[Either[NotificationError, Unit]] =
booking.phone match
case Some(number) => sendSms(gateway, number, text)
case None => IO.pure(Left(NotificationError.NoContactOnBooking(booking.reference)))
No exception was ever involved: a missing phone number is chapter 4’s ordinary absence, and it becomes a typed failure at exactly the point the absence means something. (It will surprise nobody that the case study’s first NoContactOnBooking belongs to the Okafors, who have been declining to leave a phone number since chapter 4.) The enum doesn’t care which of its cases were born from exceptions and which from None; callers match on all three the same way.
13.3. Recovery Is Just Composition
handleErrorWith recovers the Throwable channel, and chapter 11 showed the shape. The typed channel needs no new machinery at all, because its failures are values and you already own every tool that works on values. The Marchettis left a phone number and an email address; if the text times out, the restaurant would rather send an email than shrug:
def notifyWithFallback(gateway: SmsGateway, booking: Booking, text: String)
: IO[Either[NotificationError, Unit]] =
notifyDiner(gateway, booking, text).flatMap {
case Left(NotificationError.GatewayTimeout) =>
booking.email match
case Some(address) => sendEmail(address, text)
case None => IO.pure(Left(NotificationError.GatewayTimeout))
case other => IO.pure(other)
}
(The email gateway, for today, is boring and works.) One flatMap, two matches, and the policy reads off the page: a timeout falls back if there is somewhere to fall, and a timeout with no email address stays a timeout, because renaming a failure is not handling it. Notice what does not fall back. A GatewayRejected(422) means the gateway understood the request and said no, and sending an email instead would paper over a bug in our payload; a NoContactOnBooking can’t be rescued by a different channel that also has no address. Recovery is a policy decision per failure case, which is the entire argument for enumerating the cases: the match forces the decision to be made, case by case, in one place a reviewer can read. Blanket recovery, the handleErrorWith(_ ⇒ fallback) that rescues everything including the failures you’d rather know about, is the effectful cousin of catch (Exception e) {}, and it earns the same code review.
13.4. Retry, Properly
Some failures deserve a second attempt before any fallback, and a timeout is the canonical example. Chapter 10’s exercise built retry; chapter 11 ported it and proved the runtime runs it flat. Here is its grown-up form, and it earns its place in the chapter body because production retry is a policy, and policies deserve better than exercise status:
def retryWithBackoff[A](program: IO[A], attempts: Int, delay: FiniteDuration): IO[A] =
program.attempt.flatMap {
case Right(a) => IO.pure(a)
case Left(e) =>
if attempts > 1 then IO.sleep(delay) *> retryWithBackoff(program, attempts - 1, delay * 2)
else IO.raiseError(e)
}
The doubling is the point of the name: one second, then two, then four, giving a struggling gateway room to breathe instead of kicking it on a fixed schedule. Because IO.sleep parks a fiber rather than a thread, a thousand bookings retrying costs the machine effectively nothing while they wait; chapter 11 planted that fact, and this is the function it was planted for. Two honesty notes before you ship it. First, production retry adds jitter, a random fraction on each delay, because a hundred clients that failed together and back off on identical schedules will return together, a stampede on a timetable; exercise 1 adds it. Second, retry composes with the typed channel awkwardly on purpose: this retryWithBackoff retries the Throwable channel, and if you want to retry a Left, you must say which Left, because retrying a GatewayRejected(422) sends the same doomed payload three times with pauses. The types make the lazy option noisy, which is the types working. Here is the typed spelling, since the subsystem is about to want it:
def retryOn[E, A](retryable: E => Boolean)(program: IO[Either[E, A]],
attempts: Int, delay: FiniteDuration): IO[Either[E, A]] =
program.flatMap {
case Left(e) if retryable(e) && attempts > 1 =>
IO.sleep(delay) *> retryOn(retryable)(program, attempts - 1, delay * 2)
case other => IO.pure(other)
}
Same skeleton, one predicate parameter, and the policy is explicit where it belongs, at the call site: retryOn[NotificationError, Unit](_ == NotificationError.GatewayTimeout)(send, 3, 250.millis) retries the failure that deserves it and leaves the rejection alone.
How do you test delays without sleeping through them? The delays are descriptions, so a different runtime can interpret them against a fake clock. That runtime ships in cats-effect-testkit (//> using test.dep org.typelevel::cats-effect-testkit::3.7.0), and its front door is one function:
val timed =
for
start <- IO.monotonic
_ <- retryWithBackoff(alwaysFails, attempts = 3, delay = 1.second).attempt
finish <- IO.monotonic
yield (finish - start).toSeconds
TestControl.executeEmbed(timed) // IO[Long]: answers 3, instantly
executeEmbed runs the program on a virtual clock that only advances when every fiber is parked waiting, so the one-second and two-second waits "pass" without passing, and a retry policy’s whole personality becomes a fast, deterministic assertion. That’s a preview: chapter 19 makes TestControl a star. The repo’s ch13 suite uses exactly this shape, and so do this chapter’s exercises.
13.5. Cleanup You Can Trust
Failure interacts with everything the program was holding when it happened. Chapter 11’s Resource covers the big structural cases, acquisition paired with release. Two smaller combinators cover the local ones:
def audited(gateway: SmsGateway): IO[Either[NotificationError, Unit]] =
notifyWithFallback(gateway, marchettis, "Table 7 is yours")
.guarantee(IO(logLine("notification attempt recorded")))
guarantee runs its finaliser on success, on failure, and on cancellation, chapter 12’s third way for a fiber to stop; onError runs only when the Throwable channel fires, useful for failure-path logging that would be noise on the happy path. The distinction from Resource is scope: Resource is for things with a lifetime, acquired now and released later around arbitrary code in between, while guarantee is for a single program that must leave a mark regardless of how it ends. If you find yourself writing guarantee around acquisition, you’ve rebuilt Resource with fewer promises; let chapter 11 keep that job.
13.6. Fibers Fail Too
Everything so far fails on the fiber that asked. Chapter 12 taught programs to run on fibers nobody is watching, and that changes the accounting. Run the notification in the background while the front desk moves on:
def fireAndForget(gateway: SmsGateway): IO[Unit] =
for
_ <- notifyDiner(gateway, marchettis, "Table 7 is yours").start
_ <- IO(logLine("front desk moves on"))
yield ()
If the gateway throws something genuinely exceptional now, nothing happens that your program can act on. The runtime is not quite silent, in fairness: an unhandled fiber failure is reported to stderr by default, a stack trace for whoever reads the logs. But no code reacts, no fallback fires, and the diner simply never learns their table is ready. A .start you never join is a promise to ignore whatever that fiber has to say, including its dying words. The fix is to hold the receipt: joinWithNever waits and propagates, re-raising the fiber’s failure on the fiber that joins, so a background failure becomes your failure at the moment you ask after it. And when the background work should live exactly as long as some enclosing scope, the library has already made the pairing: program.background is start wrapped in a Resource, cancellation on release guaranteed, the supervised spelling chapter 12 flagged, and supervision, not hope, is how grown services run things in the background.
13.7. Fully Booked So Far
The evening, with the gateway in a mood, ch13/ in the repo:
def notifyHard(gateway: SmsGateway, booking: Booking, text: String)
: IO[Either[NotificationError, Unit]] =
retryOn[NotificationError, Unit](_ == NotificationError.GatewayTimeout)(
notifyWithFallback(gateway, booking, text), attempts = 3, delay = 250.millis)
def frontDeskLedger(gateway: SmsGateway): IO[List[String]] =
tonightsConfirmed.traverse { booking =>
notifyHard(gateway, booking, "Your table is confirmed").map {
case Right(()) => s"${booking.reference}: sent"
case Left(NotificationError.GatewayTimeout) => s"${booking.reference}: gave up"
case Left(NotificationError.GatewayRejected(code)) => s"${booking.reference}: rejected, code $code"
case Left(NotificationError.NoContactOnBooking(ref)) => s"$ref: no contact on file"
}
}
(traverse is `parTraverse’s sequential sibling from chapter 12: same shape, one at a time.) The repo’s suite drives this against a gateway scripted to misbehave, and asserts the full ledger: the Marchettis' text times out and the email lands instead; the Chens, email-less, get their text when the retry’s second attempt goes through; the Dubois' rejection is reported with its code after exactly one gateway call; the Okafors, contactless since chapter 4, are named rather than silently skipped. (A genuinely unknown explosion still fails the program loudly; a separate test proves that one.) Every failure the domain has words for reaches the front desk as a sentence; everything else stays an exception, which is what the two channels were for.
One limit remains, and it’s about size: traverse wants the whole evening in hand before it starts. Chapter 12 processed a rush; real restaurants produce a feed, bookings and cancellations and walk-ins arriving all night, and processing arrivals as they arrive is a different shape of program. That shape is chapter 14’s.
13.8. Exercises
Solutions are in the companion repo under ch13/exercises.
-
Jitter and a ceiling. Extend
retryWithBackoffwith both production trimmings: a random jitter factor on each delay (IO(Random.between(0.8, 1.2))is enough) and a maximum delay cap so the doubling can’t reach minutes. Prove the timing withTestControl, exactly as the retry section previewed: assert the total virtual time falls inside the jittered bounds, and that the cap holds after the doubling would have passed it. -
The breaker that trips. After three consecutive failures, a polite client stops calling for a while. Build
CircuitBreaker.protect(program)around aRefcounting consecutive failures: under the limit, call through and reset on success; at the limit, fail fast with a typedCircuitOpenand never touch the gateway. Write the test proving the gateway sees exactly three calls no matter how many bookings ask. Chapter 12’sRefdoing error-handling work is the point. -
The channel argument. A colleague moves
NoContactOnBookinginto theThrowablechannel: "it’s simpler, everything fails the same way." Write the paragraph, as a comment in the solution file, that talks them out of it, then move it and watch which call sites stop compiling and which silently change behaviour. The compiler’s list is your paragraph’s evidence.
14. Streams
Chapter 13 left one admission on the table: traverse wants the whole evening in hand before it starts. For four confirmed bookings that’s fine. The old reservation system Fully Booked is replacing exports its history as a file, one booking per line, eleven years of Friday nights, and "read it all into a List, then begin" stops being a plan somewhere around the tenth anniversary. Data that arrives over time, or in volumes that don’t fit in memory, wants a program shaped like itself: something that flows.
That something is fs2, the streaming library underneath most of the ecosystem you’re about to meet in Part IV, and one dependency completes Part III’s toolkit:
//> using dep co.fs2::fs2-core::3.13.0
//> using dep co.fs2::fs2-io::3.13.0
14.1. A Described Sequence
fs2’s Stream[IO, A] is to List[A] what IO[A] is to A: a description of a sequence that arrives over time, effects included. Nothing about chapter 10’s lesson changes; there is simply more of it. And you already know most of the API, because it’s chapter 3’s:
import fs2.Stream
val arrivals: Stream[IO, Booking] =
Stream.emits(tonightsConfirmed).covary[IO] // covary: lift a pure stream into IO
val references: IO[List[String]] =
arrivals
.evalMap(b => IO(logLine(s"arrived: ${b.reference}")).as(b.reference))
.compile.toList
map, filter, take, fold: all present, lazily, over data that may not exist yet. Two moves are genuinely new. evalMap runs an IO for each element, chapter 13’s notifyDiner being exactly the sort of thing you’d run. And compile closes the description into a single IO: toList when you want the elements, a fold when you want a summary, drain when only the effects matter. Until compile, a stream is a value, and everything you know about values applies, including that nothing has happened yet.
Two facts make fs2 more than a nicer loop. Streams are pull-based: the consumer asks for the next element, so a slow consumer naturally slows the producer instead of drowning under it, backpressure without configuration. And streams are resource-safe, which gets its own section shortly, because it’s the fact that makes the nightly feed boring to write.
14.2. The Nightly Feed
Here is the import, end to end. The old system’s export is a text file, name,size,slot per line, and every line is exactly as trustworthy as chapter 8 taught you strings from outside are:
import fs2.io.file.{Files, Path}
import fs2.text
def parseLine(line: String): RawRequest =
line.split(',') match
case Array(name, size, slot) => RawRequest(name, size, slot)
case _ => RawRequest(line, "", "")
def importFeed(feed: Path): Stream[IO, Either[List[BookingError], (Party, LocalTime)]] =
Files[IO].readAll(feed)
.through(text.utf8.decode)
.through(text.lines)
.filter(_.nonEmpty)
.map(line => validateAll(parseLine(line), sittings))
Read it top to bottom: bytes from the file, decoded to text, split into lines, blanks dropped, and every survivor pushed through chapter 8’s accumulating validator, the third outing for the border discipline and the first where the border is a file. (through pipes a stream through a transformation, the same idea as andThen for chapter 6’s functions.) The result is a stream of judgements: each line becomes either every complaint it deserves or a vetted booking-to-be.
Now the claim that justifies the chapter: this program runs in constant memory. readAll doesn’t read all, despite the name you’d write yourself; it reads chunks and hands them downstream on demand, so the eleven-year file and an eleven-line sample cost the same heap. The List version of this import dies with the file that no longer fits; the stream version doesn’t know how big the file is and never needs to. You prove it by pointing the repo code at a file bigger than your heap, which is a better afternoon than it sounds.
14.3. Chunks, Honestly
That word chunks deserves its footnote in the body. fs2 moves elements in batches internally, because per-element handoffs would spend more time in machinery than in your code. Mostly this is invisible: map and filter behave exactly as if elements travelled alone. It becomes visible when effects meet batching, evalMap runs its effect once per element, but operations like chunkN let you regroup and run one effect per batch, which is how a database import writes five hundred rows per round trip instead of one. The honest guidance at this altitude: know the word, trust the defaults, and reach for chunk-level operations when a profiler or a database round-trip count tells you to, and not before.
14.4. Resources Over Time
Files[IO].readAll opened a file and you never closed it. That’s not an oversight; it’s chapter 11’s guarantee extended over time. A stream built from a resource carries the release with it: when consumption ends, because the file ran out, because a consumer took four elements and stopped, because something downstream failed, or because the fiber was cancelled, the file closes, on every one of those exits. The general form is explicit:
def notifying(gatewayR: Resource[IO, SmsGateway]): Stream[IO, String] =
Stream.resource(gatewayR).flatMap { gateway =>
Stream.emits(tonightsConfirmed).covary[IO]
.evalMap(b => describeOutcome(b, notifyHard(gateway, b, confirmationText)))
}
Stream.resource turns any Resource into a stream that acquires on first pull and releases when the stream ends, however it ends. Chapter 11 said try-with-resources couldn’t survive being value-shaped; here it survives being time-shaped too, one gateway held open across an evening of sends and released exactly once.
14.5. Notifying in Parallel
Chapter 13’s subsystem sends one confirmation at a time. An evening’s worth wants the concurrency chapter 12 taught, and streams package it as one combinator:
def confirmations(gateway: SmsGateway): Stream[IO, String] =
Stream.emits(tonightsConfirmed).covary[IO]
.parEvalMap(4)(b => describeOutcome(b, notifyHard(gateway, b, confirmationText)))
parEvalMap(4) is evalMap with up to four effects in flight, order preserved on the way out; parEvalMapUnordered trades the ordering for throughput when nobody downstream cares. The bound is the point: chapter 12’s parTraverse launched a fiber per element, fine for eight bookings and a bad habit for eleven years of them, while a stream holds the whole feed and still never runs more than four sends at once. Bounded concurrency over unbounded data is most of what production streaming is.
Independent sources interleave with merge. The front-of-house board shows confirmations as they land and walk-ins as they arrive, two streams with nothing in common but a screen:
def board(gateway: SmsGateway): Stream[IO, String] =
confirmations(gateway).merge(walkInTicker)
merge emits from whichever side produces next, ending when both end. The repo’s walkInTicker paces itself with metered, a combinator that spaces emissions out in time (IO.sleep wearing stream clothing), which is also your first taste of streams as clocks: things that happen on a schedule are just streams whose elements are moments.
14.6. When Streams Fail
Chapter 13’s two channels survive the plural. A Left flowing down the feed is data, already handled; the question is the Throwable channel, and the answer has two altitudes. handleErrorWith on a stream is pipe-level: if anything upstream dies, switch to the fallback stream, right for "the file is unreadable, import nothing and say so". Element-level recovery is chapter 13’s attempt, moved inside:
def insertAll(rows: Stream[IO, (Party, LocalTime)]): Stream[IO, Unit] =
rows.evalMap(row =>
insertRow(row).attempt.flatTap {
case Left(e) => IO(logLine(s"skipped a row: ${e.getMessage}"))
case Right(_) => IO.unit
}
).collect { case Right(inserted) => inserted }
One poisonous row logs its apology and the other ten thousand keep flowing, which is what a nightly import owes its operator. The design question is the same one chapter 13 made you answer per failure case, asked once more per element: does this failure end the pipe, or just the item? Say it explicitly, at one of the two altitudes, and the 3am import stops being able to surprise you.
14.7. The Decoder Ring
The quiet reason this chapter sits where it does: Part IV’s libraries speak fs2 at every boundary, whether you asked for streams or not. An http4s request body is a Stream[IO, Byte], because a request body is bytes arriving over time and pretending otherwise requires buffering someone else’s upload in your heap. doobie can stream query rows, so "every booking ever" is a fold and not an out-of-memory incident. You will mostly consume these through friendlier signatures, and now, when you drill into one and find Stream, you’ll be reading a type you’ve used rather than trivia.
14.8. Fully Booked So Far
The import, proven, ch14/ in the repo:
test("the nightly feed imports the good lines and reports the bad"):
val feed = List(
"Okafor,2,19:00",
"Marchetti,forty,19:00", // party of "forty"
"Chen,6,03:00", // not a sitting
"Reyes,2,20:00"
)
val (complaints, vetted) = importOutcome(feed).unsafeRunSync()
assertEquals(vetted.map(_._1.name), List("Okafor", "Reyes"))
assertEquals(complaints.size, 2)
Two good lines survive the border, two bad ones come back with their reasons attached, the file opens and closes without a line of plumbing, and the same pipeline would do the same to a feed a thousand times the size without renegotiating its memory. Behind it, the same suite runs the parallel notifier against chapter 13’s scripted gateway and merges the ticker into the board, all of it on the virtual clock.
That closes the machinery Part III set out to build: effects as values, a runtime to race them, state that can’t tear, failure with a design, and now data in motion. One conversation remains before production, and it’s an argument. Everything since chapter 10 has assumed the Cats Effect bargain: wrap the program in IO, think in descriptions. Chapter 15 asks what this same evening looks like when the JVM itself, virtual threads and all, wants the job.
14.9. Exercises
Solutions are in the companion repo under ch14/exercises.
-
The end-of-service report. Chapter 9’s
Takingsmonoid summarised an evening; make it summarise the feed. Map each vetted booking to aTakings, then fold the stream in one pass withcompile.foldand the monoid’semptyandcombine, no intermediateListanywhere. The assertion: the streamed total equals the eager total for the same data, and the shape is chapter 9’s fold wearing chapter 14’s clothes. -
The arrivals board. Build a
Streamof booking events paced withmetered, fold it into a running covers count withscan(a fold that emits every intermediate state), andcompile.toListthe board’s states. Assert the final state equals chapter 3’s covers total for the same bookings, and that the running count never decreases; then check the timing withTestControl, because a paced stream is exactly the described time chapter 19 will test. -
The rush, streamed. Re-run chapter 12’s eight-contenders assault as a stream:
parEvalMapUnordered(8)over the same bookings against the sameRef-backedbookSafely, asserting the same three winners. Same property, same proof, different plumbing; write one sentence in a comment on when you’d reach for the stream spelling overparTraverse, and make it about the size of the input.
15. The Direct Style
Every chapter since the tenth has assumed a bargain: to get substitution, composable effects and safe concurrency, you wrap your program in IO and think in descriptions. This book believes the bargain is worth it, and this chapter is where we argue honestly with ourselves, because a serious movement in the Scala world thinks the price has changed.
The change came from underneath. In 2023 the JVM shipped virtual threads, Project Loom’s headline: threads so cheap you can have a million, blocking so cheap you stop fearing it. A fiber runtime exists substantially because OS threads were scarce and blocking wasted them; when the platform makes threads abundant, a fair question follows. What if you wrote the obvious blocking code, on virtual threads, and let the JVM do what `IO’s runtime does? Code in that shape is called direct style, and in Scala its flagship is Ox.
The question isn’t academic for this book’s reader. You now own both mental models, which is precisely why this chapter can be honest where advocacy pieces aren’t.
15.1. The Same Evening, Unwrapped
Ox is a library from SoftwareMill (1.0 in 2025, JDK 21 minimum) built on virtual threads and structured concurrency. Here is chapter 12’s shape, direct:
//> using dep com.softwaremill.ox::core::1.0.5
import ox.*
def confirmDirectly(booking: Booking, table: Table): Unit =
sendSms(booking.phone.getOrElse("desk"), s"Table ${table.number} is yours")
logLine(s"confirmed ${booking.reference}")
(Chapter 10’s notify logic, compressed to its rudest form because shape is tonight’s subject; the desk isn’t really getting texts.)
def eveningDirect(): (Unit, Unit) =
par(
confirmDirectly(chen, Table(9, 6)),
confirmDirectly(marchetti, Table(7, 4))
)
No IO, no for, no .void: functions do things, top to bottom, and par runs two of them on virtual threads with the structured-concurrency guarantee you met in chapter 12: both finish, or a failure cancels the sibling and both are cleaned up. Racing, timeouts, and scoped cancellation are all here in the same spirit (supervised opens the scope chapter 12’s runtime gave you implicitly; inside it, fork starts work and join waits):
import scala.concurrent.duration.*
def quickest(): String =
raceSuccess(slowLookup(), fastLookup())
def bounded(): Option[String] =
timeoutOption(100.millis)(slowLookup())
Blocking is not a sin in this world; it’s the mechanism. Thread.sleep inside a virtual thread parks it for nearly nothing, which is exactly what IO.sleep bought you, delivered by the platform instead of a library runtime.
And the last table? Ox doesn’t hand you Ref; you reach for the JVM’s own atomics, and the shape of the fix is identical because the problem never changed:
import java.util.concurrent.atomic.AtomicReference
def bookSafelyDirect(store: AtomicReference[Schedule], booking: Booking): Boolean =
var won = false
store.updateAndGet { schedule =>
if canSeat(schedule, room, eight, booking.party)
then { won = true; book(schedule, room, eight, booking) }
else { won = false; schedule }
}
won
Atomic check-and-act, same idea as modify, scruffier delivery (that var is doing a job modify’s return value did for free, and `updateAndGet may retry the function under contention, a subtlety Ref.modify shares). The point stands either way: chapter 12’s thinking transfers whole. Direct style changes the syntax of concurrency, not its logic.
15.2. What You Gain, What You Give Back
The honest ledger, both columns.
Direct style’s gains are real. Stack traces point at your code instead of a runtime’s plumbing, and a debugger steps through it like the Java you grew up with. There’s no colouring problem: a function that does effects has an ordinary type, so nothing splits libraries into IO-flavoured and not. The learning curve for a Java-fluent team is close to zero, and that’s a hiring argument with money attached. For services that are mostly "receive request, call things, respond", the plain shape reads wonderfully.
What you give back is the type. confirmDirectly returns Unit, and nothing in any signature now distinguishes "computes a label" from "texts a customer": the property chapter 10 built everything on, effects visible in types, substitution you can lean on, is gone, and with it the compiler’s ability to catch an effect happening where you didn’t mean one. Testing follows the same line: an IO program is a value you can assemble in a test and interpret with a virtual clock (TestControl, chapter 19); a direct-style function has already done whatever it does. Retry-with-backoff as a value, the described-not-performed trick from chapter 11, has no direct equivalent, only functions that really sleep. And `Resource’s guarantees become try-finally discipline again, structured but hand-rolled at each site.
None of this is fatal, and Java teams shipped decades of software without effect types. The question is only ever what you’re paying and what you’re getting, which is why this section is a ledger and not a verdict.
15.3. Where the Language Is Going
Two currents matter for a book you’ll still own in three years.
The first is that direct style is where Scala’s own designers are pushing. Martin Odersky’s research programme (Project Caprese, and the capture checking now shipping experimentally in the compiler) aims at a future where the compiler tracks capabilities, which functions can perform which effects, without wrapper types: direct syntax, typed effects, the best cells of both columns in the ledger. It is genuinely exciting and genuinely not ready; the feature is behind an experimental import, its shape still moving. A book that taught it as practice today would be lying to you by 2028; a book that ignored it would be lying about the direction of travel.
The second is that the production ecosystem hasn’t moved. The libraries Part IV builds on, http4s, doobie, fs2, and their ZIO-flavoured counterparts, are effect-typed to the bone, run enormous production estates, and are where the hiring, documentation and answers live. Both sides of the argument are worth hearing from the principals: Daniel Spiewak’s keynote "The Case for Effect Systems" is the considered defence, Alexandru Nedelcu’s essay "The case against Effect Systems", written in reply by the author of Monix, is the sharpest attack, and the survey data says the community is watching direct style with interest rather than migrating to it.
So the Two Scalas may be becoming three, or the staircase may be growing a second handrail; nobody serious claims to know. This book’s position is the pragmatic one: effect types are today’s production default and what the rest of this book uses; direct style is a real tool you now understand, correct for teams that price its tradeoffs and stay JVM-only; and capture checking is the storyline to watch, not adopt.
15.4. Choosing, When It’s Your Call
A decision sketch, since "it depends" is true but lazy. Reach for direct style when the team is Java-first, the service is straightforwardly request-shaped, and nobody needs effect values for testing or composition. Stay with effect types when you want the compiler policing effects, when described-not-performed programs earn their keep (retries, timeouts, virtual-clock tests), or when you’re building on the Typelevel or ZIO ecosystems anyway, which is most production Scala today, and it’s the road the rest of this book walks. Either way, you learned the ideas once: structured concurrency, atomic state, resource discipline. The syntax was the cheap part.
Part III closes here, and the promise from chapter 1 is kept: you’ve built IO, adopted the real one, solved the book’s hardest problem with it, and can read the alternative without flinching. Part IV puts all of it to work: a real HTTP API, a real database, and the case study finally earning its keep as a service. First stop: the build tool the project has now earned.
15.5. Exercises
Solutions are in the companion repo under ch15/exercises.
-
The translation table. Take chapter 12’s
meanwhile,bothTextsandfirstAnswerand write their direct-style equivalents withfork/join,parandraceSuccessinside asupervisedscope. The exercise is noticing which chapter 12 concepts needed new names and which needed none. -
The race, both ways. Port the overbooking detector from chapter 12’s exercises to
bookSafelyDirect, and confirm the atomic version admits exactly one winner under the same contention. Same property, same proof shape, different runtime; that’s the chapter’s whole argument in one test. -
The ledger, personally. Write
retryDirect[A](attempts: Int)(work: () ⇒ A): Ain direct style. It will work, and it will really sleep. Then write down, in a comment, the two things the chapter 11 version could do that this one can’t (compose before running; test on a virtual clock), because you’ll be asked in an interview one day and "I’ve built both" is the good answer.
Part IV: Production
16. Growing Up: scala-cli to sbt
For fifteen chapters, scala-cli has been the whole toolchain: one directory, a project.scala of directives, and commands that just worked. That was a deliberate choice, and it was right; nothing kills a first month like arguing with a build tool. But chapter 1 promised a build tool would arrive "when the case study has earned one", and the case study just spent six chapters growing effects, a runtime and a concurrency story. Part IV is about to add an HTTP server and a database. The bill has arrived.
Here’s what "earned" means concretely. Fully Booked now wants layers: domain logic that must not know HTTP exists, persistence code the web handlers shouldn’t reach around, and tests per layer. It wants dependencies per layer too: the database library (doobie, chapter 18’s subject) belongs to persistence and nowhere else. And it’s about to be the kind of project several people work on, which means shared formatting, CI, and a build definition that is itself code under review. Directives describe a compilation; they don’t describe an architecture. That’s the job of a build tool, and in Scala the build tool is sbt.
One sentence of positioning, since the ecosystem moved recently: sbt 2 shipped in 2026 after five years of work, and this book teaches sbt 2 and nothing else, on the grounds that a book is a three-year investment and the future runs on 2. Plenty of existing codebases run sbt 1; everything conceptual here transfers, and the syntax differences are small enough that you’ll cope. (Mill deserves its footnote: a genuinely nice alternative build tool with a strong following; sbt remains what you’ll meet, at roughly eight jobs in ten.)
16.1. The Moving Van
You don’t have to hand-write your way out of scala-cli; the tool packs your bags:
$ scala-cli --power export --sbt -o fully-booked .
export (a "power" command, hence the flag) reads your directives and generates a working sbt project: build.sbt, project/build.properties and sources arranged in sbt’s expected layout. It’s a fine way to see the mapping between the directives you know and the build you’re about to learn, and a fine escape hatch for real projects that outgrow their start. We’ll write ours by hand anyway, because the generated build is a translation, and you’re here to learn the language.
16.2. build.sbt, Read Honestly
The project we’re building, smallest real form:
fully-booked/
├── build.sbt
├── project/
│ └── build.properties # sbt.version=2.0.1
├── domain/
│ └── src/{main,test}/scala/...
└── app/
└── src/main/scala/...
ThisBuild / scalaVersion := "3.8.4"
ThisBuild / organization := "pub.henri.fullybooked"
lazy val domain = project
.in(file("domain"))
.settings(
name := "fully-booked-domain",
libraryDependencies += "org.scalameta" %% "munit" % "1.3.3" % Test
)
lazy val app = project
.in(file("app"))
.dependsOn(domain)
.settings(
name := "fully-booked-app",
libraryDependencies += "org.typelevel" %% "cats-effect" % "3.7.0"
)
lazy val root = project
.in(file("."))
.aggregate(domain, app)
.settings(name := "fully-booked")
Read it as Scala, because it is Scala; in sbt 2 the build definition compiles with the same Scala 3 you write all day. The pieces:
ThisBuild / scalaVersion sets a key for every module at once; / scopes a setting, and := assigns one. A project is a module rooted in a directory. dependsOn(domain) is the load-bearing line: app can import fullybooked.domain., and anything *without that line cannot import it at all, which is the architecture-as-compiler-error trick this chapter exists for. aggregate makes commands fan out: sbt test at the root tests every module. And libraryDependencies is //> using dep in its Sunday clothes; the %% infix appends the Scala binary-version suffix (_3) to the artifact name, exactly what :: did in directives.
Sources live where Java put them a generation ago: src/main/scala and src/test/scala per module. scala-cli’s .test.scala suffix convention retires; the directory is the convention now.
16.3. Driving It
sbt has a batch mode and a shell, and the shell is the one habit worth forming, because the JVM and compiler stay warm between commands:
$ sbt # opens the shell
sbt:fully-booked> test # all modules, via aggregate
sbt:fully-booked> domain/test # one module
sbt:fully-booked> app/run
Fully Booked: parties up to 12 welcome
sbt:fully-booked> ~domain/test # re-run on every file save
That last one, ~ for watch mode, replaces the edit-rerun loop you’ve had with scala-cli, per module. console opens a REPL inside a module’s classpath, chapter 1’s exploration habit now with your domain model loaded. The first sbt invocation of your life downloads the world and tries your patience; everything lands in the local artifact cache, so the second start is quick, and sbt 2’s task caching makes repeated builds quicker still.
16.4. Walls That Hold
The payoff deserves its own demonstration. domain has no dependencies except a test framework, which is an architectural statement the build now enforces: the model of parties, bookings and state machines compiles against nothing but the standard library, so no HTTP type, no database session, no logging framework can leak into it, ever, and the compile error when someone tries reads like a code review that never sleeps.
When chapters 17 and 18 add http and persistence modules, each will name its dependencies and its dependsOn, and the shape of the application becomes something you can read in thirty lines of build.sbt rather than reverse-engineer from imports. This is what the one-directory world couldn’t say, and it’s most of the reason grown services use a build tool at all.
16.5. House Style, Enforced
Two more residents of project/ and the root, both about teams rather than compilers.
scalafmt is the community’s standard formatter; a .scalafmt.conf at the root (two lines will do: a version and runner.dialect = scala3) plus one line in project/plugins.sbt, addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.6.1"), gives everyone scalafmtAll and, more usefully, scalafmtCheckAll for CI, and the tabs-versus-spaces conversation ends forever. (Plugin versions from 2.5.6 carry the sbt 2 build; if you’re spelunking older projects, that’s the line to update first.) Formatting is a solved problem; solve it once and spend the meetings on something real.
And CI itself is small enough to show whole:
# .github/workflows/ci.yml
name: ci
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { distribution: temurin, java-version: 21 }
- uses: sbt/setup-sbt@v1
- run: sbt scalafmtCheckAll test
Push, and every module compiles, is format-checked and tested on every change. The companion repo runs exactly this. There is more to say about CI, and Part IV’s remaining chapters say some of it; the point today is that the floor is a dozen lines.
16.6. Fully Booked, Rehoused
The case study’s permanent home is now the fully-booked/ sbt project in the companion repo: domain carrying the model Parts I and II built, app carrying the Cats Effect entry point, walls ready for the modules the next chapters bring. The per-chapter sample directories continue alongside it, because fragments you can run in isolation stay valuable, but from here the book’s "So Far" sections describe the sbt project, and the next two chapters each add a module to it.
The migration itself, you’ll notice, taught mostly by being unremarkable: the language didn’t change, the tests didn’t change, and the tool learned to say things about structure the old tool couldn’t. That’s what growing up is supposed to look like.
16.7. Exercises
Solutions are in the companion repo under fully-booked/ (see its README).
-
The wall, tested. Add a
notificationsmodule with nodependsOn, write a class in it that tries to importfullybooked.domain.Party, and runnotifications/compile(a module outsideaggregateneeds naming directly). Read the compile error aloud, then add thedependsOnand watch it pass. You’ve just seen the cheapest architecture test you’ll ever write, and it runs on every compile. -
Console archaeology.
sbt domain/console, then build aPartySize.from(9)and a few bookings interactively. The REPL-with-your-model-loaded is the fastest design-thinking tool in this book; notice it’s now scoped, so `app’s dependencies aren’t cluttering the domain’s namespace. -
Break the format. Misindent something, push a branch, and watch
scalafmtCheckAllfail CI before a human ever sees the diff. Then runscalafmtAlland consider, honestly, how many review comments in your current job this two-line config would delete.
17. APIs That Document Themselves
Fully Booked finally gets a front door. The requirements are the ordinary ones: accept a booking request over HTTP, answer with JSON, refuse nonsense with useful status codes, and, because other people integrate with restaurants, publish documentation that doesn’t rot. The ordinary delivery of those requirements is a framework’s routing DSL plus a hand-maintained OpenAPI file that drifts from the code within a fortnight.
Scala’s stack does something better, and it’s this book’s favourite kind of better: the API becomes a value. One description of each endpoint, and from that single value flow the server routes, the documentation, and even a client. The library is Tapir; the engine underneath is http4s, Cats Effect native down to its sockets. Between them they’re the closest thing production Scala has to a default web stack, and every idea in them is one you already own.
//> using dep com.softwaremill.sttp.tapir::tapir-core::1.13.15
//> using dep com.softwaremill.sttp.tapir::tapir-http4s-server::1.13.15
//> using dep com.softwaremill.sttp.tapir::tapir-json-circe::1.13.15
//> using dep org.http4s::http4s-ember-server::0.23.32
//> using dep io.circe::circe-core::0.14.14
(A version note in passing, because the book promised honesty about these: http4s’s stable line is 0.23.x and has been for years; the 1.0 milestones exist and the project treats "when it’s done" as a feature. Nobody should read 0.23 as beta. It runs banks.)
17.1. The Endpoint as a Value
Chapter 10 made programs values; Tapir makes interfaces values:
import sttp.tapir.*
import sttp.tapir.json.circe.*
import io.circe.Codec
case class BookingRequest(name: String, size: Int, slot: String) derives Codec.AsObject, Schema
case class BookingConfirmed(reference: String, table: Int) derives Codec.AsObject, Schema
case class ApiError(message: String) derives Codec.AsObject, Schema
val bookEndpoint =
endpoint.post
.in("bookings")
.in(jsonBody[BookingRequest])
.out(jsonBody[BookingConfirmed])
.errorOut(statusCode(sttp.model.StatusCode.Conflict).and(jsonBody[ApiError]))
Read it like the data it is. POST /bookings, a JSON body in, a JSON confirmation out, and a distinct error channel that pairs a 409 Conflict status with a JSON error body. No server exists yet; bookEndpoint is a description, exactly as an IO was, and everything this chapter does flows from interpreting it.
The derives clause carries two instances per class, and both are chapter 9’s machinery earning a living: Codec.AsObject is circe, the ecosystem’s default JSON library, building encoders and decoders from the case-class shape at compile time; Schema is Tapir’s own, describing that shape for the documentation you’ll meet shortly. One for the bytes, one for the docs, neither written by hand. No annotations, no reflection, and a malformed request never reaches your logic, because decoding is part of the endpoint’s contract.
17.2. Logic, Attached
A description needs behaviour. Tapir asks for exactly the shape chapter 8 taught you to produce (validateRequest is defined two sections down; take it on credit for a page, the way chapter 8 lent you parseSlot):
def bookLogic(store: Ref[IO, Schedule])(req: BookingRequest)
: IO[Either[ApiError, BookingConfirmed]] =
validateRequest(req) match
case Left(errors) =>
IO.pure(Left(ApiError(errors.map(_.message).mkString("; "))))
case Right((party, slot)) =>
bookSafely(store, slot, party).map {
case Some(confirmed) => Right(confirmed)
case None => Left(ApiError(s"no table free at $slot"))
}
val bookRoute = Http4sServerInterpreter[IO]().toRoutes(
bookEndpoint.serverLogic(bookLogic(store))
)
serverLogic takes input to IO[Either[error, output]]: the domain’s failure channel is the HTTP error channel, Left becomes the 409 with its JSON body, Right becomes the 200, and the mapping was declared once in the endpoint rather than sprinkled through handlers. Chapter 12’s bookSafely sits in the middle with a new signature for HTTP duty (it takes the slot and party the endpoint decoded, and answers with the confirmation the response needs rather than a bare Boolean) and an unchanged spirit: atomic against the shared Ref, because a web server is exactly the two-requests-at-once world that chapter built for.
17.3. Validation at the Door, the Polished Version
Chapter 8 hand-rolled error accumulation with a tuple match and promised you the library version when HTTP arrived. Cats (already on your classpath under Cats Effect) ships it as Validated:
import cats.data.ValidatedNel
import cats.syntax.all.*
def checkName(raw: String): ValidatedNel[BookingError, String] =
if raw.trim.nonEmpty then raw.validNel
else BookingError.NameMissing.invalidNel
def checkSize(raw: Int): ValidatedNel[BookingError, PartySize] =
PartySize.from(raw).toValidNel(BookingError.PartyOutOfRange(raw))
def checkSlot(raw: String): ValidatedNel[BookingError, LocalTime] =
parseSlot(raw).toValidatedNel
def validateRequest(req: BookingRequest)
: Either[List[BookingError], (Party, LocalTime)] =
(checkName(req.name), checkSize(req.size), checkSlot(req.slot))
.mapN((name, size, slot) => (Party(name, size.value), slot))
.toEither.left.map(_.toList)
ValidatedNel is your accumulating Either with the accumulation built in: mapN runs every check and gathers all the failures (a NonEmptyList of them, hence the Nel), instead of stopping at the first the way Either’s `flatMap must. The tuple-match you wrote in chapter 8 is exactly what mapN generalises, which is why this section is a paragraph rather than a struggle, and toEither hands the result back to the pipeline the endpoint wants. (toValidatedNel is the same bridge crossed the other way, lifting chapter 8’s parseSlot Either into the accumulating world.)
17.4. Serving It
http4s routes are values too, so they combine with <+>, and the server itself is chapter 11’s Resource:
import org.http4s.ember.server.EmberServerBuilder
import com.comcast.ip4s.*
val server: Resource[IO, org.http4s.server.Server] =
EmberServerBuilder.default[IO]
.withHost(ipv4"0.0.0.0")
.withPort(port"8080")
.withHttpApp((bookRoute <+> docsRoute).orNotFound)
.build
object Serve extends IOApp.Simple:
def run: IO[Unit] = server.useForever
build acquires the listener, useForever holds it open until shutdown, and the release path, connections drained, socket closed, runs on Ctrl-C because that’s what Resource inside IOApp means. Request bodies, incidentally, arrive as Stream[IO, Byte] under the hood: chapter 14 was the decoder ring for this whole stack’s signatures. And the ipv4"0.0.0.0" and port"8080" literals are ip4s interpolators, checked at compile time: a malformed address or an out-of-range port fails the build rather than the boot.
17.5. The Documentation That Cannot Drift
The section this chapter is named for:
//> using dep com.softwaremill.sttp.tapir::tapir-swagger-ui-bundle::1.13.15
import sttp.tapir.swagger.bundle.SwaggerInterpreter
val docsRoute = Http4sServerInterpreter[IO]().toRoutes(
SwaggerInterpreter().fromEndpoints[IO](List(bookEndpoint), "Fully Booked", "1.0")
)
Run the server, open /docs, and there’s Swagger UI: every path, schema, status code and example, generated from the same values the server routes came from. Change the endpoint and the docs change, because they are not two artefacts but two interpretations of one, and the OpenAPI file that drifts from the code has become unwritable. For teams that publish APIs, this single property pays for the whole stack.
The third interpretation completes the set: SttpClientInterpreter derives a working client from the same endpoint value, which the tests in the repo use, and which means a Scala consumer of your API never hand-writes a request either.
17.6. Fully Booked, Served
The http module joins the build, dependsOn(domain), carrying the endpoints, the validation and the wiring; the repo’s tests exercise the routes without a socket in sight, because HttpRoutes is a function from request to response and functions can simply be called:
test("a full slot answers 409 with a reason"):
val response = routes.orNotFound.run(
Request[IO](Method.POST, uri"/bookings").withEntity(fullSlotRequest)
).unsafeRunSync()
assertEquals(response.status.code, 409)
That test shape, real routes, real JSON codecs, no network, is chapter 19’s opening argument arriving early. What the service still forgets is everything, every restart: the Ref store was always a bridge. Chapter 18 gives Fully Booked a memory.
17.7. Exercises
Solutions are in the companion repo under ch17/exercises.
-
The schedule, published. Add
GET /schedule?slot=19:00returning the bookings at a sitting as JSON. Query parameters are.in(query[String]("slot")); reuseparseSlotfrom chapter 8 for the error path, and watch the docs page grow the endpoint before you’ve written a word of documentation. -
Status codes, pinned. Write the socketless tests proving the contract: a clean request answers 200 with a reference, a full slot answers 409, and a malformed size answers 400 (Tapir’s decode failure, before your logic runs). The third one is the point: the contract you get without writing code still deserves a test that notices if it changes.
-
A client for free. Use
SttpClientInterpreterto derive a client forbookEndpointand call your own route in-process. One endpoint value, now demonstrably three artefacts; count the places a drifting hand-written client could have lied to you this week.
18. The Database Is Not Pure
Chapter 12 admitted a boundary and chapter 17 tripped over it: Ref protects one process’s memory, so the moment Fully Booked runs on two servers, or simply restarts, the schedule is either racing again or gone. The fix has been the same for fifty years: a database, the shared, durable Ref for an entire fleet. What’s new is the arrival lounge. JDBC, the JVM’s door to databases, is everything Part III built discipline for, all at once: it blocks threads, it throws exceptions, and every call is a side effect. The library that makes it civilised is doobie, and by now you can guess its central move.
//> using dep org.typelevel::doobie-core::1.0.0-RC13
//> using dep org.typelevel::doobie-hikari::1.0.0-RC13
(Two honesty notes before anything else. First, the version: doobie has sat at 1.0 release candidates for years while being the de facto standard functional JDBC layer, running in anger all over the industry; the RC is a naming sensibility, and treating it as "not ready" would rule out half the production Scala you’ll meet. Second, the address: the 1.0 line moved doobie into the Typelevel organisation, and the package moved with the coordinates, so imports read org.typelevel.doobie.. A decade of tutorials says plain import doobie.; when their snippets won’t compile for you, this paragraph is why.)
18.1. JDBC, Described
doobie’s core type is ConnectionIO[A]: a description of work needing a database connection, exactly as IO[A] was a description of work needing a runtime. You’ve seen this shape four times now; here’s what it buys at the SQL boundary:
import org.typelevel.doobie.*
import org.typelevel.doobie.implicits.*
case class BookingRow(reference: String, guest: String, size: Int, slot: String)
def bookingsAt(slot: String): ConnectionIO[List[BookingRow]] =
sql"SELECT reference, guest, size, slot FROM bookings WHERE slot = $slot"
.query[BookingRow]
.to[List]
def insertBooking(row: BookingRow): ConnectionIO[Int] =
sql"""INSERT INTO bookings (reference, guest, size, slot)
VALUES (${row.reference}, ${row.guest}, ${row.size}, ${row.slot})"""
.update.run
The sql interpolator looks like string interpolation and is nothing of the kind: every $value becomes a JDBC parameter placeholder, typed and escaped, so the SQL injection your security team dreams about is unwritable by accident. .query[BookingRow] maps result columns onto the case class by position, using chapter 9’s given machinery (doobie derives row readers for products of column types it knows). .to[List] collects, .update.run returns the affected-row count, and nothing has touched a database yet: these are values, composable with flatMap and for, and that composition is about to matter more than anything else in this chapter.
18.2. The End of the Connection
A ConnectionIO runs by being handed to a Transactor, which owns connections and turns descriptions into IO:
val rows: IO[List[BookingRow]] = bookingsAt("19:00").transact(xa)
transact is this chapter’s unsafeRun, the end of the world for connections, and it does three things every time: borrows a connection, runs your description inside a transaction, and commits on success or rolls back on any failure. Which reveals the real headline, easy to miss in a sentence: whatever you composed into one ConnectionIO is one transaction. Here, flatMap does double duty: it sequences the steps and it draws the atomicity boundary around them.
18.3. The Race, Made Durable
So the yardstick returns for its final measurement. Two app servers, one database, two parties after the last table at eight: no Ref can help across processes, but a transaction can, because the database serialises transactions that touch the same rows. The pattern is lock, check, act, in one description:
def lockSitting(slot: String): ConnectionIO[Unit] =
sql"SELECT slot FROM sittings WHERE slot = $slot FOR UPDATE"
.query[String].unique.void
def bookAtomically(row: BookingRow, capacity: Int): ConnectionIO[Boolean] =
for
_ <- lockSitting(row.slot)
taken <- sql"SELECT COUNT(*) FROM bookings WHERE slot = ${row.slot}"
.query[Int].unique
free = taken < capacity
_ <- if free then insertBooking(row).void
else ().pure[ConnectionIO]
yield free
Two small spellings before the big one. The bare = binds a plain value inside a for, no wrapping required, where chapter 12 wrote the same line the long way with IO.pure; and ().pure[ConnectionIO] is the do-nothing arm, lifting () into the query language exactly as IO.pure lifted values in chapter 11.
FOR UPDATE takes a row lock on the sitting: the second transaction to ask for it waits until the first commits, and then its COUNT sees the committed insert. Check-then-act is atomic again, chapter 12’s modify rebuilt at database scale, surviving restarts, load balancers and the fleet. The repo test runs eight concurrent bookAtomically attempts through a real connection pool at a three-table sitting and gets exactly three winners, every run; the bomb from chapter 3 is now defused at every level it can exist.
One honest cost, because this book prices things: that lock is contention, and under real load a hot sitting serialises its bookers. For a restaurant, correct-and-occasionally-queued beats fast-and-double-booked without discussion; systems with different economics reach for optimistic schemes (version columns, retry loops) that trade waits for retries. Chapter 12’s thinking transfers to those too; the lock is simply the honest default.
18.4. Schema as Code: Flyway
The tables above have to come from somewhere, and "Dave ran some SQL on prod in 2024" is not a schema strategy. Flyway is the boring, excellent standard: numbered SQL files in your resources, applied in order, recorded in the database itself.
resources/db/migration/
├── V001__create_sittings.sql
└── V002__create_bookings.sql
//> using dep org.flywaydb:flyway-core:11.10.0
def migrate(url: String, user: String, pass: String): IO[Unit] =
IO.blocking {
Flyway.configure().dataSource(url, user, pass).load().migrate()
}.void
Run it at startup, before the server takes traffic; IO.blocking because Flyway is plain JDBC underneath, and chapter 11 told you where blocking calls live. Every environment, laptop to production, converges on the same schema by replaying the same files, and the migration that hasn’t been applied is visible in version control rather than in Dave’s memory. V003 never edits V001; history is append-only, like the ledger it is.
18.5. Pooling: Hikari as a Resource
Opening a connection per request is how you discover your database’s connection limit at 7pm on a Saturday. A pool holds warm connections and lends them out; HikariCP is the JVM’s standard, and doobie wraps it as exactly what chapter 11 says it should be:
import org.typelevel.doobie.hikari.HikariTransactor
def transactor(url: String, user: String, pass: String)
: Resource[IO, HikariTransactor[IO]] =
for
ec <- ExecutionContexts.fixedThreadPool[IO](8)
xa <- HikariTransactor.newHikariTransactor[IO](
"org.h2.Driver", url, user, pass, ec)
yield xa
A Resource that yields a Transactor: acquire builds the pool, release drains and closes it. The small thread pool we hand it exists for the waiting: borrowing a connection blocks when the pool runs dry, and that queueing happens off the compute threads. The JDBC calls themselves run on the runtime’s blocking pool, doobie wrapping each in chapter 11’s IO.blocking for you, so the compute pool stays free without you writing it at every query. Compose it with the server Resource from chapter 17 in one for, and the whole application’s lifecycle, pool up, migrations run, server up, then teardown in reverse, is a page of code with chapter 11’s guarantees end to end.
18.6. Fully Booked Remembers
The repo’s ch18/ wires it together: the same POST /bookings route from chapter 17, its logic now landing in bookAtomically through the pool, and a test that does what Ref never could:
test("the booking survives a restart"):
val row = BookingRow("FB-2001", "Chen", 6, "18:00")
bookAtomically(row, capacity = 3).transact(xa).unsafeRunSync()
val secondLife = freshTransactor() // a new pool: the "restarted" app
val found = bookingsAt("18:00").transact(secondLife).unsafeRunSync()
assertEquals(found.map(_.reference), List("FB-2001"))
The first transactor is gone; the data isn’t. (The samples run this against H2, an in-process database that keeps the tests fast and honest; the SQL is deliberately the portable subset, and the repo notes the two places Postgres would differ. Chapter 19 stands up the real Postgres in a container and re-runs the same suite against it, which is the correct order: fast tests for the logic, one slower rig proving the dialect.) In the sbt build, this layer is the persistence module chapter 16 promised: it dependsOn(domain) alone, and http now names both, the wall going up exactly where the build said it would.
What the service still lacks is the operational shell, packaging, health, shutdown, logs that satisfy a 3am pager, and that’s chapter 20 after the testing chapter arms us properly.
18.7. Exercises
Solutions are in the companion repo under ch18/exercises.
-
Found or not. Write
findBooking(reference: String): ConnectionIO[Option[BookingRow]]using.optioninstead of.to[List], and the two tests that pin both outcomes. Chapter 4’s discipline, now wearing SQL. -
The cancellation, counted. Write
cancel(reference: String): ConnectionIO[Either[String, Unit]]around aDELETE, using the affected-row count to distinguish "cancelled" from "no such booking", and refuse to be chapter 3’s silent swallow. One description, one transaction, honest result. -
The schema grows. Add
V003__add_phone.sqlintroducing a nullable phone column, re-run the suite, and prove old rows survive migration with a test that reads a pre-migration booking. Then map the new column intoBookingRowas what a nullable column obviously becomes in this book’s hands.
19. Testing a Real Service
Chapter 5 made two promises for "when there’s a real service to point at": property-based testing, and an honest treatment of tests that touch databases and networks. There’s a real service now, bookings over HTTP landing in Postgres-shaped SQL, and this chapter pays both debts and organises everything you’ve already been doing into a shape worth defending in a design review.
That shape, stated up front so the chapter can argue for it: a pyramid with a functional accent. The base is your pure domain, tested by example and by property, thousands of cases a second, no runtime needed. The middle is logic-with-effects and HTTP contracts, tested as programs and socketless routes, fast and deterministic. The tip is one thin rig against the real database dialect in a container. And conspicuously absent is the mock-object layer cake, because this book gave you something better in chapter 6: when behaviour is a function and state is a Ref, you inject values, and a hand-rolled mock framework has nothing left to do.
19.1. Suites That Speak IO
Until now our effectful tests ended in unsafeRunSync(), tolerated noise at the edge. munit’s Cats Effect integration deletes it:
//> using test.dep org.typelevel::munit-cats-effect::2.1.0
import munit.CatsEffectSuite
class EngineTests extends CatsEffectSuite:
test("the safe path admits one winner"):
for
store <- Ref.of[IO, Schedule](oneFreeSlot)
results <- (bookSafely(store, chen), bookSafely(store, novak)).parTupled
yield assertEquals(List(results._1, results._2).count(identity), 1)
A test returns IO, and the framework owns the single unsafeRun at the edge, which is chapter 11’s apocalypse rule finally applied to the tests themselves. assertIO, interceptIO and friends exist for direct program assertions, and every fixture idea you know carries over. From here, this is the book’s default test shape for anything effectful. (The bookSafely under test is chapter 12’s Ref-backed original, Boolean answer and all; chapter 17’s HTTP-shaped variant meets its own tests through the routes below.)
19.2. Time Under Test
Chapter 13 previewed the star; here’s its proper turn. Programs that sleep, retry and time out are miserable to test against a real clock and trivial against a virtual one:
import cats.effect.testkit.TestControl
test("backoff waits one second, then two, then gives up"):
val always = IO.raiseError[String](new RuntimeException("down"))
val timed =
for
start <- IO.monotonic
_ <- retryWithBackoff(always, attempts = 3, delay = 1.second).attempt
finish <- IO.monotonic
yield (finish - start).toSeconds
TestControl.executeEmbed(timed).assertEquals(3L)
executeEmbed runs the program on a runtime whose clock only moves when every fiber is parked waiting: the one-second and two-second sleeps "pass" instantly, the measured duration is exactly the described duration, and a retry policy’s whole personality becomes a unit test. This only works because chapter 13 built the retry out of described delays; the direct-style ledger in chapter 15 listed this capability as a cost of leaving, and now you’ve seen the asset side.
19.3. Properties: The Machine Writes the Examples
Example-based tests pin the cases you thought of. Property-based tests state an invariant and let the machine hunt for the case you didn’t:
//> using test.dep org.scalameta::munit-scalacheck::1.3.0
import munit.ScalaCheckSuite
import org.scalacheck.Prop.forAll
import org.scalacheck.Gen
class PartyProperties extends ScalaCheckSuite:
property("capped parties always land within bounds"):
forAll { (size: Int) =>
val capped = Party.capped("Anyone", size)
capped.size >= math.min(size, 1) && capped.size <= Party.MaxSize
}
property("validation never lets an oversized party through"):
forAll(Gen.choose(-5, 40), Gen.oneOf(sittings)) { (size, slot) =>
validateRequest(BookingRequest("Chen", size, slot.toString)) match
case Right((party, _)) => party.size >= 1 && party.size <= Party.MaxSize
case Left(_) => true
}
The first property runs against a hundred generated integers each execution, negative, zero, enormous, and chapter 5’s Party.capped finally gets the interrogation it was promised. The second states the boundary contract of chapter 17’s validation: whatever arrives, nothing out of range survives to the Right. When a property fails, ScalaCheck shrinks the counterexample to a minimal one before reporting, which is the difference between "failed for -2147483648" and a debugging session. Properties earn their keep on exactly the code this book has pushed you to write, pure functions with stated invariants, and that’s not a coincidence; it’s the pyramid’s base paying compound interest.
19.4. The Real Dialect, Once
Chapter 18 tested against H2 for speed and promised the real thing. Testcontainers keeps that promise by starting a genuine Postgres in Docker, per suite, from the test itself:
//> using test.dep com.dimafeng::testcontainers-scala-munit::0.44.1
//> using test.dep com.dimafeng::testcontainers-scala-postgresql::0.44.1
//> using test.dep org.postgresql:postgresql:42.7.7
import com.dimafeng.testcontainers.PostgreSQLContainer
import com.dimafeng.testcontainers.munit.TestContainerForAll
import org.testcontainers.utility.DockerImageName
class PostgresDialectTests extends CatsEffectSuite with TestContainerForAll:
override val containerDef =
PostgreSQLContainer.Def(DockerImageName.parse("postgres:17-alpine"))
test("the migrations and the race hold on real Postgres"):
withContainers { pg =>
migrate(pg.jdbcUrl, pg.username, pg.password) *>
transactorFor(pg.driverClassName, pg.jdbcUrl, pg.username, pg.password).use { xa =>
(1 to 8).toList.parTraverse(n =>
bookAtomically(BookingRow(s"FB-91$n", s"Rival$n", 2, "20:00"), 3).transact(xa))
}.map(results => assertEquals(results.count(identity), 3))
}
Same migrations, same bookAtomically, same eight-contenders assertion as chapter 18, now against the database production will run, FOR UPDATE semantics and all. (transactorFor is chapter 18’s transactor with the driver promoted to a parameter, one line of generalisation now that two databases exist.) This is the tip of the pyramid: one suite, seconds not milliseconds, needing Docker (locally and in CI, where Ubuntu runners have it waiting). Its job is narrow and irreplaceable, catching the places a dialect differs, and its narrowness is the design: if a plain logic bug first surfaces here, a cheaper test below is missing.
19.5. What to Test Where
The chapter’s opinions, gathered for the design review.
Test the pure domain hardest and cheapest: examples for the cases with names, properties for the invariants, no runtime, no mercy. Test effectful logic as programs, with real Ref`s and virtual clocks; where a collaborator is needed, pass a function or a `Ref-backed fake, chapter 6’s insight doing test-double duty, and notice you’ve never once needed a mocking framework in this book. Test HTTP as a contract through socketless routes, chapter 17’s trick, including the failure statuses, because the contract is the product. Test the database dialect once, in the container, through the same migrations production replays. And keep a genuine end-to-end smoke test count you can tally on one hand, because everything above the base trades confidence-per-second at worsening rates.
The pattern behind the pattern: this book’s architecture is its testability. Purity made the base wide, descriptions made time controllable, functions-as-values made doubles free, and one chapter of container plumbing covers the rest. Testing wasn’t a chapter you reached; it’s what the previous eighteen were quietly optimising for.
19.6. Fully Booked's Suite
The repo’s tally after this chapter: domain properties and examples in the base, CatsEffectSuite program tests and the socketless HTTP contract in the middle, and the Postgres rig at the tip, all under one sbt test (the container suite detects Docker and runs where it’s available). The suite is now the longest artefact in the case study, which is what "professional" meant back in the title.
What remains is everything around the process: packaging the thing, running it, watching it breathe. Chapter 20 ships it.
19.7. Exercises
Solutions are in the companion repo under ch19/exercises.
-
The capacity property. Generalise chapter 12’s overbooking detector into a property: for any generated list of booking requests and any capacity, the winners never exceed the capacity and never exceed the requests.
Gen.listOfNandGen.chooseare the tools; run it againstbookSafelyand enjoy the machine failing to break yourmodify. -
The yardstick, restated. Port chapter 12’s two-yeses-one-lost-update test to
CatsEffectSuitestyle, nounsafeRunSyncin sight, and decide which reads better in review. (One of the two answers is wrong.) -
The dialect that differs. Write a Postgres-only test using
INSERT … RETURNING referenceto get the inserted key back in one round trip, and confirm the same SQL is refused by H2’s default mode. You’ve just documented, executably, why the container rig exists; keep the pair as the canonical example when a colleague asks.
20. Shipping It
Fully Booked works on your machine, and the phrase is a punchline for a reason. This chapter is the distance between "works here" and "runs there": one artefact, a container around it, the handful of endpoints and habits that keep operations civil, and a shutdown that doesn’t drop a diner mid-booking. None of it is glamorous, and all of it is the difference between a project and a service.
A scope note first, honestly. This is not a Kubernetes manual, a cloud tutorial or an observability platform pitch; those are books of their own and yours may already own them. This chapter ships a well-behaved container and the contract it offers whatever runs it, which is the part that belongs to Scala.
20.1. One Artefact
The JVM’s deployment unit of least surprise is a single runnable jar, and sbt’s standard tool is the assembly plugin:
// project/plugins.sbt
addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.3.1")
// build.sbt, app module
.settings(
assembly / assemblyMergeStrategy := {
case PathList("META-INF", "services", _*) => MergeStrategy.filterDistinctLines
case PathList("META-INF", _*) => MergeStrategy.discard
case _ => MergeStrategy.first
}
)
sbt app/assembly produces one jar carrying your code and every dependency, runnable anywhere a JDK stands, with the merge strategy resolving the file collisions that a hundred jars flattened into one inevitably produce. The services line is the one everyone learns the hard way: META-INF/services files are how the JVM’s ServiceLoader finds plugins, Flyway’s database support among them, and a strategy that discards them builds a jar that compiles perfectly and dies on boot with a riddle. Concatenate the service files, discard the rest of `META-INF’s signing debris, take first for everything else; when two libraries genuinely fight over a real file, the build fails and names it, which is the correct behaviour and a rite of passage.
20.2. The Container
The Dockerfile earns its keep by being boring:
FROM eclipse-temurin:21-jre-alpine
COPY target/out/jvm/scala-3.8.4/fully-booked-app/fully-booked-app-assembly-*.jar /app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "/app.jar"]
Four lines, one honest flag, and one path worth a second look: sbt 2 moved build output under a unified target/out/ tree, so the jar lives deeper than sbt 1 muscle memory expects. MaxRAMPercentage tells the JVM to size its heap from the container’s memory limit rather than the host’s, which retires the classic incident where a 512-megabyte container hosts a JVM convinced it owns the machine. Build it, run it with the database URL in the environment, and the laptop and the cluster are finally running the same bytes:
$ sbt app/assembly && docker build -t fully-booked .
$ docker run -p 8080:8080 -e DB_URL=... fully-booked
Configuration flows in as environment variables, which chapter 11’s PureConfig reads by substitution in application.conf (url = ${?DB_URL}); the artefact stays identical across environments and only the environment differs, which is the whole discipline in one sentence.
20.3. The Operational Contract
Whatever runs your container, an orchestrator, a script, a colleague, asks three questions, and answering them well is most of "production-ready".
Are you alive, and are you ready? Two endpoints, distinct on purpose: liveness ("the process functions; restart me if this fails") and readiness ("I can serve; the pool is up and migrations ran; route traffic accordingly"). They’re just Tapir endpoints in the http module, the readiness one performing a SELECT 1 through the pool, and wiring them is an exercise because you have every tool already.
What are you saying? Logs go to stdout, one event per line, and production formats them as JSON for the aggregator, which is a Logback configuration file, not code: log4cats from chapter 11 doesn’t change, the appender does. The habit that matters is in the lines themselves: log the booking reference, not the sentence about it, because 3am searches for FB-2041, not for prose.
What are you counting? Real metrics (request rates, pool saturation, latency histograms) belong to a dedicated stack, and http4s has middleware for the standard ones; the chapter’s honest advice is to adopt your organisation’s existing stack rather than pick one from a book. The floor, though, is the readiness endpoint plus logs you can aggregate, and many a service has run respectably for years on exactly that.
20.4. Dying Well
The part everyone forgets until a deploy eats a booking. When the orchestrator sends SIGTERM, the service has a grace window to finish in-flight work, and everything this book made you build with Resource now pays out at once: IOApp catches the signal and cancels the main fiber, cancellation releases resources in reverse order, Ember stops accepting new connections and drains the ones mid-flight, the pool closes after the server, and the booking that was halfway into bookAtomically either commits or rolls back, atomically, because chapter 18’s transaction boundary doesn’t care why the process is leaving.
You wrote no shutdown code just now. That’s the point, and it was purchased in chapters 11 and 18, one Resource at a time. The direct-style ledger from chapter 15 quietly gains a line here: hand-rolled try/finally discipline has to get all of this right, at every site, by hand.
20.5. Fully Booked, Shipped
The repo’s fully-booked/ closes the case study’s arc: assembly configured, Dockerfile at the root, health endpoints in the http module, and the compose file that stands up the service beside a Postgres for a one-command local run:
$ docker compose up
Chapter 1 promised "a container to ship it in", and the promise is kept. The service it set out to build now exists end to end: typed domain, valued behaviour, honest errors, effects as descriptions, an atomic engine, an API that documents itself, a database that remembers, a suite that would catch every bug this book warned about, and an artefact that dies politely. What remains in Part IV is a different kind of chapter: the Scala you didn’t write, and will inherit anyway.
20.6. Exercises
Solutions are in the companion repo under fully-booked/ (health endpoints in the http module; compose file at the root).
-
The two questions. Implement
/health/live(static 200) and/health/ready(aSELECT 1through the pool, 503 on failure) as Tapir endpoints, with socketless tests for both outcomes of readiness. Everything needed is in chapters 17 and 18; noticing that is the exercise. -
The graceful exit, witnessed. Run the compose stack, start a slow request (add a temporary
IO.sleepto a route), send the containerSIGTERM, and watch the logs: the request completes, then the server drains, then the pool closes. Write down the order you observed and match each line to theResourcethat produced it. -
The heap that fits. Run the container with
--memory=256m, hit it with a few hundred requests, and inspectdocker stats. Then removeMaxRAMPercentageand repeat, watching the JVM misjudge its world. Five minutes of experiment, one production incident’s worth of immunity.
21. Play: The Scala You’ll Inherit
Twenty chapters built a service the way this book believes services should be built. This one is about a day this book can’t build for you: the one where you join a team whose Scala predates every choice we made, and the codebase is Play. There is more Play in production than any fashionable graph will admit, much of it a decade old, most of it quietly paying salaries, and "professional Scala" includes being excellent in it without spending your first month relitigating its architecture. This chapter is field preparation, not conversion literature, in either direction.
Play is a full MVC web framework in the Rails and Django lineage: routes, controllers, templates, hot reload, batteries throughout. It was Scala’s front door for years, it’s the first function from chapter 1 grown into a framework, and chapter 1 promised you the story of what happened to its foundations. Debt-paying time.
21.1. The Story You Inherit With It
Play was built by Lightbend (né Typesafe, the company Odersky co-founded) on top of Akka, Lightbend’s actor toolkit, which handled its concurrency and HTTP plumbing. Three dates explain the codebase you’re joining. In 2021, Lightbend handed Play to the community: still maintained, no longer corporately driven. In 2022, Lightbend relicensed Akka itself from Apache 2.0 to the Business Source License, source-available but paid-for in production above a revenue floor, citing the unsustainability of funding a foundation-grade toolkit for free. The community’s response was the time-honoured one: Apache Pekko, a fork of Akka’s last Apache-licensed release, now an Apache Software Foundation project. And in 2023, Play 3.0 shipped with its internals swapped from Akka to Pekko, which is why a Play 3 stack trace mentions org.apache.pekko and why the 2.x-to-3.x jump in an inherited build file is mostly that swap.
The temperature of all this has cooled; what remains is practical. Play 3.x is community-maintained and genuinely alive, 3.0.9 at the time of writing, with current JDK support and a stated security window. Akka-the-BSL-product continues at Lightbend for those who pay. Pekko carries the Apache torch for everyone else. If your inherited build still says Play 2.9 and akka, the licence terms of the pinned old version are unchanged, and the migration to 3.x is well-trodden. Nobody needs to panic; somebody should read the licence dates.
21.2. The Shape of the Place
A Play application will disorient you for a morning precisely because its conventions are strong. The tour, compressed, with the repo’s inherited-play/ as the working example (Play 3.0.9, and note its scalaVersion: 3.3, the LTS line, which is exactly what a conservative production estate runs, and sbt 1, likewise).
Routing lives in a text file, conf/routes, compiled into code by the build:
GET /health/live controllers.BookingController.live
POST /bookings controllers.BookingController.book
Contrast is instructive: chapter 17 made endpoints values in the language, composable and interpretable; Play makes them configuration outside it, scannable at a glance and checked at compile time via code generation. Both are defensible designs, and after fifteen chapters of endpoints-as-values you can articulate the trade rather than just feel it.
Controllers are classes of `Action`s, wired by Guice, and here’s the one from the repo, whole:
@Singleton
class BookingController @Inject() (cc: ControllerComponents)
extends AbstractController(cc):
def live: Action[AnyContent] = Action {
Ok("alive")
}
def book: Action[JsValue] = Action(parse.json) { request =>
request.body.validate[BookingRequest] match
case JsError(_) =>
BadRequest(Json.obj("message" -> "malformed booking request"))
case JsSuccess(req, _) =>
if req.size < 1 || req.size > 12 then
Conflict(Json.obj("message" -> s"we can't seat a party of ${req.size}"))
else if !sittings.contains(req.slot) then
Conflict(Json.obj("message" -> s"'${req.slot}' isn't a sitting we recognise"))
else
Ok(Json.toJson(BookingConfirmed("FB-2001", 7)))
}
Read it with your dialect glasses on. @Inject: dependency wiring happens at runtime, by Guice, annotations and reflection, where chapters 16 to 20 wired everything at compile time with constructors and Resource; when a Play app fails to start with a Guice stack trace, this is the machinery talking. play.api.libs.json: a third JSON library (after circe; Play has its own, with Reads/Writes where circe said Decoder/Encoder, same idea, different spelling, and yes, the ecosystem knows). And the validation is if/else returning early results, honest imperative-flavoured Scala; your chapter 8 instincts itch, correctly, and we’ll get to what to do about the itch.
The deepest dialect difference is invisible above because the endpoints are simple: real Play controllers are full of Action.async returning Future[Result]. Future is the standard library’s older async type, and it is not IO: a Future starts executing the moment it’s created, memoises its result, and cannot be a description. The substitution reasoning from chapter 10 doesn’t hold around Future; refactoring one into a val changes when it runs. You don’t have to like it, and you do have to read it fluently, because half the JVM’s Scala speaks it. At boundaries the bridges are IO.fromFuture and unsafeToFuture(), both used exactly where an unsafeRun would be, at edges, on purpose.
21.3. Being Effective There
The professional move in an inherited Play codebase is neither rewriting it nor going native to the point of forgetting Part II. The disciplines that travel wholesale, because they’re about the domain, not the framework: ADTs for states, smart constructors at boundaries, Either with typed errors inside your service layer, pure functions extracted from controllers until the controller is a thin translation between HTTP and a core you can test without Play’s helpers. That last one is chapter 16’s module wall rebuilt culturally instead of in the build, and teams accept it readily because it makes their tests faster too.
What doesn’t travel well on day one: IO. Smuggling cats-effect into a Future-speaking codebase creates a two-dialect internal border with adapters at every crossing, and chapter 1 told you what teams do at borders. If the team wants to move, that’s a team decision with a migration plan; if you’re the new arrival, your job is to make the existing dialect’s code better in its own terms first. Credibility, then architecture.
The tests you inherit will be ScalaTest, PlaySpec with FakeRequest helpers, the repo’s spec shows the shape, and your chapter 5 and 19 instincts transfer directly: the socketless trick exists here too (controllers are functions given a stubbed context), name tests as behaviour, and property-test the pure core you’ve been extracting.
21.4. The Assessment, Honestly
Would this book start a new service on Play in 2026? No, and the preceding five chapters are the argument: endpoints as values, compile-time wiring, effects as descriptions and the testing dividends thereof. The survey data agrees, with Play mindshare declining and its Pekko foundations drawing more wariness than enthusiasm.
Will you be entirely fine inheriting one? Also yes, and more than fine: Play estates are frequently the best-staffed, best-understood systems in their organisations, boring in the way that pays. Where Play remains a defensible new choice: a team that already knows it deeply, building server-rendered MVC applications, valuing convention over composition. That’s a real niche, honestly held, and narrower than it was.
The Two Scalas, then, at the end of their thread. Chapter 1 showed you two functions and promised you’d read both; you now write both, plus the direct style making a bid to be a third. The dialects were never a war to win. They’re history plus staffing plus taste, crystallised into codebases, and the professional in this book’s title means fluency across all of it with judgement about when each earns its place. The gap the book opened on has closed, for you. The industry keeps it open, and now that’s an opportunity rather than a confusion.
21.5. Exercises
Solutions are in the companion repo under inherited-play/.
-
The schedule, inherited. Add
GET /schedule?slot=19:00to the Play app: a line inconf/routes, an action with a query parameter, play-json output. Notice which parts your chapter 17 knowledge predicted and which are convention you simply had to look up; that ratio is what framework inheritance feels like. -
The border bridge. Take
bookSafelyfrom chapter 12 (anIO-speaking function) and call it from a PlayAction.asyncviaunsafeToFuture(), placing the bridge in one clearly-named edge function. Then write down why the bridge must not spread beyond the edge, in one sentence, using the word "description". -
The extraction. The controller above validates inline. Extract its checks into a pure
validate(req): Either[String, BookingRequest]in the style of chapter 8, call it from the action, and add the ScalaTest for the pure function alone. You’ve just performed this chapter’s whole strategy in miniature: the framework keeps its skin, the domain gets your discipline.
Appendix A: Reading Scala 2 in the Wild
This is a field guide, not a migration manual. The code you inherit, the answers you find on a decade of Stack Overflow, and half the books on your shelf speak Scala 2, and the differences that matter for reading fit in a few pages. Each entry: the old spelling, the Scala 3 you know, and anything that bites.
Implicits, the big one. Scala 2 spells chapter 9’s entire given/using mechanism with one overloaded keyword. implicit val ordering: Ordering[Table] = … is a given; def sorted(implicit ord: Ordering[A]) is a using parameter; and implicit def plus a class is how extension methods were built (an "implicit class" wrapping the target type). Same machinery, one keyword doing three jobs, and the reason Scala 3 split it into given, using and extension is exactly the confusion you’ll feel for your first hour of Scala 2.
Braces everywhere, ⇒ in different places. Every block wears {}; if (cond) x else y keeps its parentheses; match cases sit inside braces. Lambdas are identical. You already read this dialect; chapter 2’s note said braces stayed legal in Scala 3, and much Scala 3 in the wild still uses them too.
object + extends App instead of @main. The App trait runs the object’s body as the program, with initialisation-order gotchas that @main was designed to end.
Enums don’t exist. Chapter 7’s sealed trait + case object/case class general form is Scala 2’s only spelling; every ADT you meet will look like that. Reading it costs you nothing, you learned the desugared form on purpose.
Type classes without derives. No derivation clauses; instances are written by hand or generated by macro libraries (you’ll see import io.circe.generic.auto._ doing invisibly what your derives Codec.AsObject does visibly). The underscore-star swap applies throughout: a Scala 2 wildcard import reads import foo._, and _ also appears where Scala 3 writes * in a few other corners.
Procedure syntax and other fossils. def run() { … } (no =, silently Unit) was deprecated for years and still lurks in old code; read it as def run(): Unit = { … }. do/while loops exist. Symbolic operator soup (<*>, |@|, the Scalaz-era zoo) mostly predates even the code you’ll inherit, but when you meet it, it’s a library method with a pronounceable alias nearby.
XML literals. Yes, val x = <foo>{bar}</foo> was legal. No, you don’t need to do anything but recognise it.
What you don’t need from a migration manual: cross-building, -Xsource:3 flags, macro rewrites. That’s maintainers' work, it’s well documented at docs.scala-lang.org, and the ecosystem finished the crossing years ago. Your job is fluent reading, and the table above is most of it.
Appendix B: The Ecosystem Map
One opinionated paragraph per neighbourhood, so a name-drop in a stand-up never leaves you blank. Star counts and fashions drift; the shapes below are stable.
The Typelevel stack is this book’s Part III and IV: Cats (the abstractions), Cats Effect (the runtime), fs2 (streams), http4s (HTTP), doobie (JDBC), circe (JSON), plus friends like Skunk (Postgres without JDBC, wire-protocol native, pure fs2) and log4cats. Coherent, principled, and everything composes because everything speaks F[_]-shaped effect types. When this book said "the second Scala", commercially, it mostly meant this neighbourhood and the next one.
The ZIO ecosystem is the same philosophy with different ergonomics and a strong batteries-included instinct: ZIO[R, E, A] bakes the error type (and an environment) into the effect itself, where Cats Effect keeps IO[A] minimal and lets Either carry typed errors. zio-http, zio-json, Quill for databases, and a culture that prizes discoverability. Everything you learned transfers on a day’s reading; the two communities rhyme far more than their forum threads suggest.
The Li Haoyi universe (os-lib, requests, upickle, cask, Mill) is the "simple Scala" counterpoint: direct style before it was a movement, minimal abstractions, excellent docs, beloved for scripts and tools. If chapter 15 appealed to you, this neighbourhood is its elder sibling, and Hands-on Scala Programming is its book.
Akka and Pekko are the actor-model estate: supervision trees, distributed systems toolkits, event sourcing. Chapter 21 told the licensing story. New work leans Pekko or moves to the newer stacks; existing Akka estates are vast, sophisticated and hiring.
Play got chapter 21. The data engineering world, Spark above all, is its own country: Scala 2.13 syntax (appendix A pays off immediately), dataframe-first thinking, cluster mechanics; a Spark job and a Cats Effect service share a language and almost nothing else. Visit with appendix A in hand.
Tooling you’ll brush against: Metals (the LSP behind VS Code Scala), Scala Native and Scala.js (the same language compiled elsewhere; Ox can’t follow you there, effect types can), scalafix (automated rewrites), and Coursier (the artifact fetcher under everything, including your scala-cli).
Where to keep learning, briefly and honestly: the library documentation in this ecosystem is unusually good, Typelevel’s and SoftwareMill’s especially; Functional Programming in Scala (the red book) deepens Part III’s ideas rigorously; Programming in Scala remains the language reference; and the Scala Discord and users forum answer questions with a patience that surprises newcomers. Go build something; you’re equipped.