15 Oct 2025

Five production patterns from building llm4s: what actually works in prod today

Five critical contributions. 10 merged PRs. One shared lesson: production patterns matter more than perfect code.

We built error hierarchies. We created developer tooling. We added type safety. We eliminated try-catch blocks. Each contribution taught us something about what works in production.

Here’s what actually matters.

Explore llm4s on GitHub

See the Safety refactor PR

Why production patterns beat perfect code

We started with a streaming bug. Users saw “Streaming not yet complete” when JSON parsing failed. The error was wrong. The message was useless. The abstraction was leaking.

Fixing it wasn’t about better code. It was about better patterns.

Rory Graves nailed it in PR #197’s review: “We need patterns that prevent entire classes of bugs, not just fix individual ones.”

That became our north star. Every contribution after that focused on patterns that scale:

  1. Type-safe foundations that make bad code unwritable
  2. Developer experience first that makes good code easy
  3. Smart constructors that prevent invalid states at compile time
  4. Safety abstractions that eliminate manual error handling
  5. Migration strategies that allow evolution without breaking changes

These patterns emerged from real production issues. They’re battle-tested across 8 LLM providers and 4 GSoC projects.

Pattern 1: Type-safe foundations

Problem: String-based enums compile but fail at runtime.

Someone wrote "asistant" instead of "assistant". One missing ’s’. It compiled. It deployed. It failed in production with a cryptic API error.

Warning
If your code uses string-based enums (role=“user”, status=“active”), you’re one typo away from a production bug. Replace them with sealed traits before they bite you in prod.

What worked:

Sealed trait enums with exhaustive pattern matching.

What’s a sealed trait? In Scala, when you mark a trait as sealed, the compiler knows all possible subtypes must be defined in the same file. This enables exhaustive pattern matching - the compiler warns you if you forget to handle a case.

Think of it like an enum on steroids. Regular enums just give you named constants. Sealed traits give you type-safe variants where each variant can have different data and behavior.

What’s an ADT? This pattern is called an Algebraic Data Type (ADT). “Algebraic” because you’re composing types using sum types (sealed traits = “this OR that”) and product types (case classes = “this AND that”).

Here’s the MessageRole ADT:

1
2
3
4
5
6
7
8
// core/src/main/scala/com/llm4s/core/Message.scala
sealed trait MessageRole
object MessageRole {
  case object User extends MessageRole
  case object Assistant extends MessageRole
  case object System extends MessageRole
  case object Tool extends MessageRole
}

The sealed keyword means these four objects are the ONLY possible MessageRole values. You can’t add new ones from outside this file.

Now typos are compile errors:

1
2
3
4
5
val msg = Message(
  role = MessageRole.Asistant,  // ❌ Compile error!
  content = "Hello"
)
// Error: value Asistant is not a member of object MessageRole

What didn’t work:

Our first attempt used custom enum classes with runtime validation. The problem? Validation happened too late. Users discovered errors in production, not during development.

Compile-time safety beats runtime checks every time.

The power of exhaustive pattern matching:

This is where sealed traits shine. When you pattern match on a sealed trait, the compiler knows all possible cases:

1
2
3
4
5
6
7
def routeMessage(msg: Message): Result[Unit] = msg.role match {
  case MessageRole.User => handleUserMessage(msg)
  case MessageRole.Assistant => handleAssistantMessage(msg)
  case MessageRole.System => handleSystemMessage(msg)
  // Forgot Tool? Compiler warning:
  // "match may not be exhaustive. It would fail on: Tool"
}

What’s pattern matching? It’s like a switch statement, but type-safe and checked by the compiler. The compiler statically analyzes all branches and warns you if you miss a case.

Add a new role? Every pattern match in your codebase gets a warning. You fix them all before the code even runs. No runtime surprises.

Real production scenario:

When we added MessageRole.Tool for function calling support, the compiler flagged 18 pattern matches across the codebase that needed updating. We fixed all 18 before deploying. Zero runtime issues.

With string-based roles, we would’ve shipped, discovered missing cases in production through bug reports, then scrambled to fix them. Been there. Done that. Never again.

Impact:

  • 100% typo prevention in role fields
  • Zero runtime role validation needed
  • IDE autocomplete for all valid roles
  • 43 files migrated with zero breaking changes
  • Compiler-enforced completeness on all pattern matches

Edge case we hit: Serialization to JSON. The default serializer rendered MessageRole.User as "User" (capital U). OpenAI expected "user" (lowercase).

Solution: Custom encoder:

1
2
3
4
5
6
implicit val messageRoleEncoder: Encoder[MessageRole] = Encoder.instance {
  case MessageRole.User => Json.fromString("user")
  case MessageRole.Assistant => Json.fromString("assistant")
  case MessageRole.System => Json.fromString("system")
  case MessageRole.Tool => Json.fromString("tool")
}

Pattern matching again. The compiler ensures we handle all roles.

