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.
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:
- Type-safe foundations that make bad code unwritable
- Developer experience first that makes good code easy
- Smart constructors that prevent invalid states at compile time
- Safety abstractions that eliminate manual error handling
- 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.
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:
| |
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:
| |
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:
| |
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:
| |
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?”
What worked:
Giter8 template with smart defaults:
| |
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:
| |
Variable substitution:
| |
User enters name=chatbot, package=com.example, provider=anthropic. Giter8 generates:
| |
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
Problem: Invalid error objects can exist.
Before smart constructors, you could create this:
| |
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:
| |
Invalid states become impossible:
| |
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:
| |
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:
| |
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:
| |
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
Problem: 47 try-catch blocks doing manual error conversion-and getting it wrong.
Every provider had code like this:
| |
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:
| |
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:
| |
Now error conversion is automatic and consistent:
| |
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:
| |
After Safety utilities:
| |
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:
| |
Real usage-validating multiple provider configs:
| |
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:
| |
This ensures resources always close, even if exceptions occur:
| |
No more resource leaks. No more try-finally boilerplate.
Future helpers for async operations
Safety utilities also handle Scala Futures:
| |
Async code gets the same error mapping:
| |
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:
| |
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
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:
| |
If we just changed the signature to require MessageRole, existing code would fail immediately:
| |
We had three choices:
- Make breaking changes (lose users who can’t upgrade immediately)
- Never improve (accumulate tech debt, stay with unsafe strings forever)
- 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:
| |
How the compiler helps during migration:
The @deprecated annotation makes the compiler warn users on every call site:
| |
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.
| |
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
| |
Example 2: Pattern matching on roles
| |
Example 3: Creating role dynamically from config
| |
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:
- Overlap, don’t replace: Keep old API working while introducing new one
- Use compiler warnings:
@deprecatedmakes warnings visible at compile time - Provide migration timeline: Users need to know when breaking change lands
- Write migration guides: Show concrete before/after examples for common scenarios
- Communicate clearly: Release notes, README updates, Discord announcements
- Respect production schedules: 6 months gives teams multiple release cycles
- 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:
| Contribution | PR | Key metrics | Impact | Code changes |
|---|---|---|---|---|
| Error handling foundation | #137 | 12+ specific error types (from 1 generic), 1,200% increase in error granularity, Result[A] type alias | 60% reduction in debugging time, structured errors with context | Foundation for all future error handling |
| Developer experience | #101 / #115 / #116 | 95% setup time reduction (20 min → 60 sec), 4/4 GSoC contributors successful, 15.4 hours saved | Zero manual setup typos, instant productivity | Giter8 template with working code |
| Error refinement | #197 | +638 lines added, -263 removed, 71% simplification of LLMError.scala (178 → 52 lines), 201-line test suite | 100% error object validity guaranteed, smart constructors prevent invalid states | Validation at construction time |
| Type system upgrades | #216 | 100% typo prevention in role fields, 191 lines of type class infrastructure, 43 files migrated | 90% reduction in user confusion, zero breaking changes | MessageRole enum, 6 type classes |
| Safety refactor | #260 | 47 try-catch blocks eliminated (100%), -260 net lines despite adding features, P1 streaming bug fixed | 8 providers refactored with consistent patterns | Safety utilities, UsingOps, ErrorMapper |
Total aggregate impact across all contributions:
| Dimension | Measurement | Details |
|---|---|---|
| Code quality | 2,270+ lines enhanced | Cleaner, safer, more maintainable code |
| Patterns established | 5 major patterns | Type-safe foundations, DX-first tooling, smart constructors, safety abstractions, migration strategies |
| Providers refactored | 8 LLM providers | OpenAI, Anthropic, Azure, OpenRouter, Ollama, Groq, Mistral, Perplexity |
| Community adoption | all GSoC assignments | All built on these foundations, zero friction |
| API stability | Zero breaking changes | 100% backward compatibility maintained throughout |
| Migration success | 43 files migrated | From string roles to type-safe enums, zero issues |
| Bug elimination | 47 try-catch → 0 | Entire class of errors eliminated via Safety utilities |
| Net code reduction | -260 lines | Less code, more functionality (pattern power) |
Team effort: the people behind the patterns
These patterns emerged from collaboration across the llm4s community:
| Contributor | Key contributions | Impact | Specific insight |
|---|---|---|---|
| Rory Graves | Lead, Pair-programmed the big refactors, handled hairy rebases, reviewed every pattern | Ensured each pattern shipped production-ready | “Patterns prevent entire classes of bugs, not just individual ones.” |
| Kannupriya Kalra | Led 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 DX | 95% faster onboarding, steady OSS presence | Developer experience is not optional. |
| Atul S. Khot | Co-authored multiple PRs, Guided smart constructors, Result ergonomics, Design reviews on Error hierarchy, Try-Either patterns and type classes design | Made error hierarchy and type classes safe by construction | “If it compiles, it should be valid.” |
| Dmitry Mamonov | Delivered tokens subsystem, Anthropic tool-calling fixes, devcontainer cleanup | Pressure-tested Safety utilities against real integrations | “This pattern repeats everywhere-so abstract it.” |
| Iyadi Dayo-Akinlaja | Hardened 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 handling | Treat build health as product surface area. |
| Shubham Vishwakarma | Built tracing on top of Safety, validated async error handling | Proved patterns work for observability workloads | First to adopt Safety utilities in production tracing. |
| Anshuman Awasthi | Ported multimodal code to structured errors + MessageRole enum | Showed patterns scale to vision/audio features | Multimodal errors need the same structure as LLM errors. |
| Elvan Konukseven | Refactored agent framework onto type-safe foundations | Demonstrated patterns for stateful, long-running agents | Agents benefit from exhaustive pattern matching. |
| Gopi Trinadh Maddikunta | Built the RAG system and surfaced the onboarding pain that birthed the template | Sparked the g8 template that saves everyone 20 minutes | Manual 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 :
| Lesson | Principle | Why it matters | Real example from llm4s | How to apply |
|---|---|---|---|---|
| 1. Compile-time safety beats runtime validation | Catch errors during development, not in production | Users discover bugs immediately in their IDE, not after deployment | String "asistant" compiled → MessageRole enum makes it a compile error | Use sealed traits, smart constructors, type classes to make invalid states unrepresentable |
| 2. Developer experience is not optional | Time spent on tooling pays back 10x | Gopi 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 bugs | Don’t just fix individual bugs, prevent categories of bugs | Safety utilities eliminated 47 potential bugs by replacing all manual try-catch with automatic error mapping | 47 try-catch blocks → 0, each was a potential bug | Identify recurring patterns, abstract them once, apply everywhere |
| 4. Migration strategies enable evolution | Breaking changes kill adoption | Bridge methods + deprecation warnings + 6-month timeline = zero user complaints during MessageRole migration | 43 files migrated, zero breaking changes | Use @deprecated, bridge methods, migration guides, respect production schedules |
| 5. Patterns beat perfect code | Good patterns scale better than perfect implementations | We reduced code (-260 net lines) while adding features because patterns enable simplification | Code got simpler despite more functionality | Document patterns, review for pattern adherence, refactor to patterns |
Application checklist for your codebase:
| Question | If yes… | If no… |
|---|---|---|
| Do you have string-based enums that could be typos? | Replace with sealed traits for compile-time safety | You’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 pattern | Good, but look for other repetitive patterns |
| Are you planning breaking API changes? | Use bridge methods + deprecation warnings + timeline | Plan migration strategy before implementing |
| Is code getting more complex as you add features? | Identify patterns, refactor to reduce duplication | Keep 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?
- Explore the codebase: github.com/llm4s/llm4s
- Try the g8 template:
sbt new llm4s/llm4s.g8 - Join the community: discord.gg/4uvTPn6qww
- Review open issues and PRs on GitHub
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:
- llm4s: https://github.com/llm4s/llm4s
- Template repo: https://github.com/llm4s/llm4s.g8
- PR #137: Error hierarchy foundation
- PRs #101/#115/#116: Giter8 template trilogy / #115 / #116
- PR #197: Smart constructors
- PR #216: MessageRole enum
- PR #260: Safety refactor
- Build & CI: #224 / #227 / #229 / #279
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 .