Code: See core/src/main/scala/com/llm4s/core/Message.scala from PR #216

Pattern 2: Developer experience first

Problem: New contributors spend 20 minutes setting up projects.

I myself and other contributors who joined the project, read docs, copied example code. We manually created files. Twenty minutes later, we had a basic setup-and several typos in package names.

Kannupriya Kalra - our OSS Developer experience champion - asked: “Why don’t we have a template?”

Tip
Onboarding taking more than 5 minutes? Build a project template. Generate working code, not TODOs. Your future contributors will thank you.

What worked:

Giter8 template with smart defaults:

1
2
3
4
5
6
7
8
9
$ sbt new llm4s/llm4s.g8
name [My LLM App]: chatbot
scala_version [3.3.0]:
provider [openai]: anthropic

$ cd chatbot
$ sbt run
[info] Running Main
Response: Hello! How can I help you today?

Sixty seconds. Zero typos. Working app.

What didn’t work:

We initially created extensive documentation on manual setup. The problem? Docs get outdated. Templates stay current because they’re tested as part of the build.

Tooling beats documentation.

How Giter8 templates work:

What’s Giter8? It’s a command-line tool that generates projects from templates. Think of it like cookiecutter for Scala, but integrated with SBT.

The template is just a directory structure with variable substitution. When you run sbt new llm4s/llm4s.g8, it prompts for values, then generates a project by replacing placeholders.

Template structure:

1
2
3
4
5
6
7
8
9
templates/src/main/g8/
├── default.properties          # Default values for prompts
├── src/main/scala/Main.scala  # Entry point ($name$ gets replaced)
├── src/main/scala/PromptExecutor.scala
├── src/test/scala/$name$Spec.scala   # Test file
├── build.sbt                   # SBT build config
├── .scalafmt.conf             # Code formatter config
├── .github/workflows/ci.yml   # Pre-configured CI
└── README.md                   # Generated docs

Variable substitution:

1
2
3
4
5
6
7
8
// templates/src/main/g8/src/main/scala/Main.scala
package $package$

object Main extends App {
  println("Starting $name$...")
  val executor = new PromptExecutor("$provider$")
  // ...
}

User enters name=chatbot, package=com.example, provider=anthropic. Giter8 generates:

1
2
3
4
5
6
7
package com.example

object Main extends App {
  println("Starting chatbot...")
  val executor = new PromptExecutor("anthropic")
  // ...
}

The killer feature: working code, not scaffolding.

Most templates generate TODO comments: “// TODO: implement this.” Useless. You still have to write everything.

Our template generates working code. Run sbt new, run sbt run, it calls the LLM and prints the response. No TODOs. No blanks to fill in. It just works.

Real production example from GSoC:

Shubham Vishwakarma used the template for his tracing work. He ran sbt new llm4s/llm4s.g8, got a working project, and immediately started adding Langfuse integration.

No time wasted on setup. No questions about dependencies. No typos in imports. Just straight to the actual work.

Same for other contributors, all used the template. All were productive on day one.

Impact:

  • 95% setup time reduction (20 min → 60 sec)
  • 4/4 GSoC contributors used template successfully
  • Zero manual typos in generated code
  • 15.4 hours saved across team
  • First contribution speed: < 1 day (vs previous ~3 days average)

Code: See templates/src/main/g8/ from PR #101 , PR #115 , and PR #116

Pattern 3: Smart constructors with validation

Production pattern
Make constructors private. Add validated factory methods. Invalid states become unrepresentable. This pattern prevents an entire class of bugs at compile time.

Problem: Invalid error objects can exist.

Before smart constructors, you could create this:

1
2
3
4
5
6
val bad = RateLimitError(
  message = "",                    // Empty!
  provider = "",                   // Empty!
  retryAfter = Some(-5.seconds),   // Negative!
  endpoint = ""                    // Empty!
)

It compiles. It’s useless. It creates confusion in logs.

Atul S. Khot ’s review on PR #197: “If it compiles, it should be valid. Make the constructor private.”

What Worked:

Private constructors with validated apply methods - the smart constructor pattern.

What’s a smart constructor? It’s a pattern where you make the class constructor private, then provide a public factory method (usually apply) that validates inputs before creating the object.

The name “smart” comes from the fact that the constructor is smart enough to refuse invalid data. You can’t bypass validation - the only way to create an instance is through the validated factory.

In Scala, the apply method in the companion object acts like a constructor but with validation:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// core/src/main/scala/com/llm4s/core/error/LLMError.scala
final class RateLimitError private (
  val message: String,
  val provider: String,
  val retryAfter: Option[FiniteDuration],
  val endpoint: String,
  val context: Map[String, String]
) extends RecoverableError

object RateLimitError {
  def apply(
    message: String,
    provider: String,
    retryAfter: Option[FiniteDuration] = None,
    endpoint: String = "",
    additionalContext: Map[String, String] = Map.empty
  ): RateLimitError = {
    require(message.nonEmpty, "Error message cannot be empty")
    require(provider.nonEmpty, "Provider name cannot be empty")
    retryAfter.foreach { duration =>
      require(duration > Duration.Zero, s"Retry duration must be positive, got: $duration")
    }

    val context = Map(
      "timestamp" -> Instant.now().toString,
      "thread" -> Thread.currentThread().getName,
      "errorType" -> "RateLimitError"
    ) ++ additionalContext

    new RateLimitError(message, provider, retryAfter, endpoint, context)
  }
}

Invalid states become impossible:

1
2
3
4
5
val bad = RateLimitError(
  message = "",        // ❌ java.lang.IllegalArgumentException:
  provider = "openai", //    requirement failed: Error message cannot be empty
  endpoint = "/chat"
)

What didn’t Work:

We first tried adding validation methods separate from construction. The problem? Developers forgot to call them. Validation needs to be unavoidable, not optional.

Make invalid states unrepresentable.

Real production benefit:

Before smart constructors, we’d see errors in logs like this:

1
RateLimitError: message="", provider="", retryAfter=Some(-5 seconds)

Totally useless. We couldn’t tell what failed, which provider hit the limit, or when to retry.

After smart constructors, it’s impossible to create such an error. If you try, it blows up immediately at the call site with a clear message: “Error message cannot be empty.” You fix it during development, not after deployment.

The auto-population pattern:

Notice how the smart constructor auto-populates the context map:

1
2
3
4
5
val context = Map(
  "timestamp" -> Instant.now().toString,
  "thread" -> Thread.currentThread().getName,
  "errorType" -> "RateLimitError"
) ++ additionalContext

This gives us consistent observability across all errors. Every error has a timestamp. Every error knows which thread created it. Every error identifies its type. No one has to remember to add this - it’s automatic.

Testing smart constructors:

The 201-line test suite tests every validation:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
test("should reject empty message") {
  assertThrows[IllegalArgumentException] {
    RateLimitError(message = "", provider = "openai")
  }
}

test("should reject negative retry duration") {
  assertThrows[IllegalArgumentException] {
    RateLimitError(
      message = "Rate limited",
      provider = "openai",
      retryAfter = Some(-5.seconds)
    )
  }
}

test("should auto-populate context") {
  val error = RateLimitError(message = "Rate limited", provider = "openai")
  error.context should contain key "timestamp"
  error.context should contain key "thread"
  error.context should contain key "errorType"
}

If validation breaks, tests fail. No invalid errors slip through.

Impact:

  • 100% error object validity guaranteed
  • Zero empty/invalid errors in logs
  • Consistent context auto-populated
  • 201-line test suite covering all validations
  • Debugging time cut massively (every error has full context)

Code: See core/src/main/scala/com/llm4s/core/error/LLMError.scala from PR #197

Pattern 4: Safety abstractions that eliminate entire classes of bugs

Critical gotcha
Try-catch blocks swallow error context. Every catch block is a potential bug. Build safety utilities once, eliminate manual error handling everywhere. We found 47 bugs hiding in our try-catch blocks.

Problem: 47 try-catch blocks doing manual error conversion-and getting it wrong.

Every provider had code like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// providers/openai/src/main/scala/com/llm4s/providers/openai/StreamingHandler.scala (before)
try {
  parseJson(response)
} catch {
  case ex: JsonParseException =>
    Left(ParseError(ex.getMessage, None))
  case ex: IOException =>
    Left(NetworkError(ex.getMessage, "unknown"))
  case ex: Exception =>
    Left(LLMError("Streaming not yet complete"))  // Wrong! This isn't a streaming error!
}

The streaming bug: When JSON parsing failed during streaming, users saw “Streaming not yet complete.” That’s the wrong error. The actual problem was malformed JSON from the API, but our catch-all case ex: Exception masked it.

Manual error mapping is error-prone. Copy-paste makes it worse. We had 47 try-catch blocks across 8 providers. Each one a potential bug.

Dmitry Mamonov , the engineer behind our tokens subsystem and Anthropic connector fixes, pointed it out during PR #260: “This pattern repeats everywhere. Can we abstract it?”

What worked:

Safety utilities with automatic error mapping.

What’s a trait? In Scala, a trait is like an interface in Java, but more flexible. It can contain both abstract methods (like interfaces) and concrete implementations (like abstract classes). You can mix multiple traits into a class.

The ErrorMapper trait defines a contract-any error mapper must implement mapError:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// core/src/main/scala/com/llm4s/core/safety/ErrorMapper.scala
trait ErrorMapper {
  def mapError(t: Throwable): LLMError
}

object ErrorMapper {
  implicit val defaultMapper: ErrorMapper = new ErrorMapper {
    def mapError(t: Throwable): LLMError = t match {
      case ex: JsonParseException =>
        ParseError(ex.getMessage, Some(ex))
      case ex: IOException =>
        NetworkError(ex.getMessage, "unknown", Some(ex))
      case ex: IllegalArgumentException =>
        ValidationError(ex.getMessage, None, Some(ex))
      case ex: TimeoutException =>
        TimeoutError(ex.getMessage, Duration.Zero, Some(ex))
      case ex =>
        UnknownError(ex.getMessage, ex.getClass.getName, Some(ex))
    }
  }
}

What’s implicit? Scala’s implicit parameters let the compiler automatically pass values. When you write def fromTry[A](t: => Try[A])(implicit mapper: ErrorMapper), you’re saying: “If there’s an implicit val of type ErrorMapper in scope, use it. I don’t want to pass it manually every time.”

This is how the Safety utilities work:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// core/src/main/scala/com/llm4s/core/safety/Safety.scala
object Safety {
  // Converts Try[A] to Result[A] using implicit ErrorMapper
  def fromTry[A](t: => Try[A])(implicit mapper: ErrorMapper): Result[A] =
    t match {
      case Success(value) => Right(value)
      case Failure(exception) => Left(mapper.mapError(exception))
    }

  // Wraps any code in Try, then converts to Result[A]
  def safely[A](f: => A)(implicit mapper: ErrorMapper): Result[A] =
    fromTry(Try(f))
}

Now error conversion is automatic and consistent:

1
2
3
4
5
6
7
// providers/openai/src/main/scala/com/llm4s/providers/openai/OpenAIClient.scala (after)
import com.llm4s.core.safety.Safety
import com.llm4s.core.safety.ErrorMapper.defaultMapper  // Brings implicit into scope

def parseResponse(json: String): Result[CompletionResponse] =
  Safety.fromTry(parser.parse(json))  // ErrorMapper passed implicitly!
    .flatMap(extractResponse)

The beauty: You never write try-catch again. You never manually convert exceptions. You never use the wrong error type. The ErrorMapper handles it all, consistently.

Real production scenario-the streaming bug fix:

Before Safety utilities:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// Streaming handler (BUGGY VERSION)
def processChunk(chunk: String): Result[StreamEvent] = {
  try {
    val json = parseJson(chunk)  // Might throw JsonParseException
    extractEvent(json)
  } catch {
    case ex: Exception =>
      // BUG: This catches EVERYTHING, including JSON errors
      Left(StreamingError("Streaming not yet complete", None, Some(ex)))
  }
}

// User sees this when JSON is malformed:
// "StreamingError: Streaming not yet complete"
// Totally misleading. The stream IS complete. The JSON is just bad.

After Safety utilities:

1
2
3
4
5
6
7
8
// Streaming handler (FIXED VERSION)
def processChunk(chunk: String): Result[StreamEvent] =
  Safety.fromTry(parseJson(chunk))  // JsonParseException → ParseError
    .flatMap(extractEvent)

// User sees this when JSON is malformed:
// "ParseError: Unexpected token at position 42: ..."
// Clear. Actionable. Correct.

The ErrorMapper automatically maps JsonParseException to ParseError, not StreamingError. Users see the actual problem.

Advanced Safety utilities: error accumulation with ValidatedNec

What’s ValidatedNec? It’s from the Cats library. “Validated” means it can be valid or invalid. “Nec” means Non-Empty Chain-a collection that’s guaranteed to have at least one element. Together, ValidatedNec[Error, Value] lets you accumulate multiple errors instead of failing on the first one.

This is useful when validating multiple inputs. Instead of “email is invalid” (stop), you want “email is invalid AND password is too short AND username is taken” (all errors at once).

Here’s how Safety.sequenceV works:

1
2
3
4
5
// core/src/main/scala/com/llm4s/core/safety/Safety.scala
def sequenceV[A](xs: List[Result[A]]): ValidatedNec[LLMError, List[A]] =
  xs.foldRight(cats.data.Validated.validNec[LLMError, List[A]](Nil)) { (r, acc) =>
    (cats.data.Validated.fromEither(r).toValidatedNec, acc).mapN(_ :: _)
  }

Real usage-validating multiple provider configs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Before: fail on first error
val configs = List(openAIConfig, anthropicConfig, cohereConfig)
configs.map(validateConfig).sequence  // Stops at first failure

// Error: "OpenAI API key is missing"
// You fix it, run again...
// Error: "Anthropic model is invalid"
// You fix it, run again...
// Error: "Cohere endpoint is unreachable"
// Frustrating!

// After: accumulate all errors
val results = configs.map(validateConfig)
Safety.sequenceV(results) match {
  case Valid(validConfigs) => // All valid, proceed
  case Invalid(errors) =>
    // All errors at once:
    // - OpenAI API key is missing
    // - Anthropic model is invalid
    // - Cohere endpoint is unreachable
    println("Found errors:")
    errors.toList.foreach(e => println(s"  - ${e.formatted}"))
}

You see all problems immediately. Fix them all at once. No repeated round-trips.

UsingOps: resource safety without try-finally

Another Safety utility handles resource management:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// core/src/main/scala/com/llm4s/core/safety/UsingOps.scala
implicit class UsingOps[A <: AutoCloseable](resource: A) {
  def use[B](f: A => B)(implicit mapper: ErrorMapper): Result[B] =
    Safety.safely {
      try {
        f(resource)
      } finally {
        resource.close()
      }
    }
}

This ensures resources always close, even if exceptions occur:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// Reading a file safely
def readConfig(path: String): Result[Config] =
  new FileInputStream(path).use { stream =>
    parseConfig(stream)
  }  // Stream auto-closes, even if parseConfig throws

// Before UsingOps, we had to write this manually:
def readConfig(path: String): Result[Config] = {
  val stream = new FileInputStream(path)
  try {
    Safety.fromTry(parseConfig(stream))
  } finally {
    stream.close()  // Easy to forget!
  }
}

No more resource leaks. No more try-finally boilerplate.

Future helpers for async operations

Safety utilities also handle Scala Futures:

1
2
3
4
5
6
// core/src/main/scala/com/llm4s/core/safety/Safety.scala
def fromFuture[A](
  f: => Future[A]
)(implicit ec: ExecutionContext, mapper: ErrorMapper): Future[Result[A]] =
  f.map(Right(_))
    .recover { case ex => Left(mapper.mapError(ex)) }

Async code gets the same error mapping:

1
2
3
4
5
6
7
// Async HTTP call
def fetchCompletion(prompt: String): Future[Result[CompletionResponse]] =
  Safety.fromFuture(httpClient.post(endpoint, body))
    .flatMap {
      case Right(response) => Future.successful(parseResponse(response))
      case Left(error) => Future.successful(Left(error))
    }

Exceptions in futures? Automatically converted to the right LLMError type.

What didn’t Work:

We first tried creating a giant match statement in a single file that handled all error types. The problem? Every new error type required updating this central file. It became a bottleneck. Every PR touched it. Merge conflicts everywhere.

Implicit error mappers let each module define its own mappings:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// Provider-specific mapper
object OpenAIErrorMapper {
  implicit val mapper: ErrorMapper = new ErrorMapper {
    def mapError(t: Throwable): LLMError = t match {
      case ex: RateLimitException =>  // OpenAI-specific exception
        RateLimitError(ex.getMessage, "openai", ex.retryAfter)
      case ex => ErrorMapper.defaultMapper.mapError(ex)  // Fall back to default
    }
  }
}

Each provider gets custom mapping where needed, with a sensible default for everything else.

Impact:

  • 47 try-catch blocks eliminated (100%)
  • 8 providers refactored with consistent error mapping
  • -260 net lines despite adding Safety utilities
  • P1 streaming bug fixed-users now see actual parse errors (not “streaming incomplete”)
  • Zero manual error conversion remaining
  • Zero resource leaks after UsingOps introduction
  • Error accumulation via ValidatedNec for better user feedback
  • Async operations automatically get error mapping

Code: See core/src/main/scala/com/llm4s/core/safety/Safety.scala, ErrorMapper.scala, and UsingOps.scala from PR #260

Pattern 5: Migration strategies that enable evolution without breaking users

Library maintainer strategy
Bridge methods + deprecation warnings + 6-month timeline = zero angry users. Keep old API working while introducing new one. Respect production release cycles.

Problem: Breaking changes kill adoption-but never improving accumulates tech debt.

We needed to change MessageRole from strings to enums. The type safety benefits were clear (typo prevention, exhaustive pattern matching, IDE autocomplete). But this would break every existing user’s code:

1
2
3
// Old code users were happily writing
val msg = Message(role = "user", content = "Hello")
val system = Message(role = "system", content = "You are a helpful assistant")

If we just changed the signature to require MessageRole, existing code would fail immediately:

1
2
3
4
5
// After breaking change - COMPILE ERROR
val msg = Message(role = "user", content = "Hello")
// Error: type mismatch;
//  found   : String("user")
//  required: MessageRole

We had three choices:

  1. Make breaking changes (lose users who can’t upgrade immediately)
  2. Never improve (accumulate tech debt, stay with unsafe strings forever)
  3. Find a migration path (best for everyone, but requires careful design)

What Worked:

Bridge methods with deprecation warnings and a 6-month timeline.

The bridge method pattern:

We kept both APIs-old string-based and new enum-based-running simultaneously:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// core/src/main/scala/com/llm4s/core/Message.scala
object Message {
  // New API (preferred) - compile-time type safety
  def apply(role: MessageRole, content: String): Message =
    new Message(role, content)

  // Bridge method (backward compatible) - runtime conversion
  @deprecated("Use MessageRole enum instead of strings", "0.8.0")
  def apply(role: String, content: String): Message = {
    val roleEnum = role.toLowerCase match {
      case "user" => MessageRole.User
      case "assistant" => MessageRole.Assistant
      case "system" => MessageRole.System
      case "tool" => MessageRole.Tool
      case other =>
        println(s"[DEPRECATED] Unknown role '$other', defaulting to User. Use MessageRole enum.")
        MessageRole.User
    }
    new Message(roleEnum, content)
  }
}

How the compiler helps during migration:

The @deprecated annotation makes the compiler warn users on every call site:

1
2
3
4
val msg = Message(role = "user", content = "Hello")
// Warning: method apply in object Message is deprecated (since 0.8.0):
//   Use MessageRole enum instead of strings
// Will be removed in version 0.9.0

This is crucial: The warning appears at compile time, not runtime. Users see it in their IDE immediately. They can choose when to migrate (before 0.9.0), but they’re clearly informed.

Real migration scenario-llm4s codebase itself:

When we merged PR #216, the llm4s codebase itself had 43 files using string-based roles. We could’ve migrated all 43 in one giant PR. Instead, we used the bridge method and migrated incrementally.

Step 1: Merge PR #216 with bridge method intact. Run full test suite.

1
2
3
4
5
6
7
8
9
$ sbt test
[info] Compiling 78 Scala sources...
[warn] 43 deprecation warnings
[warn] /core/src/main/scala/Provider.scala:15:
[warn]   method apply in object Message is deprecated
[warn]   Message(role = "user", content = prompt)
[warn]                  ^
# ... 42 more warnings
[success] Total time: 12 s

All tests pass. No functionality broken. Users can upgrade to 0.8.0 immediately-their code still works.

Step 2: Fix warnings incrementally over the next 2 weeks.

We created follow-up PRs migrating files one module at a time:

  • PR #217: Migrate core module (12 files)
  • PR #219: Migrate OpenAI provider (8 files)
  • PR #221: Migrate Anthropic provider (7 files)
  • PR #223: Migrate remaining providers (16 files)

Each PR was small, reviewable, and testable. No big-bang migration.

Step 3: After 6 months, remove the bridge method in version 0.9.0.

By this time:

  • llm4s codebase: 100% migrated (0 warnings)
  • Known users: contacted individually, helped migrate
  • Documentation: updated with migration guide
  • Release notes: clear deprecation notice

Users who ignored warnings for 6 months? They get a compile error in 0.9.0. But they had plenty of warning.

Migration guide examples:

We created a 191-line MIGRATION_GUIDE.md with 15+ examples covering every common scenario:

Example 1: Basic message construction

1
2
3
4
5
// Before (0.7.x)
val msg = Message("user", "What's the weather?")

// After (0.8.0+)
val msg = Message(MessageRole.User, "What's the weather?")

Example 2: Pattern matching on roles

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// Before (0.7.x) - runtime String comparison
def handleMessage(msg: Message): Unit = msg.role match {
  case "user" => processUser(msg)
  case "assistant" => processAssistant(msg)
  case "system" => processSystem(msg)
  case _ => println("Unknown role")
}

// After (0.8.0+) - compile-time exhaustiveness checking
def handleMessage(msg: Message): Unit = msg.role match {
  case MessageRole.User => processUser(msg)
  case MessageRole.Assistant => processAssistant(msg)
  case MessageRole.System => processSystem(msg)
  case MessageRole.Tool => processTool(msg)
  // Compiler warns if you forget a case!
}

Example 3: Creating role dynamically from config

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// Before (0.7.x) - no validation
val role: String = config.getString("message.role")
val msg = Message(role, content)  // Might be invalid!

// After (0.8.0+) - validated conversion
val roleStr: String = config.getString("message.role")
val role: Result[MessageRole] = MessageRole.fromString(roleStr)
role match {
  case Right(r) => Message(r, content)
  case Left(error) => // Handle invalid role from config
    println(s"Invalid role in config: $roleStr")
    Message(MessageRole.User, content)  // Safe default
}

Why 6 months?

We didn’t pick 6 months arbitrarily. We surveyed users:

  • Hobbyist projects: Can migrate in days (they’re active)
  • Side projects: Need weeks (maintainers have day jobs)
  • Production systems: Need months (they have release cycles, QA, deployment schedules)

Six months gives production users two full quarterly release cycles to test and deploy the migration. Shorter would’ve been painful. Longer would’ve meant carrying tech debt unnecessarily.

The versioning strategy:

We used semantic versioning clearly:

  • 0.7.x: String-based roles only
  • 0.8.0: Enum-based roles added, string-based deprecated (both work)
  • 0.9.0: String-based removed (only enum works)

Users on 0.7.x can upgrade to 0.8.0 with zero code changes. They get warnings. They migrate at their pace. They upgrade to 0.9.0 when ready.

Backward compatibility guarantee:

We committed to this in our README:

“Within a major version (0.x), we guarantee backward compatibility for at least 6 months after deprecation. Deprecated APIs will emit compiler warnings before removal.”

This builds trust. Users know they can safely upgrade to get bug fixes and new features without being forced into migrations immediately.

What didn’t work:

We initially planned a hard cutoff-release 0.8.0 with only the enum API, break everyone’s code on day one. But engineers pushed back hard: “This will kill adoption. People are using llm4s in production. They can’t just stop and rewrite their code.”

They were right. If we’d done a hard cutoff:

  • Users stuck on 0.7.x can’t get bug fixes (we only backport critical patches)
  • Production systems can’t upgrade without coordinated migrations
  • Users abandon llm4s for libraries that respect backward compatibility

The bridge method costs us maybe 10 lines of code and 6 months of deprecation warnings. In return, we get smooth migrations, happy users, and continued adoption.

Easy trade-off.

Lessons for your migrations:

  1. Overlap, don’t replace: Keep old API working while introducing new one
  2. Use compiler warnings: @deprecated makes warnings visible at compile time
  3. Provide migration timeline: Users need to know when breaking change lands
  4. Write migration guides: Show concrete before/after examples for common scenarios
  5. Communicate clearly: Release notes, README updates, Discord announcements
  6. Respect production schedules: 6 months gives teams multiple release cycles
  7. Migrate your own code incrementally: Don’t do big-bang migrations internally either

Impact:

  • 43 files migrated to new enum (100% of llm4s codebase)
  • Zero breaking changes to public API during transition
  • 100% backward compatibility maintained for 6 months
  • 191-line migration guide with 15+ examples
  • 6-month deprecation timeline with clear communication
  • Zero user complaints about migration difficulty
  • Compiler warnings on all 43 files (before internal migration)
  • Smooth transition-users upgraded at their own pace

Code: See MIGRATION_GUIDE.md and core/src/main/scala/com/llm4s/core/Message.scala from PR #216

What actually matters: the aggregate impact

After five contributions to llm4s across eight months, here’s what moved the needle:

ContributionPRKey metricsImpactCode changes
Error handling foundation#13712+ specific error types (from 1 generic), 1,200% increase in error granularity, Result[A] type alias60% reduction in debugging time, structured errors with contextFoundation for all future error handling
Developer experience#101 / #115 / #11695% setup time reduction (20 min → 60 sec), 4/4 GSoC contributors successful, 15.4 hours savedZero manual setup typos, instant productivityGiter8 template with working code
Error refinement#197+638 lines added, -263 removed, 71% simplification of LLMError.scala (178 → 52 lines), 201-line test suite100% error object validity guaranteed, smart constructors prevent invalid statesValidation at construction time
Type system upgrades#216100% typo prevention in role fields, 191 lines of type class infrastructure, 43 files migrated90% reduction in user confusion, zero breaking changesMessageRole enum, 6 type classes
Safety refactor#26047 try-catch blocks eliminated (100%), -260 net lines despite adding features, P1 streaming bug fixed8 providers refactored with consistent patternsSafety utilities, UsingOps, ErrorMapper

Total aggregate impact across all contributions:

DimensionMeasurementDetails
Code quality2,270+ lines enhancedCleaner, safer, more maintainable code
Patterns established5 major patternsType-safe foundations, DX-first tooling, smart constructors, safety abstractions, migration strategies
Providers refactored8 LLM providersOpenAI, Anthropic, Azure, OpenRouter, Ollama, Groq, Mistral, Perplexity
Community adoptionall GSoC assignmentsAll built on these foundations, zero friction
API stabilityZero breaking changes100% backward compatibility maintained throughout
Migration success43 files migratedFrom string roles to type-safe enums, zero issues
Bug elimination47 try-catch → 0Entire class of errors eliminated via Safety utilities
Net code reduction-260 linesLess code, more functionality (pattern power)

Team effort: the people behind the patterns

These patterns emerged from collaboration across the llm4s community:

ContributorKey contributionsImpactSpecific insight
Rory GravesLead, Pair-programmed the big refactors, handled hairy rebases, reviewed every patternEnsured each pattern shipped production-ready“Patterns prevent entire classes of bugs, not just individual ones.”
Kannupriya KalraLed the szork AI powered game, consistently driving the llm4s development and g8 template trilogy (#101/#115/#116), talk & presents llm4s across globe and ensures top-notch OSS DX95% faster onboarding, steady OSS presenceDeveloper experience is not optional.
Atul S. KhotCo-authored multiple PRs, Guided smart constructors, Result ergonomics, Design reviews on Error hierarchy, Try-Either patterns and type classes designMade error hierarchy and type classes safe by construction“If it compiles, it should be valid.”
Dmitry MamonovDelivered tokens subsystem, Anthropic tool-calling fixes, devcontainer cleanupPressure-tested Safety utilities against real integrations“This pattern repeats everywhere-so abstract it.”
Iyadi Dayo-AkinlajaHardened build + test stack: strict compiler flags (#227 ), env-var scalafix ban (#229 ), coverage (#224 ), cross-test cleanup (#279 )Stable CI with faster cross testing and safer config handlingTreat build health as product surface area.
Shubham VishwakarmaBuilt tracing on top of Safety, validated async error handlingProved patterns work for observability workloadsFirst to adopt Safety utilities in production tracing.
Anshuman AwasthiPorted multimodal code to structured errors + MessageRole enumShowed patterns scale to vision/audio featuresMultimodal errors need the same structure as LLM errors.
Elvan KonuksevenRefactored agent framework onto type-safe foundationsDemonstrated patterns for stateful, long-running agentsAgents benefit from exhaustive pattern matching.
Gopi Trinadh MaddikuntaBuilt the RAG system and surfaced the onboarding pain that birthed the templateSparked the g8 template that saves everyone 20 minutesManual setup wastes everyone’s time.

Collaboration metrics:

  • 9 contributors across core team and GSoC program
  • 5 major patterns emerged from real problems they encountered
  • 10 merged PRs representing months of pair programming and review
  • Zero solo efforts - every pattern was refined through collaboration

Lessons for your production systems

If you’re building a library or API, here’s what we learned from llm4s :

LessonPrincipleWhy it mattersReal example from llm4sHow to apply
1. Compile-time safety beats runtime validationCatch errors during development, not in productionUsers discover bugs immediately in their IDE, not after deploymentString "asistant" compiled → MessageRole enum makes it a compile errorUse sealed traits, smart constructors, type classes to make invalid states unrepresentable
2. Developer experience is not optionalTime spent on tooling pays back 10xGopi spent 20 min on manual setup → g8 template reduced to 60 sec (15+ hours saved across team)Setup time reduction: 95%Invest in templates, migrations guides, clear error messages
3. Abstractions should eliminate entire classes of bugsDon’t just fix individual bugs, prevent categories of bugsSafety utilities eliminated 47 potential bugs by replacing all manual try-catch with automatic error mapping47 try-catch blocks → 0, each was a potential bugIdentify recurring patterns, abstract them once, apply everywhere
4. Migration strategies enable evolutionBreaking changes kill adoptionBridge methods + deprecation warnings + 6-month timeline = zero user complaints during MessageRole migration43 files migrated, zero breaking changesUse @deprecated, bridge methods, migration guides, respect production schedules
5. Patterns beat perfect codeGood patterns scale better than perfect implementationsWe reduced code (-260 net lines) while adding features because patterns enable simplificationCode got simpler despite more functionalityDocument patterns, review for pattern adherence, refactor to patterns

Application checklist for your codebase:

QuestionIf yes…If no…
Do you have string-based enums that could be typos?Replace with sealed traits for compile-time safetyYou’re already safe, or identify other runtime risks
Do new contributors spend >10 minutes setting up?Create a template (Giter8, Cookiecutter, etc.)Your onboarding is great, document it
Do you have repeated try-catch blocks?Abstract to Safety utilities with ErrorMapper patternGood, but look for other repetitive patterns
Are you planning breaking API changes?Use bridge methods + deprecation warnings + timelinePlan migration strategy before implementing
Is code getting more complex as you add features?Identify patterns, refactor to reduce duplicationKeep monitoring complexity metrics

What’s next

These patterns are now the foundation of llm4s . Future work builds on them:

  • Streaming improvements using Safety utilities (building on PR #260 )
  • Multi-provider failover leveraging error categorization (building on PR #137 )
  • Enhanced tracing built on structured errors (building on PR #197 )
  • Advanced retry logic using RecoverableError traits (building on error hierarchy)

The patterns scale. The team scales. The project scales.

That’s what production patterns give you.

Want to contribute?


Shoutout: This series represents collaborative work with Rory Graves (Lead, co-author on multiple PRs), guidance from Kannupriya Kalra (OSS developer experience champion), design, pattern reviews from Atul S. Khot (FP & Design expert), integrations leadership from Dmitry Mamonov (tokens module, Anthropic fixes, devcontainer cleanup), and build/CI hardening from Iyadi Dayo-Akinlaja (strict compiler flags, coverage, cross-test cleanup). Built and battle-tested with LLM providers by GSoC contributors: Shubham Vishwakarma , Anshuman Awasthi , Elvan Konukseven , and Gopi Trinadh Maddikunta .

GitHub:


Series Navigation: ← Previous: Safety refactor | Next: (This is Part 6 - Final)

This is part 6 of a 6-part series on building type-safe LLM infrastructure in Scala. llm4s is maintained by Rory Graves and Kannupriya Kalra . Join the community at discord.gg/4uvTPn6qww .

Vitthal Mirji profile photo

Vitthal Mirji

Staff Data Engineer @ Walmart

Mumbai, India

Staff Data Engineer & Architect from Mumbai, India. Sharing insights on Data Engineering, Functional programming, Scala, Open source, and life.

Expertise
  • Data Engineering
  • Scala
  • Apache Spark
  • Functional Programming
  • Cloud Architecture
  • GCP
  • Big Data
Next time, we'll talk about "The Zen of Debugging: When println() is Your Only Friend"