Build a code review operating system: prevent 2 AM incidents in serious codebases
You know how it goes. A pull-request gets approved with “LGTM” after a 1-2 minute glance. Three weeks later, that code crashes production at 2 AM because nobody noticed it was swallowing errors in a catch-all. The on-call engineer (probably you) spends four hours debugging something a reviewer could have caught in four minutes.
This post is about building a review system that prevents that. Not a checklist - an operating system.
This post is a practical code review system that works for two audiences at once: engineers who want actionable patterns, and PMs who want to understand why review rigor actually speeds things up.
The examples come from llm4s , a Scala library for building type-safe LLM applications. But the principles apply to any serious codebase. I’ll call out when something is llm4s-specific and when it generalizes.
This is a cleaned-up, public version of the code/design/infra/ops review checklist I use regularly.
- Executive summary for stakeholders (what this guarantees, what it prevents)
- Copy/paste-ready review comments (see Common PR smells and how to comment )
- 70+ functional programming patterns with Scala 2.13 and Scala 3 examples (Reader/Writer monads, Tagless Final, Kleisli, State, Traverse, HKT, Eq, mapN, recursive typeclasses, and more)
- 10 design patterns with UML diagrams - how FP, OOP, and SOLID work together (Factory+ADT, Builder+Phantom Types, Singleton+DI, Strategy+Pattern Matching, State+ADT, Adapter+Type Classes, Decorator+HOFs, Composite+Semigroup, Observer+Callbacks, Command+ADTs)
- Checklists for error handling, type safety, architecture, security, testing (including property-based testing)
- 9 PR smell templates - copy-paste review comments with code fixes
- Mermaid diagram templates for complex reviews with semantic color codes
- Pattern maturity model (Level 1-4 progression with code examples)
- llm4s examples index - find real patterns in the codebase
- Phased adoption plan that doesn’t stall delivery
??? / ... as placeholders. When you want real, runnable code, jump to
llm4s examples index
and follow the links into the repo.Executive summary (for PMs and stakeholders)
If you’re short on time, here’s what this review system guarantees, what it prevents, and why it matters for delivery.
What this system guarantees
| Guarantee | How | Business impact |
|---|---|---|
| No silent failures | Errors are typed, explicit, and always handled | Fewer 2 AM pages, faster incident resolution |
| No accidental breaking changes | API changes require explicit versioning decisions | Predictable upgrades for consumers |
| No security footguns | Secrets are redacted, LLM output is untrusted | No leaked API keys in logs, no prompt injection |
| No resource leaks | Connections and handles are bracketed | No memory leaks in long-running services |
| Consistent patterns | Same approach to the same problem across codebase | Faster onboarding, easier maintenance |
What this system prevents
Common failure modes this catches:
- The swallowed exception -
catch { case _ => }that hides real errors until production - The config bomb - hardcoded secrets or environment reads scattered through core logic
- The breaking change surprise - API modification that breaks consumers without warning
- The concurrency bug - mutable state shared across threads without synchronization
- The unbounded cost - LLM calls without token limits or retry caps
How this affects velocity
Here’s the counterintuitive part: rigorous review speeds up delivery.
Short-term cost: PRs take 15-30 minutes longer to review thoroughly.
Long-term gain:
- 60-80% reduction in production incidents from preventable bugs
- 50% faster onboarding because code is consistent and documented
- 70% fewer “fix the fix” PRs because issues are caught before merge
- Near-zero emergency rollbacks from breaking changes
The ratio: 25:1. One hour of careful review prevents 25 hours of downstream work.
| |
What contributors need to adopt
For engineers joining a codebase with this system:
- Use Result types for errors - No exceptions in domain logic
- Follow existing patterns - If there’s a factory, use it; if there’s a builder, use it
- Test both paths - Success and failure cases get equal attention
- Keep PRs small - Under 400 lines of diff, focused on one concern
- Write the “why” - PR description explains motivation, not just mechanics
Repository map and architecture invariants
Understanding the codebase structure is essential for effective review. Here’s the llm4s layout:
| |
Boundary map (core vs edge)
The same tree, in one diagram: what counts as core (highest review bar) vs edge (allowed to do IO) vs examples.
flowchart LR
subgraph Core["Core library (highest review bar)"]
CoreSrc["modules/core/src/main/scala/..."]
Types["types/: Result[A], newtypes"]
Errors["error/: LLMError ADT"]
ToolApi["toolapi/: ToolRegistry, ToolFunction"]
Agent["agent/: orchestration (core logic)"]
end
subgraph Boundaries["Allowed boundaries (still in core)"]
Config["config/: ONLY place for env/config reads"]
Safety["core/safety/: controlled try/catch boundary"]
Resource["resource/: bracket/use/release"]
end
subgraph Edge["Edges + lower review bar"]
Samples["modules/samples/: usage examples"]
Workspace["modules/workspace/: runner/client"]
Docs["docs/: user + internal docs"]
Cross["modules/crossTest/: cross-version checks"]
end
Agent --> ToolApi
Agent --> Types
Agent --> Errors
ToolApi --> Types
Config --> Types
Config --> Errors
Safety --> Errors
Resource --> Types
Samples --> CoreSrc
Workspace --> CoreSrc
Docs --> CoreSrc
Cross --> CoreSrc
style Core fill:#e1f5ff,stroke:#0066cc,color:#000
style Boundaries fill:#fff4e1,stroke:#cc8800,color:#000
style Edge fill:#f0e1ff,stroke:#8800cc,color:#000
style CoreSrc fill:#e1f5ff,stroke:#0066cc,color:#000
style Types fill:#e1ffe1,stroke:#2d7a2d,color:#000
style Errors fill:#e1ffe1,stroke:#2d7a2d,color:#000
style ToolApi fill:#e1f5ff,stroke:#0066cc,color:#000
style Agent fill:#e1f5ff,stroke:#0066cc,color:#000
style Config fill:#fff4e1,stroke:#cc8800,color:#000
style Safety fill:#fff4e1,stroke:#cc8800,color:#000
style Resource fill:#fff4e1,stroke:#cc8800,color:#000
style Samples fill:#f0e1ff,stroke:#8800cc,color:#000
style Workspace fill:#f0e1ff,stroke:#8800cc,color:#000
style Docs fill:#f0e1ff,stroke:#8800cc,color:#000
style Cross fill:#f0e1ff,stroke:#8800cc,color:#000
Key public API surfaces
| Package | Key types | Purpose |
|---|---|---|
org.llm4s.types | Result[A], AsyncResult[A], ModelName, ApiKey, ConversationId | Core types, newtypes |
org.llm4s.llmconnect | LLMClient, LLMConnect, Conversation, Completion | LLM client interface |
org.llm4s.agent | Agent, AgentState, AgentStatus, Handoff | Agent framework |
org.llm4s.toolapi | ToolFunction, ToolRegistry, ToolBuilder, Schema | Tool calling |
org.llm4s.config | Llm4sConfig, ProviderConfig | Configuration loading |
org.llm4s.error | LLMError, RecoverableError, NonRecoverableError | Error hierarchy |
Architecture invariants
Error Model: Result[A] = Either[LLMError, A] everywhere. No exceptions in domain logic.
Configuration Boundary: Core code never reads env vars or config directly. All config reads happen in
org.llm4s.config. Enforced by Scalafix.
Purity Boundary: IO (HTTP, filesystem, time, randomness) stays at edges. Core logic is pure transformations.
Type Safety: Use newtypes (ModelName, ApiKey, ConversationId) to prevent confusion. ApiKey.toString is
always redacted.
Cross-build: Scala 2.13.16 and Scala 3.7.1. Single shared source tree with ScalaCompat for tiny shims.
Scalafix enforcements
The .scalafix.conf enforces these rules in core sources:
| Rule | Pattern banned | Allowed in |
|---|---|---|
NoConfigFactory | ConfigFactory | org.llm4s.config only |
NoSysEnv | sys.env | org.llm4s.config, samples, workspace |
NoSystemGetenv | System.getenv | org.llm4s.config, samples, workspace |
NoKeywordTry | try { | org.llm4s.core.safety, org.llm4s.agent.orchestration |
NoKeywordCatch | catch { | Same as above |
NoKeywordFinally | finally | Same as above |
Review principles and quality bar
Core values
- Correct first, clever never: LLM systems fail in creative ways. Our job is to make failures uncreative.
- Explicit over implicit: Errors are typed, effects are at boundaries, dependencies are injected.
- Boring reliability: Predictable behavior, clear contracts, code that stays readable six months later.
Pull request quality bar
A good pull request is:
- Correct: Handles success, failure, edge cases, partial data
- Safe: No secrets leaked, no tool abuse, no prompt injection footguns
- Maintainable: Clear APIs, consistent patterns, minimal cleverness
- Compatible: No accidental breaking changes to published artifacts
- Measurable: Performance claims backed by benchmarks
What to review rigorously
| Area | Review bar | Why |
|---|---|---|
modules/core public APIs | Highest - treat as product | Consumers depend on stability |
modules/core internals | High - must follow patterns | Foundation for everything else |
| Error handling changes | High - affects all consumers | Errors propagate everywhere |
| Config changes | High - affects deployment | Misconfig breaks production |
| Security-relevant code | Highest - tool calling, secrets | Breaches are existential |
modules/samples | Medium - working examples | Users copy these verbatim |
modules/workspace | Medium - containerized runner | Lower blast radius |
Pull request review workflow (CI playbook)
PR review, in one diagram: the “operating system” is just a tight loop of gating -> classify -> fix -> re-check -> merge.
flowchart TD
Start["Author opens PR"] --> Scope["Scope + risk summary<br/>(modules, blast radius)"]
Scope --> CI["CI gates: +compile, +test, scalafmtCheckAll"]
CI -->|Fail| FixCI["Fix failures<br/>(don’t review broken builds)"]
FixCI --> CI
CI -->|Pass| Bot["CI bot review:<br/>checklists + invariants"]
Bot --> Human["Human review:<br/>design + readability + risks"]
Human --> Classify{"Classify finding"}
Classify -->|Blocker| Block["Blocker: must fix before merge"]
Classify -->|Must - fix| Must["Must-fix: fix before merge"]
Classify -->|Should - fix| Should["Should-fix: fix or justify"]
Classify -->|Nit| Nit["Nit: optional, keep sparse"]
Block --> Fix["Author fixes<br/>+ adds tests/docs"]
Must --> Fix
Should --> Fix
Nit --> Fix
Fix --> ReCI["Re-run CI + re-review"]
ReCI --> CI
Bot --> Merge{"All blockers + must-fix resolved?"}
Human --> Merge
Merge -->|No| Fix
Merge -->|Yes| Ship["Merge + release notes<br/>(README/CHANGELOG if needed)"]
style Start fill:#e1f5ff,stroke:#0066cc,color:#000
style Scope fill:#fff4e1,stroke:#cc8800,color:#000
style CI fill:#e1f5ff,stroke:#0066cc,color:#000
style FixCI fill:#ffe1e1,stroke:#cc0000,color:#000
style Bot fill:#f0e1ff,stroke:#8800cc,color:#000
style Human fill:#f0e1ff,stroke:#8800cc,color:#000
style Classify fill:#fff4e1,stroke:#cc8800,color:#000
style Block fill:#ffe1e1,stroke:#cc0000,color:#000
style Must fill:#ffe1e1,stroke:#cc0000,color:#000
style Should fill:#fff4e1,stroke:#cc8800,color:#000
style Nit fill:#e1f5ff,stroke:#0066cc,color:#000
style Fix fill:#f0e1ff,stroke:#8800cc,color:#000
style ReCI fill:#e1f5ff,stroke:#0066cc,color:#000
style Merge fill:#fff4e1,stroke:#cc8800,color:#000
style Ship fill:#e1ffe1,stroke:#2d7a2d,color:#000
CI review bot contract
When analyzing a PR, the CI bot MUST produce:
1. PR summary (3-5 sentences)
- What the PR changes
- Which modules are affected
- Whether it’s a feature, fix, refactor, or docs change
2. Risk assessment
| Risk category | Level (None/Low/Medium/High/Blocker) | Notes |
|---|---|---|
| API breaking change | New params, removed methods, changed signatures | |
| Binary compatibility | MiMa violations, ABI changes | |
| Source compatibility | Deprecations, renames | |
| Scala 2/3 cross-build | Version-specific code, ScalaCompat changes | |
| Performance | Hot path changes, allocation patterns | |
| Concurrency | Thread safety, blocking, resource leaks | |
| Security | Secrets, tool validation, LLM output handling |
3. Must-fix findings (Blocker severity)
- Each finding cites:
file:lineStart-lineEnd - Each finding links to guideline section by name
4. Should-fix findings (Should-fix severity)
- Same format as must-fix
5. Optional questions
- Clarifying questions for PR author
- Suggestions that improve but don’t block
Severity rubric
| Severity | Definition | Action |
|---|---|---|
| Blocker | Breaks build, leaks secrets, causes data loss, violates core invariant | MUST fix before merge |
| Must-fix | Correctness issue, missing error handling, security concern, breaks pattern | MUST fix before merge |
| Should-fix | Non-idiomatic code, missing tests, unclear naming, minor inefficiency | Should fix, can defer with justification |
| Nit | Style preference, minor readability | Use sparingly, only if pattern is established |
Severity triage, in one diagram: if you and I classify differently, the system breaks.
flowchart TD
Start["Found an issue in PR"] --> Q1{"Does it break a gate<br/>or violate a core invariant?"}
Q1 -->|Yes| Blocker["Blocker<br/>Fix before merge"]
Q1 -->|No| Q2{"Is it correctness, security,<br/>or missing error handling?"}
Q2 -->|Yes| Must["Must-fix<br/>Fix before merge"]
Q2 -->|No| Q3{"Is it readability, naming,<br/>tests, or minor inefficiency?"}
Q3 -->|Yes| Should["Should-fix<br/>Fix or justify deferral"]
Q3 -->|No| Nit["Nit<br/>Optional suggestion"]
style Start fill:#e1f5ff,stroke:#0066cc,color:#000
style Q1 fill:#fff4e1,stroke:#cc8800,color:#000
style Q2 fill:#fff4e1,stroke:#cc8800,color:#000
style Q3 fill:#fff4e1,stroke:#cc8800,color:#000
style Blocker fill:#ffe1e1,stroke:#cc0000,color:#000
style Must fill:#ffe1e1,stroke:#cc0000,color:#000
style Should fill:#fff4e1,stroke:#cc8800,color:#000
style Nit fill:#e1f5ff,stroke:#0066cc,color:#000
Comment format
Every review comment MUST follow this format:
| |
Code review checklist (llm4s specific)
Error handling
- Uses
Result[A]for operations that can fail - No
throwin domain logic (only at hard integration boundaries) - No
try/catchin core (useSafety.safelyorTry(...).toResult) - Preserves error causes when wrapping (
cause = Some(e)) - Pattern matches on error types use type patterns, not
case _
llm4s pattern (modules/core/src/main/scala/org/llm4s/core/safety/Safety.scala:22-28):
| |
What’s happening here?
- This snippet shows the minimal shape for: Error handling.
- In review, confirm the types make the happy path obvious and the error path explicit.
Errors are values. They have types. They compose. They don’t surprise you at 2 AM.
When this doesn’t apply: At the absolute edge of your system where you interface with truly external code that throws, you need to catch. But do it once, at the boundary, and convert to your error type.
Configuration
- No
sys.env,System.getenv, orConfigFactoryin core main sources - Config reads happen only in
org.llm4s.config - Core code receives typed config via constructor injection
llm4s pattern (modules/core/src/main/scala/org/llm4s/config/Llm4sConfig.scala):
| |
What’s happening here?
- This snippet shows the minimal shape for: Configuration.
- In review, confirm the types make the happy path obvious and the error path explicit.
Type safety
- Uses newtypes where confusion is possible (
ModelName,ApiKey,ConversationId) - Secrets never logged (
ApiKey.toStringreturns"ApiKey(***)") - ADTs used for state machines (sealed trait + case objects)
- No sentinel values (
-1,"",null) - useOptionor ADT
llm4s pattern (modules/core/src/main/scala/org/llm4s/types/package.scala:88-92):
| |
What’s happening here?
- This snippet shows the minimal shape for: Type safety.
- In review, confirm the types make the happy path obvious and the error path explicit.
ApiKey.toString printed the actual key, your logs would leak secrets. Every security breach in our industry can be
traced to logs, error messages, or stack traces containing credentials.Pattern matching
- Exhaustive on sealed traits (no unguarded
case _) - Uses pattern matching for ADT dispatch, not
isInstanceOf - Guards are explicit when partial matching is intentional
- Wildcard
case _has logging or explicit justification
llm4s pattern (modules/core/src/main/scala/org/llm4s/llmconnect/provider/LLMProvider.scala:15-24):
| |
What’s happening here?
- This snippet shows the minimal shape for: Pattern matching.
- In review, confirm the types make the happy path obvious and the error path explicit.
Immutability
- Default to
val, immutable collections, case classes -
varonly in tight, locally-contained loops with measured perf impact - No mutable state shared across threads without explicit synchronization
- State changes return new instances (e.g.,
AgentState.copy)
for-Comprehensions
- Used for sequencing dependent operations (not looping)
- Each step has clear semantics
- Not deeply nested (break into named functions if > 5 steps)
llm4s pattern (modules/core/src/main/scala/org/llm4s/agent/Agent.scala:929-949):
| |
What’s happening here?
- This snippet shows the minimal shape for: for-Comprehensions.
- In review, confirm the types make the happy path obvious and the error path explicit.
API and design review checklist
Public API changes
- Backward compatible or explicitly breaking (with migration path)
- Default parameters don’t change existing behavior
- New required parameters fail at compile time, not runtime
- Deprecations include
@deprecatedwith since version and migration hint - Return types are explicit (no inferred public return types)
Type design
- ADTs for closed sets (sealed trait + case classes/objects)
- Plain traits for extension points (open for implementation)
- Smart constructors for validated types (private constructor + factory returning
Result) - Companion objects for default instances and factory methods
llm4s pattern - Smart constructor (modules/core/src/main/scala/org/llm4s/types/package.scala:115-121):
| |
What’s happening here?
- This snippet shows the minimal shape for: Type design.
- In review, confirm the types make the happy path obvious and the error path explicit.
Trait design
- Small, focused traits (Interface Segregation)
- No god traits with 10+ methods
- Composition over inheritance
- Self-types avoided unless strictly necessary
llm4s pattern (modules/core/src/main/scala/org/llm4s/llmconnect/LLMClient.scala):
| |
What’s happening here?
- This snippet shows the minimal shape for: Trait design.
- In review, confirm the types make the happy path obvious and the error path explicit.
Six methods, all essential, all related to the single responsibility of “interact with an LLM”.
Architecture review checklist
Dependency direction
- Core depends on abstractions, not concrete implementations
- No circular dependencies between packages
- Providers implement core interfaces, not the reverse
- Config injected at edges, not pulled from environment
graph TD
subgraph "High-Level (Core)"
Agent["Agent"]
LLMClient["LLMClient (trait)"]
ToolRegistry["ToolRegistry"]
end
subgraph "Low-Level (Implementations)"
OpenAI["OpenAIClient"]
Anthropic["AnthropicClient"]
Gemini["GeminiClient"]
end
Agent --> LLMClient
OpenAI --> LLMClient
Anthropic --> LLMClient
Gemini --> LLMClient
style Agent fill:#e1f5ff,stroke:#0066cc,color:#000
style LLMClient fill:#fff4e1,stroke:#cc8800,color:#000
style ToolRegistry fill:#e1f5ff,stroke:#0066cc,color:#000
style OpenAI fill:#e1ffe1,stroke:#2d7a2d,color:#000
style Anthropic fill:#e1ffe1,stroke:#2d7a2d,color:#000
style Gemini fill:#e1ffe1,stroke:#2d7a2d,color:#000
Effect boundaries
- IO (HTTP, file, time, random) at application edges
- Pure transformations in core
- No hidden side effects in
map/flatMapchains - Logging is the only acceptable side effect in core (use sparingly)
Resource management
- Resources acquired with bracket/use/release pattern
-
ManagedResourceorResource[F, A]for lifecycle management - No resource leaks (connections, file handles, threads)
- Cleanup happens even on error paths
llm4s pattern (modules/core/src/main/scala/org/llm4s/resource/ManagedResource.scala:26-30):
| |
What’s happening here?
- This snippet shows the minimal shape for: Resource management.
- In review, confirm the types make the happy path obvious and the error path explicit.
Cross-version compatibility
- Code compiles on both Scala 2.13 and 3.x
- No Scala 3-only features in shared source (opaque types, inline, derives)
- Version-specific code in
src/main/scala-2.13orsrc/main/scala-3 -
ScalaCompatused only for tiny shims (not dumping ground) -
modules/crossTesttests pass on both versions
Functional programming patterns in llm4s
Preferred patterns
| Pattern | When to use | llm4s example |
|---|---|---|
Result[A] / Either | Single operation that can fail | parseJson(raw): Result[ujson.Value] |
Option | Value may be absent | tools.find(_.name == name): Option[ToolFunction] |
ValidatedNec | Accumulate multiple errors | Multi-field form validation |
| for-comprehension | Sequential dependent operations | Agent run pipeline |
map/flatMap | Transform success values | result.map(_.field) |
fold / match | Handle both success and failure | Error recovery |
| ADTs | Closed state machines | LLMProvider, AgentStatus |
| Smart constructors | Validated type creation | ConversationId.create(value) |
Avoided patterns in core
| Pattern | Why avoided | Alternative |
|---|---|---|
try/catch | Hides error types, loses exhaustiveness | Safety.safely, Try.toResult |
null | NPE risk, unclear semantics | Option |
| Implicit conversions | Surprise behavior at distance | Explicit type classes |
| Structural types | Reflection overhead, unclear contracts | Explicit traits |
| Cake pattern | Complex wiring, hard to test | Constructor injection |
| Free monads | Excessive complexity for our use case | Tagless final if needed |
Monad transformer usage
Use EitherT or OptionT only when nesting becomes unreadable:
| |
What’s happening here?
- The first version nests
FutureandResult, so you end up manually unpackingRight/Leftat multiple levels. EitherTlets you write one for-comprehension where failures short-circuit automatically;.valuegives you backFuture[Result[User]].
Advanced FP patterns you’ll actually use
These patterns show up in serious codebases. They’re not theoretical - they solve real problems when basic patterns (Option, Result, for-comprehensions) aren’t enough.
Reader monad: dependency injection without the ceremony
The problem: You have multiple functions that need the same configuration, and you’re tired of threading it through every signature.
| |
Good: Reader monad:
| |
What’s happening here?
Reader[Config, A]is a function wrapper that delays config injection.- You compose operations without mentioning config in signatures.
- Test by running with
testConfig, prod withprodConfig.
llm4s application:
| |
When to use:
- Multiple functions need same dependencies (DB, API clients, config)
- You want to test with different configs without changing signatures
- Overkill for single function or two-function chains
sequenceDiagram
participant Client
participant Program as Reader[Config, A]
participant FetchUser as fetchUser
participant EnrichUser as enrichUser
participant Config
Client->>Program: program.run(prodConfig)
activate Program
Program->>FetchUser: apply(config)
activate FetchUser
FetchUser->>Config: config.dbUrl
Config-->>FetchUser: dbUrl
FetchUser-->>Program: Result[User]
deactivate FetchUser
Program->>EnrichUser: apply(config)
activate EnrichUser
EnrichUser->>Config: config.apiKey
Config-->>EnrichUser: apiKey
EnrichUser-->>Program: Result[EnrichedUser]
deactivate EnrichUser
Program-->>Client: Result[EnrichedUser]
deactivate Program
note over Program: Config injected once<br/>Threaded through all steps
note over FetchUser,EnrichUser: Functions never mention<br/>config in signatures
Writer monad: logging without side effects
The problem: You want to accumulate logs alongside computations, but println is a side effect that doesn’t compose.
Good: Writer monad
| |
What’s happening here?
Writer[Vector[String], A]carries both a value (A) and accumulated logs (Vector[String]).- Each operation adds its log entry; composition merges them.
- Extract final logs with
.run- no side effects during composition.
When to use:
- You need structured audit trails for compliance
- Debugging complex pipelines (trace intermediate steps)
- Don’t use for real-time logging (buffering overhead) - use regular logging for that
flowchart TD
Start[Start: Empty logs] --> ValidateEmail[validateEmail]
ValidateEmail --> EmailCheck{Email valid?}
EmailCheck -->|Yes| EmailLog["Log: Valid email<br/>Value: Right(Email)"]
EmailCheck -->|No| EmailFail["Log: Invalid email<br/>Value: Left(Error)"]
EmailLog --> ValidateAge[validateAge]
ValidateAge --> AgeCheck{Age >= 0?}
AgeCheck -->|Yes| AgeLog["Logs: [email log, Valid age]<br/>Value: Right(30)"]
AgeCheck -->|No| AgeFail["Logs: [email log, Negative age]<br/>Value: Left(Error)"]
AgeLog --> Result["Final: (Vector of logs, result)"]
EmailFail --> ShortCircuit[Short-circuit with logs]
AgeFail --> ShortCircuit
style EmailLog fill:#e1ffe1,stroke:#2d7a2d,color:#000
style AgeLog fill:#e1ffe1,stroke:#2d7a2d,color:#000
style EmailFail fill:#ffe1e1,stroke:#cc0000,color:#000
style AgeFail fill:#ffe1e1,stroke:#cc0000,color:#000
style Result fill:#e1f5ff,stroke:#0066cc,color:#000
Kleisli: composing functions that return monads
The problem: You have functions A => F[B] and B => F[C], and you want to compose them like A => F[C].
Good: Kleisli
| |
What’s happening here?
Kleisli[IO, Config, A]isConfig => IO[A]with composition built-in.- It’s Reader + IO combined - dependency injection + async effects.
andThenchains Kleisli arrows:(A => F[B]) andThen (B => F[C]) = (A => F[C]).
llm4s application:
| |
When to use:
- Functions return monads (IO, Result, Future) and need shared dependencies
- Building composable pipelines (ETL, agent orchestration)
- Overkill if you just need Reader (no monad) or just IO (no dependencies)
flowchart LR
subgraph Input["Input"]
UserId["userId: String"]
end
subgraph K1["fetchUser<br/>Kleisli[IO, Config, User]"]
K1_Config[Read Config] --> K1_IO[IO effect]
K1_IO --> K1_User[User]
end
subgraph K2["fetchPermissions<br/>Kleisli[IO, Config, Permissions]"]
K2_Config[Read Config] --> K2_IO[IO effect]
K2_IO --> K2_Perms[Permissions]
end
subgraph Composed["Combined Pipeline<br/>Kleisli[IO, Config, (User, Perms)]"]
Comp_Config[Config shared] --> Comp_Result["(User, Permissions)"]
end
UserId --> K1
K1_User --> K2
K1 -.andThen.-> K2
K2 --> Composed
Config[Config] -.injected once.-> Composed
style K1 fill:#e1f5ff,stroke:#0066cc,color:#000
style K2 fill:#e1f5ff,stroke:#0066cc,color:#000
style Composed fill:#e1ffe1,stroke:#2d7a2d,color:#000
style Config fill:#fff4e1,stroke:#cc8800,color:#000
State monad: pure stateful computations
The problem: You need to thread state through a sequence of operations without mutable vars.
Good: State monad
| |
What’s happening here?
State[S, A]is a functionS => (S, A)- takes old state, returns new state + value.- Composition threads state automatically - no manual passing.
- Extract final state and value with
.run(initialState).value.
llm4s equivalent:
| |
When to use:
- Complex stateful logic (game loops, parsers, interpreters)
- You want referential transparency (pure functions, testable)
- Simple state - just use case class immutable updates
- Concurrent state - use Ref[F, A] instead
stateDiagram-v2
[*] --> S0: Start
S0: GameState with score 0, lives 3, level 1
S0 --> S1: increaseScore(100)
S1: GameState with score 100, lives 3, level 1
note right of S1: Pure function maps S to new S and value A
S1 --> S2: increaseScore(50)
S2: GameState with score 150, lives 3, level 1
S2 --> S3: loseLife
S3: GameState with score 150, lives 2, level 1
note right of S3: Returns new state and livesLeft equals 2
S3 --> [*]: game.run(initialState)
note left of S3: State threaded automatically without manual passing
Traverse: sequence effects across collections
The problem: You have List[F[A]] and need F[List[A]] - fail-fast on first error, or collect all results.
Bad: manual recursion
| |
Good: Traverse
| |
What’s happening here?
traverse(f: A => F[B]): F[List[B]]sequences effects and collects results.- Fail-fast: first
Leftstops execution and returns that error. parTraverseruns effects in parallel (requiresParallelinstance).
llm4s application:
| |
When to use:
- Apply effectful function to collection and collect results
- Fail-fast error handling (first error stops everything)
- Parallel execution with
parTraverse - Don’t need sequencing - use
map - Need to collect all errors - use
Validated+traverse
flowchart TD
Input["List[String]<br/>['1', '2', '3']"] --> Traverse[".traverse(fetchUser)"]
Traverse --> Fetch1["fetchUser('1')<br/>Result[User]"]
Traverse --> Fetch2["fetchUser('2')<br/>Result[User]"]
Traverse --> Fetch3["fetchUser('3')<br/>Result[User]"]
Fetch1 --> Check1{Success?}
Check1 -->|Right| Acc1["Accumulate User1"]
Check1 -->|Left| Fail[Short-circuit with error]
Fetch2 --> Check2{Success?}
Check2 -->|Right| Acc2["Accumulate User2"]
Check2 -->|Left| Fail
Fetch3 --> Check3{Success?}
Check3 -->|Right| Acc3["Accumulate User3"]
Check3 -->|Left| Fail
Acc1 --> Acc2
Acc2 --> Acc3
Acc3 --> Result["Result[List[User]]<br/>Right([User1, User2, User3])"]
Fail --> ErrorResult["Result[List[User]]<br/>Left(error)"]
style Fetch1 fill:#e1f5ff,stroke:#0066cc,color:#000
style Fetch2 fill:#e1f5ff,stroke:#0066cc,color:#000
style Fetch3 fill:#e1f5ff,stroke:#0066cc,color:#000
style Result fill:#e1ffe1,stroke:#2d7a2d,color:#000
style ErrorResult fill:#ffe1e1,stroke:#cc0000,color:#000
style Fail fill:#ffe1e1,stroke:#cc0000,color:#000
Higher-Kinded Types (HKT): abstract over type constructors
The problem: You want generic functions that work with any container (List, Option, Result, IO) without duplicating code.
Good: HKT
| |
What’s happening here?
F[_]is a type constructor - it takes a type and returns a type (List[], Option[]).Functor[F[_]]abstracts over the container shape.- One implementation works for List, Option, Result, IO, etc.
llm4s application:
| |
When to use:
- Writing libraries that work with multiple effect types
- Tagless Final architecture (abstract over F[_])
- Reusing algorithms across containers (Functor, Monad, Traverse)
- Application code - usually just pick one effect type (IO or Result)
- Beginner teams - start with concrete types, refactor to HKT later
classDiagram
class Functor~F[_]~ {
<<trait>>
+map(fa: F[A])(f: A => B): F[B]
}
class FunctorOption {
+map(fa: Option[A])(f: A => B): Option[B]
}
class FunctorList {
+map(fa: List[A])(f: A => B): List[B]
}
class FunctorResult {
+map(fa: Result[A])(f: A => B): Result[B]
}
class IncrementFunction {
+increment~F[_]: Functor~(F[Int]): F[Int]
}
Functor <|.. FunctorOption : implements
Functor <|.. FunctorList : implements
Functor <|.. FunctorResult : implements
IncrementFunction ..> Functor : uses F[_]
note for Functor "F[_] is a type constructor<br/>Takes type, returns type"
note for IncrementFunction "One function works for<br/>Option, List, Result, IO..."
style Functor fill:#e1f5ff,stroke:#0066cc,color:#000
style FunctorOption fill:#e1ffe1,stroke:#2d7a2d,color:#000
style FunctorList fill:#e1ffe1,stroke:#2d7a2d,color:#000
style FunctorResult fill:#e1ffe1,stroke:#2d7a2d,color:#000
style IncrementFunction fill:#fff4e1,stroke:#cc8800,color:#000
Scala 3 type-level patterns: compile-time guarantees
These are Scala 3-specific features that catch bugs at compile time with zero runtime overhead. If you’re still on Scala 2, see the cross-compilation notes in scala-2-advanced patterns .
Opaque types: zero-cost domain modeling
The problem: You want type-safe wrappers (UserId vs String) without the runtime cost of case class wrappers.
Bad: case class wrapper (allocates objects):
| |
Good: opaque type:
| |
What’s happening here?
opaque typecreates a new type that’s only distinct at compile time.- At runtime, it’s literally the underlying type (String) - zero allocation.
- Outside the defining scope (
object Domain), you can’t mix up UserId and Email. - Inside the scope, they’re both String (for implementation convenience).
llm4s application:
| |
When to use:
- Domain primitives (UserId, OrderId, Money, Email)
- Performance-critical code (hot paths, tight loops)
- Preventing primitive obsession without overhead
- Scala 2.13 - use AnyVal value classes instead
flowchart LR
subgraph CompileTime["Compile Time"]
direction TB
UserId["UserId"]
Email["Email"]
String1["String"]
UserId -.distinct from.-> Email
UserId -.opaque to.-> String1
Email -.opaque to.-> String1
style UserId fill:#e1f5ff,stroke:#0066cc,color:#000
style Email fill:#e1ffe1,stroke:#2d7a2d,color:#000
end
subgraph Runtime["Runtime"]
direction TB
String2["String"]
String3["String (UserId erased)"]
String4["String (Email erased)"]
String3 --> String2
String4 --> String2
style String2 fill:#fff4e1,stroke:#cc8800,color:#000
style String3 fill:#fff4e1,stroke:#cc8800,color:#000
style String4 fill:#fff4e1,stroke:#cc8800,color:#000
end
CompileTime ==>|Erasure| Runtime
note1["Inside Domain scope:<br/>UserId = String<br/>Email = String"]
note2["Outside Domain scope:<br/>UserId ≠ Email<br/>(Compile error)"]
note3["Zero allocation<br/>Zero overhead"]
style note1 fill:#f0e1ff,stroke:#8800cc,color:#000
style note2 fill:#f0e1ff,stroke:#8800cc,color:#000
style note3 fill:#e1ffe1,stroke:#2d7a2d,color:#000
Union types: type-safe APIs without inheritance
The problem: You want to accept multiple unrelated types without creating artificial inheritance hierarchies.
Bad: sealed trait hierarchy (forces inheritance):
| |
Good: union type:
| |
What’s happening here?
String | Int | Booleanmeans “one of these types” - no inheritance required.- Pattern matching is exhaustive - compiler warns if you miss a case.
- Zero runtime overhead - no wrapper classes.
Error handling with union types:
| |
llm4s application:
| |
When to use:
- Accepting multiple primitive types without wrapping
- Error modeling (union of error types)
- API responses that can be different shapes
- Complex domain modeling - use ADTs (sealed trait + case classes)
flowchart TD
Input["value: String | Int | Boolean"] --> Match{Pattern Match}
Match -->|case s: String| String["String branch<br/>s'String: $s'"]
Match -->|case i: Int| Int["Int branch<br/>s'Int: $i'"]
Match -->|case b: Boolean| Boolean["Boolean branch<br/>s'Boolean: $b'"]
String --> Output[Output: String]
Int --> Output
Boolean --> Output
Invalid["Double"] -.->|Compile Error| Match
subgraph UnionType["Union Type = String | Int | Boolean"]
T1[String]
T2[Int]
T3[Boolean]
end
note1["No inheritance hierarchy<br/>No wrapper classes<br/>Zero runtime overhead"]
note2["Exhaustiveness checking:<br/>Compiler ensures all cases handled"]
style String fill:#e1f5ff,stroke:#0066cc,color:#000
style Int fill:#e1ffe1,stroke:#2d7a2d,color:#000
style Boolean fill:#fff4e1,stroke:#cc8800,color:#000
style Invalid fill:#ffe1e1,stroke:#cc0000,color:#000
style UnionType fill:#f0e1ff,stroke:#8800cc,color:#000
style note1 fill:#e1ffe1,stroke:#2d7a2d,color:#000
style note2 fill:#e1f5ff,stroke:#0066cc,color:#000
Intersection types: compound capabilities
The problem: You need an object that implements multiple unrelated traits, but you don’t want to create a new trait just for the combination.
Good: intersection type
| |
What’s happening here?
Serializable & Loggablemeans “both traits required” - intersection of capabilities.- Compiler ensures the argument has all required methods.
- No need to create
trait SerializableAndLoggable extends Serializable with Loggable.
When to use:
- Function parameters that need multiple capabilities
- Avoiding trait proliferation (DRY)
- Type-safe mixin composition
- Domain modeling - use regular trait extension (
with)
flowchart TB
subgraph Capabilities["Available Capabilities"]
Serializable["Serializable<br/>serialize()"]
Loggable["Loggable<br/>log()"]
end
Serializable --> Intersection
Loggable --> Intersection
Intersection["Serializable & Loggable<br/>(Both required)"]
Intersection --> ProcessAndLog["processAndLog(obj)"]
ProcessAndLog --> CallLog["obj.log()"]
ProcessAndLog --> CallSerialize["obj.serialize"]
User["User<br/>extends Serializable with Loggable"] -->|Accepted| Intersection
Order["Order<br/>extends Serializable only"] -.->|Compile Error| Intersection
note1["Intersection Type:<br/>All capabilities must be present"]
note2["No need to create<br/>SerializableAndLoggable trait"]
style Serializable fill:#e1f5ff,stroke:#0066cc,color:#000
style Loggable fill:#e1ffe1,stroke:#2d7a2d,color:#000
style Intersection fill:#fff4e1,stroke:#cc8800,color:#000
style User fill:#e1ffe1,stroke:#2d7a2d,color:#000
style Order fill:#ffe1e1,stroke:#cc0000,color:#000
style note1 fill:#f0e1ff,stroke:#8800cc,color:#000
style note2 fill:#e1f5ff,stroke:#0066cc,color:#000
Literal types: compile-time constants
The problem: You want to restrict a parameter to specific values, not just a type (e.g., only 3, not any Int).
Good: literal type:
| |
What’s happening here?
- Literal types (
3,"pending") are exact values, not type ranges. - Compiler rejects anything that doesn’t match the literal.
- Combine with union types for enums without boilerplate.
Real-world example:
| |
llm4s application:
| |
When to use:
- Enums with small, fixed sets of values
- Configuration constants (environments, feature flags)
- Preventing typos in string constants
- Large enums - use
enumkeyword instead - Values known only at runtime - use regular types
Compile-time validation: catch drift before production
These patterns use macros to validate schemas, enforce state machines, and eliminate entire classes of runtime errors.
Schema conformance with macros
The problem: Producer schema changes break consumers at runtime. You only discover schema drift when data pipelines fail in production.
Good: compile-time schema validation:
| |
What’s happening here?
- Macro compares case class structures at compile time.
SchemaConforms[Producer, Contract, Policy]proves compatibility.- If schemas don’t match policy, code won’t compile.
Prevented at compile time:
- Schema drift in Spark/Kafka pipelines
- Breaking changes between microservices
- Runtime schema mismatch errors
When to use:
- Data pipelines with producer/consumer contracts
- Spark schema evolution (prevent AnalysisException)
- gRPC/Protobuf compatibility checks
- Overkill for internal-only schemas with no external consumers
Phantom type state machines
The problem: You can call builder methods in the wrong order, creating invalid pipelines (e.g., .build() before .addSource()).
Good: phantom type builder:
| |
What’s happening here?
S <: BuilderStateis a phantom type - exists only at compile time, zero runtime overhead.- Each method returns a builder with a different state type.
- Type constraints (
S <:< WithSource) prevent calling methods in wrong order.
Impossible states made unrepresentable:
- Can’t build pipeline without source
- Can’t add sink before transform
- Can’t skip required configuration steps
When to use:
- Complex builders with required sequences (Spark jobs, HTTP clients, DSLs)
- Preventing misconfiguration at compile time
- Simple builders (2-3 optional methods) - phantom types are overkill
stateDiagram-v2
[*] --> Empty: new PipelineBuilder()
Empty --> WithSource: addSource(csvSource)
note right of Empty: State is Empty - Can only call addSource
WithSource --> WithTransform: transformAs(fn)
note right of WithSource: State is WithSource - Can call transformAs with constraint ev
WithTransform --> Complete: addSink(sink)
note right of WithTransform: State is WithTransform - Can call addSink with constraint ev
Complete --> [*]: build()
note right of Complete: State is Complete - Can call build with equality constraint
Empty --> CompileError1: build()
Empty --> CompileError2: addSink()
WithSource --> CompileError3: build()
WithSource --> CompileError4: addSink()
state CompileError1 {
[*] --> Error: Type constraint failed
}
state CompileError2 {
[*] --> Error: Type constraint failed
}
state CompileError3 {
[*] --> Error: Type constraint failed
}
state CompileError4 {
[*] --> Error: Type constraint failed
}
note left of CompileError1: Phantom types prevent<br/>invalid state transitions<br/>at compile time
Inline for zero-cost abstractions
The problem: Helper functions add overhead (call stack, allocation). You want abstraction without performance cost.
Good: inline:
| |
What’s happening here?
inlinetells compiler to replace function call with function body at every call site.inline ifevaluated at compile time - dead branches eliminated.- Zero runtime overhead - inlined code is exactly as if you copy-pasted it.
llm4s application:
| |
When to use:
- Performance-critical hot paths
- Compile-time feature flags
- Eliminating abstraction overhead
- Large functions - increases bytecode size
- Functions used in hundreds of places - code bloat
Design patterns: how FP, OOP, and SOLID work together
Scala supports both OOP and FP paradigms. This section shows how to blend design patterns with functional techniques, when to use which approach, and how they relate to SOLID principles.
The pattern spectrum
flowchart LR
subgraph PureFP["Pure FP"]
FP1["ADTs, Monads"]
FP2["Type Classes"]
FP3["Immutable Data"]
FP4["SOLID: SRP, ISP"]
end
subgraph Hybrid["Hybrid"]
H1["Smart Constructors"]
H2["Builder + Phantom Types"]
H3["Resource Management"]
H4["SOLID: All 5"]
end
subgraph PureOOP["Pure OOP"]
OOP1["Mutable State"]
OOP2["Inheritance Trees"]
OOP3["Imperative Logic"]
OOP4["SOLID: LSP, DIP"]
end
PureFP <-->|" Data transforms,<br/>validation, parsing "| Hybrid
Hybrid <-->|" External APIs,<br/>stateful components "| PureOOP
style PureFP fill:#e1ffe1,stroke:#2d7a2d,color:#000
style Hybrid fill:#fff4e1,stroke:#cc8800,color:#000
style PureOOP fill:#e1f5ff,stroke:#0066cc,color:#000
style FP1 fill:#c8e6c9,stroke:#2d7a2d,color:#000
style FP2 fill:#c8e6c9,stroke:#2d7a2d,color:#000
style FP3 fill:#c8e6c9,stroke:#2d7a2d,color:#000
style FP4 fill:#c8e6c9,stroke:#2d7a2d,color:#000
style H1 fill:#ffe0b2,stroke:#cc8800,color:#000
style H2 fill:#ffe0b2,stroke:#cc8800,color:#000
style H3 fill:#ffe0b2,stroke:#cc8800,color:#000
style H4 fill:#ffe0b2,stroke:#cc8800,color:#000
style OOP1 fill:#bbdefb,stroke:#0066cc,color:#000
style OOP2 fill:#bbdefb,stroke:#0066cc,color:#000
style OOP3 fill:#bbdefb,stroke:#0066cc,color:#000
style OOP4 fill:#bbdefb,stroke:#0066cc,color:#000
Guidelines:
- Pure FP (Left): Data transformations, validation, business logic, parsing
- Hybrid (Center): Object construction, resource management, external integrations
- Pure OOP (Right): Rare in llm4s - only when external APIs demand it
Pattern synergies: combinations that strengthen each other
| Pattern combination | SOLID | Why they work together | Benefit |
|---|---|---|---|
| Factory + smart constructors + Result + DIP | DIP | Factory returns trait (abstraction), smart constructors validate, Result for errors | Type-safe creation, testable |
| Builder + phantom types + SRP | SRP | Each builder method has one job; phantom types track state | Impossible states unrepresentable |
| Strategy + ADTs + pattern matching + OCP | OCP | Strategy as sealed trait; add new strategy = add new case | Exhaustive checks, open for extension |
| Adapter + type classes + ISP | ISP | Type classes adapt types without inheritance; focused interfaces | Non-intrusive adaptation |
| Decorator + HOFs + Kleisli + SRP | SRP | Each decorator does one thing; HOFs compose | Composable, no class explosion |
| State + state monad + for-comprehensions + SRP | SRP | Each state transformation is pure function with single job | Pure, testable, no mutation |
Pattern anti-synergies: combinations to avoid
| Pattern combination | SOLID violation | Why they conflict | What to do instead |
|---|---|---|---|
| Mutable state + pure functions | SRP | Breaks referential transparency | Use State monad or Ref[F, A] |
| Deep inheritance + ADTs | LSP, SRP | ADTs can’t extend multiple sealed traits | Use composition |
| Exceptions + Result | SRP | Mixing throw with Result breaks monad laws | Convert Try to Result at boundaries |
| Singleton + dependency injection | DIP | Global state makes testing hard | Pass dependencies explicitly |
| God object + many responsibilities | SRP, ISP | One class does everything | Split into focused classes |
Pattern review decision tree
flowchart TD
Start([Reviewing Code]) --> CheckSOLID{Check SOLID<br/>violations first}
CheckSOLID -->|God Object| SplitSRP[Violates SRP<br/>Split responsibilities]
CheckSOLID -->|Concrete Deps| ApplyDIP[Violates DIP<br/>Depend on abstractions]
CheckSOLID -->|No Violations| Question1{Is it data<br/>transformation?}
Question1 -->|Yes| FP[Use Pure FP:<br/>ADTs, Pattern Matching,<br/>Monads]
Question1 -->|No| Question2{Is it object<br/>construction?}
Question2 -->|Yes| Question3{Complex validation<br/>or many params?}
Question3 -->|Yes| Builder[Use Builder +<br/>Phantom Types]
Question3 -->|No| Factory[Use Factory +<br/>Smart Constructors]
Question2 -->|No| Question4{Is it external<br/>integration?}
Question4 -->|Yes| TypeClass[Use Type Classes<br/>or Adapter Pattern]
Question4 -->|No| Question5{Is it polymorphic<br/>behavior?}
Question5 -->|Yes| ADT[Use ADTs +<br/>Pattern Matching]
Question5 -->|No| ReviewSOLID[Apply SOLID<br/>Principles]
style Start fill:#e1f5ff,stroke:#0066cc,color:#000
style CheckSOLID fill:#f5e1ff,stroke:#8800cc,color:#000
style SplitSRP fill:#ffe1e1,stroke:#cc0000,color:#000
style ApplyDIP fill:#ffe1e1,stroke:#cc0000,color:#000
style FP fill:#e1ffe1,stroke:#2d7a2d,color:#000
style Builder fill:#fff4e1,stroke:#cc8800,color:#000
style Factory fill:#fff4e1,stroke:#cc8800,color:#000
style TypeClass fill:#f0e1ff,stroke:#8800cc,color:#000
style ADT fill:#e1ffe1,stroke:#2d7a2d,color:#000
Factory pattern: smart constructors + ADTs
classDiagram
class LLMClient {
<<trait>>
+complete(Conversation, CompletionOptions) Result~Completion~
+streamComplete(Conversation, CompletionOptions, onChunk) Result~Completion~
}
class ProviderConfig {
<<sealedtrait>>
}
class OpenAIConfig {
+model: ModelName
+apiKey: ApiKey
}
class AnthropicConfig {
+model: ModelName
+apiKey: ApiKey
}
class LLMConnect {
<<object>>
+getClient(ProviderConfig) Result~LLMClient~
}
class OpenAIClient {
+complete() Result~Completion~
}
class AnthropicClient {
+complete() Result~Completion~
}
ProviderConfig <|-- OpenAIConfig
ProviderConfig <|-- AnthropicConfig
LLMClient <|.. OpenAIClient
LLMClient <|.. AnthropicClient
LLMConnect ..> ProviderConfig: uses
LLMConnect ..> LLMClient: creates
OOP approach (classic Factory):
| |
What’s happening here?
- This snippet shows the minimal shape for: Factory pattern: smart constructors + ADTs.
- In review, confirm the types make the happy path obvious and the error path explicit.
FP approach (Smart constructors + ADTs):
| |
What’s happening here?
- This snippet shows the minimal shape for: Factory pattern: smart constructors + ADTs.
- In review, confirm the types make the happy path obvious and the error path explicit.
llm4s hybrid approach (LLMConnect.scala:10-46):
| |
What’s happening here?
- This snippet shows the minimal shape for: Factory pattern: smart constructors + ADTs.
- In review, confirm the types make the happy path obvious and the error path explicit.
Why this works:
- DIP: High-level code depends on
LLMClienttrait, not concrete classes - OCP: Add new provider by adding case to sealed trait, not modifying factory
- Result monad: Handles errors without exceptions (FP)
- ADTs: Exhaustive pattern matching ensures all cases handled (FP)
Review checklist
- Factory returns
Result[T]notT(handles validation) - Factory uses ADT pattern matching, not if/else on strings
- New implementations don’t require modifying factory code
- Factory is in companion object or dedicated object
Builder pattern: phantom types vs currying
classDiagram
class BuilderState {
<<sealedtrait>>
}
class Empty {
<<phantomtype>>
}
class HasModel {
<<phantomtype>>
}
class Complete {
<<phantomtype>>
}
class LLMClientBuilder~S~ {
-model: Option~ModelName~
-apiKey: Option~ApiKey~
+withModel(ModelName) LLMClientBuilder~HasModel~
+withApiKey(ApiKey) LLMClientBuilder~Complete~
+build() Result~LLMClient~
}
BuilderState <|-- Empty
BuilderState <|-- HasModel
BuilderState <|-- Complete
LLMClientBuilder ..> BuilderState: S < BuilderState
note for LLMClientBuilder "Phantom type S tracks builder state at compile time"
FP approach (Phantom types for compile-time safety):
| |
What’s happening here?
S <: BuilderStateis a phantom type: it exists only at compile time to track which steps you’ve done.- The
implicit ev: S =:= ...constraints make illegal calls (like.build()too early) fail at compile time. build()still returnsResult[LLMClient]because “builder complete” is not the same thing as “config valid”.
Review checklist
- Builder is immutable (returns new instance, doesn’t mutate)
-
build()returnsResult[T]for validation - Required fields enforced at compile time (phantom types) or runtime (validation)
- Builder doesn’t have 10+ methods (split into multiple builders if needed)
Singleton pattern: object in Scala
Singletons, in one diagram: object is fine for stateless shared wiring, but domain code should still depend on
abstractions (DI) instead of reaching for globals.
flowchart LR
subgraph Domain["Domain (core)"]
Service["Service (depends on trait)"]
Client["LLMClient (trait)"]
end
subgraph Wiring["Wiring (edge)"]
Live["OpenAIClient (impl)"]
Registry["ToolRegistry (object)"]
end
Service -->|depends on| Client
Live -->|implements| Client
Registry --> Service
style Service fill:#e1f5ff,stroke:#0066cc,color:#000
style Client fill:#fff4e1,stroke:#cc8800,color:#000
style Live fill:#e1ffe1,stroke:#2d7a2d,color:#000
style Registry fill:#f0e1ff,stroke:#8800cc,color:#000
OOP approach (Java singleton, avoid in Scala):
| |
What’s happening here?
- You’re manually recreating something Scala already gives you with
object. - It also nudges you towards hidden global state, which makes testing painful.
Scala approach (object is already a singleton):
| |
What’s happening here?
objectis a single instance by definition, so callers just refer to it directly.- Keep objects either stateless or holding immutable state; if you need mutation, isolate and synchronize it.
Review checklist
- Use
objectfor singletons, not class with private constructor - Singleton objects are stateless or have immutable state
- If mutable state needed, synchronize access or use Ref/Atomic
Strategy pattern: ADTs + pattern matching
classDiagram
class ToolExecutionStrategy {
<<sealedtrait>>
}
class Sequential {
<<caseobject>>
}
class Parallel {
<<caseobject>>
}
class ParallelWithLimit {
+maxConcurrent: Int
}
class ToolRegistry {
+executeAll(requests, strategy) AsyncResult
}
ToolExecutionStrategy <|-- Sequential
ToolExecutionStrategy <|-- Parallel
ToolExecutionStrategy <|-- ParallelWithLimit
ToolRegistry ..> ToolExecutionStrategy: "pattern matches on"
note for ToolExecutionStrategy "Adding new strategy = adding new case class"
| |
What’s happening here?
- The “strategy” is data (a sealed ADT), not a subtype hierarchy with overridden methods.
- Dispatch stays explicit in one place (
match), and Scala enforces exhaustiveness when you add a new strategy.
Why ADTs are better for Strategy:
- OCP: Add new strategy by adding case to sealed trait
- Exhaustiveness: Compiler ensures all strategies handled
- No runtime polymorphism: Direct dispatch, better performance
- Serializable: ADTs can be serialized (strategies as data)
Review checklist
- Strategy is sealed trait (exhaustive matching)
- Strategies are immutable case objects/classes
- Pattern matching covers all cases (no wildcard unless justified)
- New strategy doesn’t require modifying existing strategies
State pattern: ADT + immutable state with copy
stateDiagram-v2
[*] --> InProgress: Agent started
InProgress --> WaitingForTools: Tool call requested
WaitingForTools --> InProgress: Tools executed
InProgress --> Complete: Task finished
InProgress --> Failed: Error occurred
WaitingForTools --> Failed: Tool error
Complete --> [*]
Failed --> [*]
note right of InProgress: Each transition returns new AgentState
note right of WaitingForTools: Pure functions, no mutation
| |
What’s happening here?
- You model state as data (
AgentState) plus a closed set of statuses (AgentStatus). - Every transition returns a new value via
copy, so you can test transitions as pure functions and avoid “who mutated this?” bugs.
Why ADTs + immutable state are better:
- Immutability: State transitions return new state, don’t mutate
- Pattern matching: Explicit handling of all states
- Serializable: Can save/restore agent state
- Testable: Pure functions, easy to test transitions
Review checklist
- State is represented as sealed ADT
- State transitions are pure functions (return new state)
- No mutable state fields (use
copyfor updates) - Illegal transitions return error or are no-ops
Adapter pattern: type classes
classDiagram
class LLMClient {
<<trait>>
+complete(Conversation) Result~Completion~
}
class ThirdPartyLLM {
<<externalAPI>>
+generate(String) String
}
class LLMClientAdapter {
-thirdParty: ThirdPartyLLM
+complete(Conversation) Result~Completion~
}
class JsonWriter~A~ {
<<typeclass>>
+write(A) ujson.Value
}
LLMClient <|.. LLMClientAdapter
LLMClientAdapter o-- ThirdPartyLLM: wraps
note for JsonWriter~A~ "Type class: adapts without inheritance"
OOP approach (classic Adapter):
| |
What’s happening here?
- This snippet shows the minimal shape for: Adapter pattern: type classes.
- In review, confirm the types make the happy path obvious and the error path explicit.
FP approach (Type classes):
| |
What’s happening here?
JsonWriter[A]is a capability: “I know how to turn anAinto JSON”.- You add behaviour by providing instances (
JsonWriter[Completion]) without modifyingCompletionitself. - Call sites use
toJson[A: JsonWriter]and let implicit resolution pick the right writer.
Why type classes are better for cross-cutting concerns:
- ISP: Clients only import the type class instances they need
- OCP: Add new type -> add new instance, don’t modify existing code
- Composition: Type class instances compose
Review checklist
- Adapter wraps external API, not internal types
- Adapter translates data formats bidirectionally
- For serialization/validation, prefer type classes over adapters
- Adapter handles errors gracefully (returns
Result)
Decorator pattern: higher-order functions
flowchart LR
subgraph OOP["OOP: Class Decorators"]
direction TB
Base1["OpenAIClient"] --> Log1["LoggingDecorator"]
Log1 --> Cache1["CachingDecorator"]
end
subgraph FP["FP: Higher-Order Functions"]
direction TB
Base2["baseCall"] --> Log2["withLogging(f)"]
Log2 --> Cache2["withCaching(cache)(f)"]
end
OOP ---|" Same behavior<br/>Different approach "| FP
style Base1 fill:#e1f5ff,stroke:#0066cc,color:#000
style Log1 fill:#fff4e1,stroke:#cc8800,color:#000
style Cache1 fill:#e1ffe1,stroke:#2d7a2d,color:#000
style Base2 fill:#e1f5ff,stroke:#0066cc,color:#000
style Log2 fill:#fff4e1,stroke:#cc8800,color:#000
style Cache2 fill:#e1ffe1,stroke:#2d7a2d,color:#000
OOP approach (class decorators):
| |
What’s happening here?
- This snippet shows the minimal shape for: Decorator pattern: higher-order functions.
- In review, confirm the types make the happy path obvious and the error path explicit.
FP approach (Higher-order functions):
| |
What’s happening here?
- You represent “call the LLM” as a plain function type (
Conversation => Result[Completion]). - Cross-cutting concerns become higher-order functions that wrap that call and return a new call.
- Composition becomes wiring (
pipe) instead of class stacking.
When to use which:
- Traits with abstract override: Stateful decorators (logging, tracing, metrics)
- HOFs: Stateless decorators (retry, timeout, caching)
- Kleisli: When composing functions that return monadic types
Review checklist
- Decorator doesn’t change core behavior, only adds cross-cutting concerns
- Decorator is composable (can stack multiple)
- For pure functions, prefer HOFs over trait decorators
- Decorator handles errors from wrapped component
Composite pattern: recursive ADTs + Semigroup
classDiagram
class Guardrail {
<<sealedtrait>>
}
class Single {
+check: String => Result~Unit~
}
class All {
+guardrails: List~Guardrail~
}
class Any {
+guardrails: List~Guardrail~
}
Guardrail <|-- Single
Guardrail <|-- All
Guardrail <|-- Any
All o-- Guardrail: contains
Any o-- Guardrail: contains
note for Guardrail "Recursive ADT enables tree structures"
| |
What’s happening here?
- The guardrail set is a recursive tree (
Single,All,Any) so composition is just building bigger trees. - The interpreter (
evaluate) is where behaviour lives: it walks the tree and returnsResult[Unit]. - The
Semigroup[Guardrail]instance gives you a standard “combine” operator (|+|) for free.
Why ADTs are better for composites:
- Pattern matching: Explicit handling of leaf vs composite
- Immutability: ADTs are immutable by default
- Semigroup: Algebraic composition via
|+|operator
Review checklist
- Composite and leaf share same interface
- Composite delegates to children recursively
- Empty composite has sensible behavior
- Consider using
Semigroupfor algebraic composition
Observer pattern: callbacks with ADT events
sequenceDiagram
participant C as Caller
participant A as Agent
participant CB as Callback (onEvent)
C ->> A: run(query, onEvent)
A ->> CB: StepStarted(0)
A ->> A: Process step
A ->> CB: ToolCallStarted("get_weather")
A ->> A: Execute tool
A ->> CB: AgentCompleted(state)
A ->> C: Result[AgentState]
note right of CB: ADT events ensure<br/>exhaustive handling
| |
What’s happening here?
- You model events as an ADT so callers can handle them exhaustively (no stringly-typed event names).
- The “observer” is just a callback; you avoid registering/unregistering listeners and shared mutable lists.
Why callbacks over full Observer pattern:
- Simplicity: No registration/unregistration boilerplate
- Type safety: Events are ADT, exhaustive matching enforced
- No mutable state: No observable/observer list to manage
Review checklist
- Events are sealed ADT (exhaustive matching)
- Callback parameter has default value for optional usage
- Events include timestamp for debugging
- Callback doesn’t throw (agent shouldn’t fail if callback fails)
Visitor pattern: zero-allocation JSON traversal
The problem: You need to transform JSON structures without allocating intermediate objects (critical for high-throughput systems).
Real-world example from toon4s (originally designed by Li Haoyi ):
| |
What’s happening here?
JsonVisitordefines operations (visit methods) without tying them to specific data structures.- Each
visitmethod processes one JSON node type. - No intermediate allocations - results accumulate directly.
Why Visitor over pattern matching:
| |
Performance comparison (toon4s benchmarks):
- Pattern matching: ~2000 allocations/sec, 50μs GC pauses
- Visitor: 0 allocations, 0 GC overhead
When to use:
- High-throughput systems (JSON parsing, serialization)
- Performance-critical hot paths
- Large nested structures (deep JSON, ASTs)
- Simple one-off transformations - pattern matching is clearer
sequenceDiagram
participant Client
participant JSON as JSON Structure
participant Visitor as DepthValidator
Client->>JSON: ujson.read(json)
activate JSON
JSON->>Visitor: visitObject(fields)
activate Visitor
loop For each field
JSON->>Visitor: visitString("user")
Visitor-->>JSON: depth = 1
JSON->>Visitor: visitObject(profile)
activate Visitor
JSON->>Visitor: visitString("profile")
Visitor-->>JSON: depth = 1
JSON->>Visitor: visitObject(settings)
activate Visitor
JSON->>Visitor: visitString("theme")
Visitor-->>JSON: depth = 1
JSON->>Visitor: visitString("dark")
Visitor-->>JSON: depth = 1
deactivate Visitor
Visitor-->>JSON: depth = 3
deactivate Visitor
Visitor-->>JSON: depth = 4
end
deactivate Visitor
JSON-->>Client: Final depth = 4
deactivate JSON
note over Visitor: Zero allocations<br/>No intermediate objects<br/>Direct traversal
note over JSON,Visitor: Each visit method processes<br/>one node type without<br/>creating wrapper classes
Review checklist:
- Visitor methods return consistent type (don’t mix Unit and values)
- No mutable state in visitor (keep it pure)
- Handle all node types (exhaustiveness)
- Benchmark vs pattern matching if claiming “zero allocation”
Command pattern: ADTs as operations
classDiagram
class Operation {
<<sealedtrait>>
}
class CreateFile {
+path: String
+content: String
}
class DeleteFile {
+path: String
}
class UpdateFile {
+path: String
+newContent: String
}
class Interpreter {
+execute(Operation) Result~String~
+undo(Operation) Operation
}
Operation <|-- CreateFile
Operation <|-- DeleteFile
Operation <|-- UpdateFile
Interpreter ..> Operation: interprets
note for Operation "Commands are data:<br/>serializable, composable, inspectable"
| |
What’s happening here?
- Commands are data (
Operation), so you can validate/log/queue them before you execute them. - “Doing the thing” is an interpreter (
execute), which is easy to test because it’s just a pure match.
Why ADTs are better for Command:
- Serializable: Commands can be saved, queued, sent over network
- Composable: Commands can be combined (sequence, parallel)
- Inspectable: Can analyze commands before executing
- Testable: Can test interpreter without executing side effects
Review checklist
- Commands are immutable ADTs
- Command execution is separate from command definition (interpreter pattern)
- Commands include all data needed for execution
- Command results are wrapped in
Resultfor error handling
Pattern selection guide
| Question | Pattern | Paradigm |
|---|---|---|
| Creating objects with validation? | Smart constructors + Factory | FP |
| Building complex objects step-by-step? | Phantom type Builder | Hybrid |
| Need a global instance? | object | Scala |
| Wrapping external API? | Adapter (trait or type class) | Hybrid |
| Adding cross-cutting concerns? | Decorator (HOF or trait) | Hybrid |
| Combining similar objects? | Composite (ADT + Semigroup) | FP |
| Swappable algorithms? | Strategy (ADT + pattern matching) | FP |
| State machine with transitions? | State (ADT + pure functions) | FP |
| Event notifications? | Callbacks with ADT events | Hybrid |
| Operations as data? | Command (ADT) | FP |
Pattern maturity model
Level 1: Beginner (Focus: Type safety, immutability)
Master these first:
- ADTs, Pattern Matching, Smart Constructors
Resulttype,Option,Either- Immutable collections,
copy()for updates - SOLID: SRP and DIP - single responsibility functions, depend on traits
| |
What’s happening here?
- SRP: parsing and validation are separate functions, so each one is easy to test and change.
- DIP:
Servicedepends onStorage(an abstraction), so production vs test storage is just a different implementation.
Level 2: Intermediate (Focus: Composition, abstraction)
Master these next:
- Type Classes, Implicits, Higher-Order Functions
- for-comprehensions, Monads
- Builder pattern + Phantom Types
- SOLID: ISP and OCP - focused interfaces, extend without modification
Level 3: Advanced (Focus: Type-level programming)
Master these when ready:
- EitherT/OptionT, Semigroup/Monoid
- F-Bounded Polymorphism, Self Types
- Lazy Evaluation, Currying
- All 5 SOLID principles - apply reflexively
Level 4: Expert (Focus: Architecture, domain modeling)
Master these for architecture:
- Type Members, Opaque Types (Scala 3)
- Reader/Writer Monads, Traverse
- Combining all patterns cohesively
- SOLID violations detection - spot and refactor instantly
SOLID principles in practice
Single responsibility principle (SRP)
Check: Class name describes a single, clear purpose. Can you explain it in one sentence without “and”?
SRP, in one diagram: one god object becomes a set of small, boring components.
classDiagram
class ToolManager {
+register()
+validate()
+execute()
+log()
+formatOutput()
+cacheResult()
}
class ToolRegistry
class ToolValidator
class ToolExecutor
class ToolLogger
class ToolFormatter
class ToolCache
ToolExecutor --> ToolRegistry
ToolExecutor --> ToolValidator
ToolExecutor --> ToolCache
ToolExecutor --> ToolLogger
ToolExecutor --> ToolFormatter
style ToolManager fill:#ffe1e1,stroke:#cc0000,color:#000
style ToolExecutor fill:#e1f5ff,stroke:#0066cc,color:#000
style ToolRegistry fill:#e1ffe1,stroke:#2d7a2d,color:#000
style ToolValidator fill:#e1ffe1,stroke:#2d7a2d,color:#000
style ToolCache fill:#e1ffe1,stroke:#2d7a2d,color:#000
style ToolLogger fill:#e1ffe1,stroke:#2d7a2d,color:#000
style ToolFormatter fill:#e1ffe1,stroke:#2d7a2d,color:#000
Bad:
| |
What’s happening here?
- This snippet shows the minimal shape for: Single responsibility principle.
- In review, confirm the types make the happy path obvious and the error path explicit.
Good:
| |
What’s happening here?
- This snippet shows the minimal shape for: Single responsibility principle.
- In review, confirm the types make the happy path obvious and the error path explicit.
How to detect violations:
- Class has >300 lines (smell, not rule)
- Class name contains “Manager”, “Handler”, “Service”, “Util” (vague names)
- Constructor takes 5+ dependencies
- Methods operate on unrelated data
Review checklist
- Class name describes a single, clear purpose
- Methods all relate to that single purpose
- Can explain the class in one sentence without “and”
- Changes to different concerns don’t affect this class
Open/Closed principle (OCP)
Check: Can you add a new provider/tool/strategy without editing existing classes?
OCP, in one diagram: core depends on an abstraction; new behaviour comes in as a new implementation.
flowchart LR
Core["Core code"] -->|depends on| Factory["ProviderFactory (trait)"]
OpenAI["OpenAIProviderFactory"] -->|implements| Factory
Anthropic["AnthropicProviderFactory"] -->|implements| Factory
New["NewProviderFactory"] -->|implements| Factory
style Core fill:#e1f5ff,stroke:#0066cc,color:#000
style Factory fill:#fff4e1,stroke:#cc8800,color:#000
style OpenAI fill:#e1ffe1,stroke:#2d7a2d,color:#000
style Anthropic fill:#e1ffe1,stroke:#2d7a2d,color:#000
style New fill:#f0e1ff,stroke:#8800cc,color:#000
| |
What’s happening here?
- This snippet shows the minimal shape for: Open/Closed principle.
- In review, confirm the types make the happy path obvious and the error path explicit.
Review checklist
- New features added via new implementations, not editing existing classes
- Core abstractions (traits) are stable
- New providers/tools/strategies don’t require modifying registry code
- Uses polymorphism, not if/else chains on type codes
Liskov substitution principle (LSP)
Check: Can you swap implementations without changing behavior?
LSP, in one diagram: every implementation is swappable because they all obey the same contract.
classDiagram
class LLMClient {
<<trait>>
+complete()
}
class OpenAIClient
class AnthropicClient
class BrokenClient
LLMClient <|.. OpenAIClient
LLMClient <|.. AnthropicClient
LLMClient <|.. BrokenClient
note for LLMClient "Contract: never throws; does not mutate input"
note for BrokenClient "Violates contract: throws; mutates input"
style LLMClient fill:#fff4e1,stroke:#cc8800,color:#000
style OpenAIClient fill:#e1ffe1,stroke:#2d7a2d,color:#000
style AnthropicClient fill:#e1ffe1,stroke:#2d7a2d,color:#000
style BrokenClient fill:#ffe1e1,stroke:#cc0000,color:#000
All LLMClient implementations must honor the contract:
Review checklist
- Subtype preserves preconditions (doesn’t require more)
- Subtype preserves postconditions (doesn’t promise less)
- Subtype preserves invariants (e.g., error semantics)
- Subtype doesn’t throw new exceptions not in base contract
- Subtype doesn’t weaken base type guarantees
| |
What’s happening here?
- The “contract” is not just the method signature. It’s behavioural: never throw, don’t mutate inputs, and respect options.
BrokenClienttechnically “implements the trait” but breaks those behavioural promises, so callers can’t substitute it safely.
Interface segregation principle (ISP)
Check: Are there methods that throw NotImplementedError? That’s a violation.
ISP, in one diagram: narrow interfaces let implementations pick only what they can actually support.
classDiagram
class LLMService {
+complete()
+embed()
+createFineTuneJob()
+moderate()
}
class OllamaService
LLMService <|.. OllamaService
class CompletionClient
class EmbeddingClient
class FineTuneClient
class ModerationClient
class OpenAIClient
class OllamaClient
class AnthropicClient
CompletionClient <|.. OpenAIClient
EmbeddingClient <|.. OpenAIClient
FineTuneClient <|.. OpenAIClient
ModerationClient <|.. OpenAIClient
CompletionClient <|.. OllamaClient
EmbeddingClient <|.. OllamaClient
CompletionClient <|.. AnthropicClient
note for OllamaService "Fat interface forces stubs/NotImplementedError"
style LLMService fill:#ffe1e1,stroke:#cc0000,color:#000
style OllamaService fill:#ffe1e1,stroke:#cc0000,color:#000
style CompletionClient fill:#e1ffe1,stroke:#2d7a2d,color:#000
style EmbeddingClient fill:#e1ffe1,stroke:#2d7a2d,color:#000
style FineTuneClient fill:#e1ffe1,stroke:#2d7a2d,color:#000
style ModerationClient fill:#e1ffe1,stroke:#2d7a2d,color:#000
style OpenAIClient fill:#e1f5ff,stroke:#0066cc,color:#000
style OllamaClient fill:#e1f5ff,stroke:#0066cc,color:#000
style AnthropicClient fill:#e1f5ff,stroke:#0066cc,color:#000
Bad:
| |
What’s happening here?
- This snippet shows the minimal shape for: Interface segregation principle.
- In review, confirm the types make the happy path obvious and the error path explicit.
Good:
| |
What’s happening here?
- This snippet shows the minimal shape for: Interface segregation principle.
- In review, confirm the types make the happy path obvious and the error path explicit.
Review checklist
- Traits have ≤7 abstract methods (guideline, not rule)
- All implementations use all methods
- Traits are cohesive (methods relate to single purpose)
- Clients import only the traits they need
Dependency inversion principle (DIP)
Check: Does core depend on infrastructure, or vice versa?
DIP, in one diagram: high-level code depends on abstractions; config and providers live at the edge.
flowchart TD
subgraph HighLevel["High-level policy (core)"]
Agent["Agent"]
end
subgraph Abstractions["Abstractions (core)"]
LLMClient["LLMClient (trait)"]
end
subgraph LowLevel["Low-level details (edge)"]
Config["Config loader"]
OpenAI["OpenAIClient"]
end
Agent --> LLMClient
OpenAI --> LLMClient
Config --> OpenAI
style Agent fill:#e1f5ff,stroke:#0066cc,color:#000
style LLMClient fill:#fff4e1,stroke:#cc8800,color:#000
style Config fill:#f0e1ff,stroke:#8800cc,color:#000
style OpenAI fill:#e1ffe1,stroke:#2d7a2d,color:#000
Bad:
| |
What’s happening here?
- This snippet shows the minimal shape for: Dependency inversion principle.
- In review, confirm the types make the happy path obvious and the error path explicit.
Good:
| |
What’s happening here?
- This snippet shows the minimal shape for: Dependency inversion principle.
- In review, confirm the types make the happy path obvious and the error path explicit.
Review checklist
- Core modules depend on traits, not concrete classes
- Concrete implementations provided at application edges
- Dependencies injected via constructors, not created internally
- No
new ConcreteClassin core business logic
Testing strategy review
Test strategy, in one diagram: fast gates run always; slow/provider tests run explicitly.
flowchart TD
PR["PR opened"] --> Gates["CI gates"]
Gates --> Compile["+compile (Scala 2.13 + 3)"]
Gates --> Format["scalafmtCheckAll"]
Gates --> Unit["Unit tests (fast)"]
Unit --> Coverage["scoverage gate (baseline + critical paths)"]
Coverage --> Cross["Cross-version tests (modules/crossTest)"]
Cross --> Integr["Integration tests (no credentials)"]
Integr --> Provider{"Provider tests?<br/>(needs credentials)"}
Provider -->|CI without creds| Skip["Skip in CI<br/>(tagged, explicit)"]
Provider -->|Local/cred - enabled CI| Run["Run provider tests<br/>(@ProviderTest)"]
Run --> Merge["Ready to merge"]
Skip --> Merge
style PR fill:#e1f5ff,stroke:#0066cc,color:#000
style Gates fill:#f0e1ff,stroke:#8800cc,color:#000
style Compile fill:#e1f5ff,stroke:#0066cc,color:#000
style Format fill:#e1f5ff,stroke:#0066cc,color:#000
style Unit fill:#e1ffe1,stroke:#2d7a2d,color:#000
style Coverage fill:#fff4e1,stroke:#cc8800,color:#000
style Cross fill:#fff4e1,stroke:#cc8800,color:#000
style Integr fill:#fff4e1,stroke:#cc8800,color:#000
style Provider fill:#fff4e1,stroke:#cc8800,color:#000
style Skip fill:#e1f5ff,stroke:#0066cc,color:#000
style Run fill:#e1ffe1,stroke:#2d7a2d,color:#000
style Merge fill:#e1ffe1,stroke:#2d7a2d,color:#000
Unit test requirements
- Tests mirror package structure
- Each public method has at least one test
- Both success and failure paths tested
- Edge cases covered (empty, null/None, boundary values)
Test patterns
| |
What’s happening here?
- This snippet shows the minimal shape for: Test patterns.
- In review, confirm the types make the happy path obvious and the error path explicit.
Coverage requirements
- Minimum 50% statement coverage (enforced by scoverage)
- Critical paths (error handling, security) should have higher coverage
- Coverage excludes: samples, workspace, runner
Property-based testing
For complex transformations, use ScalaCheck:
| |
What’s happening here?
- You generate lots of inputs (
Arbitrary[ModelName]) and check an invariant holds for all of them (roundtrip, error preservation). - This is how you catch “works for my one test input” bugs in parsing/serialization/transforms.
When to use property-based tests:
- Serialization roundtrips (JSON, protobuf)
- Invariant preservation (sorted lists stay sorted)
- Algebraic laws (Semigroup associativity)
- Parsers (parse-print roundtrip)
Integration tests
- Provider tests marked with
@ProviderTesttag (skipped in CI without credentials) - Tests use test fixtures, not production data
- Cleanup happens in
afterAll, not relying on garbage collection - Timeouts set for network operations
Cross-version tests
-
modules/crossTestvalidates behavior matches on Scala 2.13 and 3.x - No version-specific behavior leaks (especially in implicits/givens)
-
ScalaCompatshims tested on both versions
Documentation review
Code documentation
- Public APIs have ScalaDoc
- Complex algorithms have inline comments explaining “why”
- No comments explaining obvious “what”
- Examples in ScalaDoc actually compile
ADR requirements
Write an ADR when a change:
- Introduces new architectural dependency
- Changes public API or error model
- Changes storage formats or tool schemas
- Changes concurrency model
- Adds security-relevant behavior
README updates
- User-facing features documented in README
- Breaking changes noted in CHANGELOG
- New samples added for new features
- Environment variables documented if added
README and user-facing docs
- README updated if API changes
- Quickstart examples still work after changes
- Migration guide added for breaking changes
- CHANGELOG updated with user-visible changes
- Version numbers consistent across docs and code
Documentation review checklist
| Change type | Required doc updates |
|---|---|
| New public API | ScalaDoc + README example |
| API behavior change | ScalaDoc update + migration note |
| New config option | README config section |
| New error type | Error handling guide |
| Deprecation | @deprecated annotation + migration path |
Build, release, CI, DevOps review
Build requirements
-
sbt +compilesucceeds (both Scala versions) -
sbt +testsucceeds -
sbt scalafmtCheckAllpasses - No new compiler warnings introduced
Dependency changes
- New dependencies justified (not just “nice to have”)
- License compatible with MIT
- No transitive dependency conflicts
- Cross-published for both Scala versions
Version compatibility
- MiMa checks pass or exception justified
- Deprecations have migration path
- Breaking changes require version bump
Security and dependency hygiene
Security trust boundary, in one diagram: treat LLM output like user input until it’s validated.
flowchart LR
User["User input"] --> Prompt["Prompt builder<br/>(string templating)"]
Prompt --> LLM["LLM call"]
LLM --> Out["LLM output<br/>(UNTRUSTED)"]
Out --> Parse["Parse candidate tool call"]
Parse --> Validate["Validate args against schema<br/>(strict types + allowlists)"]
Validate -->|Valid| Exec["Execute tool"]
Validate -->|Invalid| Reject["Reject + return typed error"]
Exec --> Logs["Logs/trace<br/>(redaction + no secrets)"]
Reject --> Logs
Out -.-> Attack["Prompt injection attempt<br/>(tool call with untrusted args)"]
Attack -.-> Validate
style User fill:#e1f5ff,stroke:#0066cc,color:#000
style Prompt fill:#f0e1ff,stroke:#8800cc,color:#000
style LLM fill:#f0e1ff,stroke:#8800cc,color:#000
style Out fill:#ffe1e1,stroke:#cc0000,color:#000
style Parse fill:#fff4e1,stroke:#cc8800,color:#000
style Validate fill:#fff4e1,stroke:#cc8800,color:#000
style Exec fill:#e1ffe1,stroke:#2d7a2d,color:#000
style Reject fill:#ffe1e1,stroke:#cc0000,color:#000
style Logs fill:#e1f5ff,stroke:#0066cc,color:#000
style Attack fill:#ffe1e1,stroke:#cc0000,color:#000
Secret handling
- API keys never logged
- Secrets redacted in error messages
-
ApiKey,JwtToken,OAuthTokentypes have safetoString - No secrets in stack traces
LLM safety
- LLM output treated as untrusted user input
- Tool arguments validated against schema
- No LLM output executed as code/SQL/shell
- Prompt injection mitigations in place for user input
Resource limits
- Token budgets enforced
- Max tool calls per request
- Timeouts on all external calls
- Max payload sizes
- Retry limits with backoff
Tool calling security
- Strict schemas for tool inputs
- Allowlists for outbound network calls
- Audit logging of tool invocations
- Idempotent where possible
Performance and resource safety
Practical performance patterns
These are battle-tested patterns from handsonscala that solve real performance problems.
Memoization: cache expensive computations
The problem: You’re recomputing expensive results for the same inputs (Fibonacci, recursive algorithms).
Bad:
| |
Good: memoization:
| |
Review checklist:
- Cache keyed on function arguments (not mutable state)
- Thread-safety considered if concurrent access
- Cache eviction strategy for unbounded inputs
Parallel collections: CPU-bound parallelism
The problem: You’re doing CPU-intensive work on large collections sequentially.
Bad:
| |
Good:
| |
When to use:
- CPU-bound work (math, parsing, transformations)
- Large collections (>10k elements typically)
- Stateless computations (no side effects)
- IO-bound work - use
Future.traverseorparTraverseinstead - Small collections - overhead > benefit
Review checklist:
- Actually CPU-bound (profile first!)
- No side effects in parallel operations
- Thread pool size configured for workload
StringBuilder: string concatenation in loops
The problem: String concatenation in loops creates intermediate strings (O(n²) allocations).
Bad:
| |
Good:
| |
Real-world benchmark (handsonscala):
- String concatenation: 1000 iterations = 50ms, 500KB garbage
- StringBuilder: 1000 iterations = 2ms, 4KB garbage
Review checklist:
- Use
StringBuilderfor loops with >10 concatenations - Don’t forget
.toStringat the end - Consider
mkStringfor simple joins:list.mkString(",")
Performance review checklist
- Hot paths identified before optimization
- Benchmarks provided for performance claims (JMH preferred)
- Tradeoffs explicitly documented (readability vs speed)
- No premature optimization
Allocation awareness
- Avoid unnecessary allocations in hot paths
- Hoist constants out of loops
- Prefer
StringBuilderover string concatenation in loops - Avoid
ziptuple churn when indices suffice
Concurrency safety
- No blocking on global ExecutionContext
- Blocking isolated to dedicated thread pool
- No
Await.resultorThread.sleepin core - Shared mutable state has explicit synchronization
Resource leaks
- Connections properly closed
- File handles released
- Thread pools shut down
- Cleanup in error paths
Production resilience patterns
Retry with exponential backoff (llm4s):
| |
flowchart TD
Start[Execute operation] --> Check{Result?}
Check -->|Right value| Success[Return Right value]
Check -->|Left error| Retryable{isRetryable?<br/>maxRetries > 0?}
Retryable -->|Yes| Sleep["Sleep delay<br/>delay = 1s"]
Retryable -->|No| Fail[Return Left error]
Sleep --> Retry1["Retry attempt 1<br/>Sleep 2s<br/>maxRetries = 2"]
Retry1 --> Check2{Result?}
Check2 -->|Right| Success
Check2 -->|Left| Retry2["Retry attempt 2<br/>Sleep 4s<br/>maxRetries = 1"]
Retry2 --> Check3{Result?}
Check3 -->|Right| Success
Check3 -->|Left| Retry3["Retry attempt 3<br/>Sleep 8s<br/>maxRetries = 0"]
Retry3 --> Check4{Result?}
Check4 -->|Right| Success
Check4 -->|Left| Fail
note1["Exponential backoff:<br/>delay × 2 each retry<br/>1s to 2s to 4s to 8s"]
style Success fill:#e1ffe1,stroke:#2d7a2d,color:#000
style Fail fill:#ffe1e1,stroke:#cc0000,color:#000
style Sleep fill:#e1f5ff,stroke:#0066cc,color:#000
style Retry1 fill:#fff4e1,stroke:#cc8800,color:#000
style Retry2 fill:#fff4e1,stroke:#cc8800,color:#000
style Retry3 fill:#fff4e1,stroke:#cc8800,color:#000
style note1 fill:#f0e1ff,stroke:#8800cc,color:#000
Circuit breaker essentials:
- Open (failing) → reject immediately
- Half-open (testing) → try one request
- Closed (healthy) → normal operation
stateDiagram-v2
[*] --> Closed
Closed --> Open: Failure threshold exceeded - e.g. 5 failures in 10s
note right of Closed: Normal operation<br/>All requests pass through
Open --> HalfOpen: Timeout elapsed - e.g. after 30 seconds
note right of Open: Failing state<br/>Reject immediately<br/>Fail fast
HalfOpen --> Closed: Success
HalfOpen --> Open: Failure
note right of HalfOpen: Testing state<br/>Try one request<br/>to check health
Closed --> Closed: Success
Open --> Open: Request rejected
WebSocket streaming (llm4s):
| |
sequenceDiagram
participant Client as WebSocket Client
participant Server as WebSocket Server
participant Agent
participant LLM as LLM Provider
Client->>Server: message (user query)
activate Server
Server->>Agent: runWithStreaming(query, callback)
activate Agent
Agent->>LLM: streamComplete(conversation)
activate LLM
loop Streaming chunks
LLM-->>Agent: StreamedChunk(delta)
Agent-->>Server: onChunk callback
Server-->>Client: ws.send(chunk.toJson)
note over Client: Display token immediately<br/>(progressive rendering)
end
LLM-->>Agent: Final completion
deactivate LLM
Agent-->>Server: Result[AgentState]
deactivate Agent
Server-->>Client: Final state
deactivate Server
note over Client,LLM: Streaming reduces latency<br/>User sees tokens as they arrive
Pattern maturity progression
Use this 4-level model to assess codebase maturity and recommend next steps:
Level 1: Basic (Entry bar for production)
- Tests exist and pass
- Error handling present (even if basic)
- CI pipeline configured
Level 2: Intermediate (Professional standard)
- ADTs for domain modeling
- Validated error flows (Result/Either)
- Resource safety (bracket/Resource)
Level 3: Advanced (Staff-level engineering)
- Tagless final / effect abstraction
- Typestate / phantom types
- Property-based testing
Level 4: Expert (Principal-level architecture)
- Compile-time contract validation
- Effect systems (Cats Effect 3, ZIO)
- Complex type-level programming
Review guidance: Identify current level, recommend one step up (not three). If codebase is Level 1, suggest Level 2 patterns. Don’t jump to Level 4.
Common PR smells and how to comment
Smell: null checks
Bad:
| |
What’s happening here?
- This snippet shows the minimal shape for: Smell: null checks.
- In review, confirm the types make the happy path obvious and the error path explicit.
Comment:
| |
Smell: exception in domain logic
Bad:
| |
What’s happening here?
- This snippet shows the minimal shape for: Smell: exception in domain logic.
- In review, confirm the types make the happy path obvious and the error path explicit.
Comment:
| |
Smell: direct config read in core
Bad:
| |
What’s happening here?
- This snippet shows the minimal shape for: Smell: direct config read in core.
- In review, confirm the types make the happy path obvious and the error path explicit.
Comment:
| |
Smell: non-exhaustive match
Bad:
| |
What’s happening here?
- This snippet shows the minimal shape for: Smell: non-exhaustive match.
- In review, confirm the types make the happy path obvious and the error path explicit.
Comment:
| |
Smell: blocking on global EC
Bad:
| |
What’s happening here?
- This snippet shows the minimal shape for: Smell: blocking on global EC.
- In review, confirm the types make the happy path obvious and the error path explicit.
Comment:
| |
Smell: missing error cause
Bad:
| |
What’s happening here?
- This snippet shows the minimal shape for: Smell: missing error cause.
- In review, confirm the types make the happy path obvious and the error path explicit.
Comment:
| |
Smell: fail-fast when accumulation needed
Bad:
| |
What’s happening here?
- This snippet shows the minimal shape for: Smell: fail-fast when accumulation needed.
- In review, confirm the types make the happy path obvious and the error path explicit.
Comment:
| |
Smell: unsafe partial function
Bad:
| |
What’s happening here?
- This snippet shows the minimal shape for: Smell: unsafe partial function.
- In review, confirm the types make the happy path obvious and the error path explicit.
Comment:
| |
Smell: comparing newtypes with ==
Bad:
| |
What’s happening here?
- This snippet shows the minimal shape for: Smell: comparing newtypes with ==.
- In review, confirm the types make the happy path obvious and the error path explicit.
Comment:
| |
llm4s examples index
Quick reference to find real examples of patterns in the llm4s codebase.
Error handling
| Pattern | File | Lines |
|---|---|---|
| Safety.safely | modules/core/src/main/scala/org/llm4s/core/safety/Safety.scala | 22-28 |
| fromTry | modules/core/src/main/scala/org/llm4s/core/safety/Safety.scala | 25-28 |
| Result chaining | modules/core/src/main/scala/org/llm4s/agent/Agent.scala | 929-949 |
| Error ADT | modules/core/src/main/scala/org/llm4s/error/LLMError.scala | 21-50 |
Type safety
| Pattern | File | Lines |
|---|---|---|
| ApiKey redaction | modules/core/src/main/scala/org/llm4s/types/package.scala | 88-92 |
| ConversationId smart constructor | modules/core/src/main/scala/org/llm4s/types/package.scala | 115-121 |
| ModelName validation | modules/core/src/main/scala/org/llm4s/types/package.scala | 719-738 |
ADTs and pattern matching
| Pattern | File | Lines |
|---|---|---|
| LLMProvider sealed ADT | modules/core/.../llmconnect/provider/LLMProvider.scala | 12-107 |
| ToolCallError ADT | modules/core/.../toolapi/ToolCallError.scala | entire file |
| AgentStatus ADT | modules/core/.../agent/AgentState.scala | AgentStatus section |
Factory pattern
| Pattern | File | Lines |
|---|---|---|
| LLMConnect factory | modules/core/.../llmconnect/LLMConnect.scala | 10-46 |
| ToolRegistry.empty | modules/core/.../toolapi/ToolRegistry.scala | 138-143 |
Builder pattern
| Pattern | File | Lines |
|---|---|---|
| ToolBuilder | modules/core/.../toolapi/ToolFunction.scala | 101-119 |
Resource management
| Pattern | File | Lines |
|---|---|---|
| ManagedResource | modules/core/.../resource/ManagedResource.scala | 11-45 |
| fileInputStream | modules/core/.../resource/ManagedResource.scala | 60-64 |
Strategy pattern
| Pattern | File | Lines |
|---|---|---|
| ToolExecutionStrategy | modules/core/.../toolapi/ToolExecutionStrategy.scala | entire file |
| executeAll dispatch | modules/core/.../toolapi/ToolRegistry.scala | 64-77 |
Guardrails
| Pattern | File | Lines |
|---|---|---|
| Guardrail trait | modules/core/.../agent/guardrails/Guardrail.scala | 14-47 |
| InputGuardrail | modules/core/.../agent/guardrails/Guardrail.scala | 57-67 |
| CompositeGuardrail | modules/core/.../agent/guardrails/CompositeGuardrail.scala | entire file |
Mermaid diagrams in reviews
GitHub renders Mermaid diagrams in PRs. Use them strategically to clarify complex logic.
When to use Mermaid
- Control flow complexity: Nested pattern matches, multiple error paths
- Architecture boundaries: Module dependencies, trait hierarchies
- Sequence of events: Async operations, tool call flows
- State machines: AgentStatus transitions, connection states
- Error propagation: How errors flow through Result chains
When NOT to use Mermaid
- Trivial logic that fits in 1-2 sentences
- Restating code: Don’t just translate code to diagram form
- Large diagrams: >10 nodes becomes unreadable
Size and readability rules
- Maximum 10 nodes per diagram (preferably 5-7)
- Maximum 12 edges/connections
- Keep node labels under 50 characters
- One diagram per comment
- Always include plain-text summary above diagram
Flowchart template
| |
Semantic colors
| Meaning | Fill | Stroke | Usage |
|---|---|---|---|
| Success | #e1ffe1 | #2d7a2d | Happy path |
| Info | #e1f5ff | #0066cc | Inputs, neutral |
| Warning | #fff4e1 | #cc8800 | Decisions |
| Error | #ffe1e1 | #cc0000 | Failures |
| Process | #f0e1ff | #8800cc | Transformations |
How to sell this internally
If you’re introducing this system to a team, here’s how to frame it.
Frame it as risk management, not nitpicking
“We’re not slowing down delivery - we’re preventing the 15-hour incidents that actually slow us down.”
Metrics to track
| Metric | What it shows | Target |
|---|---|---|
| PR size | Review quality correlates with size | <400 lines of diff |
| Review latency | Time from open to first review | <4 hours |
| Defect escape rate | Bugs found in prod vs in review | Decreasing over time |
| Incident rate | Production incidents per sprint | Decreasing over time |
| Rollback rate | Deployments that need rollback | <5% |
Phased adoption
Week 1-2: Awareness
- Share this document
- Discuss in team meeting
- No enforcement, just education
Week 3-4: Soft enforcement
- Reviewers start using severity labels
- Must-fix items must be fixed
- Should-fix can be deferred with comment
Week 5-8: Full enforcement
- CI bot checks formatting and basic patterns
- All Must-fix items block merge
- Metrics dashboard visible
Ongoing: Refinement
- Monthly review of what’s working
- Adjust rules based on actual defect data
- Remove rules that don’t catch real bugs
What reviewers enforce vs encourage
Hard rules (blockers)
These MUST be fixed before merge:
- Exceptions in domain logic - Use Result types
- Direct config reads in core - Use injection
- Secrets in logs - Use redacted types
- Non-exhaustive ADT matches - Handle all cases
- Breaking API changes without versioning - Explicit migration
- Missing error handling - All paths must handle failure
- Blocking on global EC - Use dedicated pools
Strong recommendations
These SHOULD be fixed, but can be deferred with justification:
- Missing tests for new code - Can defer for prototype
- Suboptimal performance - Document and ticket
- Unclear naming - Context-dependent
- Missing documentation - Better late than never
Optional improvements (non-blocking)
These are suggestions that improve but don’t block:
- More idiomatic patterns - Style preference
- Additional test cases - Diminishing returns
- Code organization - Personal preference
- Comment style - Consistency helps but isn’t critical
Advanced FP patterns reference
Pattern: unified error handling across effect types using Cats MonadError[F, E].
| |
What’s happening here?
- This snippet is a minimal reference shape: unified error handling across effect types using Cats
MonadError[F, E]. - In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.
Pattern: use Validated[E, A] with Semigroup[E] to accumulate multiple errors instead of fail-fast.
| |
What’s happening here?
- This snippet is a minimal reference shape: use
Validated[E, A]withSemigroup[E]to accumulate multiple errors instead of fail-fast. - In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.
Scala 2.13 recipe:
| |
What’s happening here?
- This snippet is a minimal reference shape: use
Validated[E, A]withSemigroup[E]to accumulate multiple errors instead of fail-fast. - In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.
Scala 3 equivalent:
| |
What’s happening here?
- This snippet is a minimal reference shape: use
Validated[E, A]withSemigroup[E]to accumulate multiple errors instead of fail-fast. - In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.
Pattern: compose functions that return effects (A => F[B]) using Kleisli.
| |
What’s happening here?
- This snippet is a minimal reference shape: compose functions that return effects (A => F[B]) using
Kleisli. - In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.
| |
What’s happening here?
- This snippet is a minimal reference shape: compose functions that return effects (A => F[B]) using
Kleisli. - In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.
Pattern: covariance (+T) for producers/collections, contravariance (-T) for consumers/actions.
| |
What’s happening here?
- This snippet is a minimal reference shape: covariance (+T) for producers/collections, contravariance (-T) for consumers/actions.
- In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.
Review tip: When reviewing generic types (especially public APIs), verify variance annotations match usage patterns.
Pattern: use .lift to safely handle partial functions.
| |
What’s happening here?
- This snippet is a minimal reference shape: use
.liftto safely handle partial functions. - In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.
| |
What’s happening here?
- This snippet is a minimal reference shape: use
.liftto safely handle partial functions. - In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.
| |
What’s happening here?
- This snippet is a minimal reference shape: use
.liftto safely handle partial functions. - In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.
| |
What’s happening here?
- This snippet is a minimal reference shape: use
.liftto safely handle partial functions. - In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.
| |
What’s happening here?
- This snippet is a minimal reference shape: use
.liftto safely handle partial functions. - In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.
| |
What’s happening here?
- This snippet is a minimal reference shape: use
.liftto safely handle partial functions. - In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.
| |
What’s happening here?
- This snippet is a minimal reference shape: use
.liftto safely handle partial functions. - In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.
Review note: CPU-bound operations should use a dedicated ExecutionContext rather than
scala.concurrent.ExecutionContext.global, which is designed for non-blocking I/O.
| |
What’s happening here?
- This snippet is a minimal reference shape: use
.liftto safely handle partial functions. - In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.
Review checklist:
- Is there a maximum concurrency limit?
- Does the code respect external rate limits (API quotas)?
- Is backpressure handled (don’t spawn unlimited futures)?
| |
What’s happening here?
- This snippet is a minimal reference shape: use
.liftto safely handle partial functions. - In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.
Review questions:
- Is all access to mutable state synchronized?
- Are read and write operations atomic when needed?
- Would an immutable approach (Actor, STM, or State monad) be cleaner?
| |
What’s happening here?
- This snippet is a minimal reference shape: use
.liftto safely handle partial functions. - In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.
Review checklist:
- Is the trait sealed for exhaustive matching?
- Do all cases have serialization instances?
- Are new message types added by extending the ADT (not modifying existing cases)?
| |
What’s happening here?
- This snippet is a minimal reference shape: use
.liftto safely handle partial functions. - In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.
Review questions:
- Are all state combinations handled?
- Is the
case _fallback intentional and documented? - Would an explicit enum of transitions be clearer?
| |
What’s happening here?
- This snippet is a minimal reference shape: use
.liftto safely handle partial functions. - In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.
| |
What’s happening here?
- This snippet is a minimal reference shape: use
.liftto safely handle partial functions. - In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.
| |
What’s happening here?
- This snippet is a minimal reference shape: use
.liftto safely handle partial functions. - In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.
| |
What’s happening here?
- This snippet is a minimal reference shape: use
.liftto safely handle partial functions. - In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.
| |
What’s happening here?
- This snippet is a minimal reference shape: use
.liftto safely handle partial functions. - In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.
When to use:
- Type parameters: When type is known at call site (more common)
- Type members: When type is defined by implementation, not caller
| |
What’s happening here?
- This snippet is a minimal reference shape: use
.liftto safely handle partial functions. - In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.
Migration path from Scala 2.13 AnyVal:
| |
What’s happening here?
- This snippet is a minimal reference shape: Migration path from Scala 2.13 AnyVal.
- In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.
Pattern: thread configuration through computations without explicit parameter passing.
| |
What’s happening here?
- This snippet is a minimal reference shape: thread configuration through computations without explicit parameter passing.
- In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.
When to use Reader in llm4s context:
- Many functions need the same config subset
- Config is read multiple times in a for-comprehension
- You need to test different configs without reconstructing objects
Pattern: abstract over effect types using higher-kinded type parameters, enabling polymorphic code that works with
any effect (Future, IO, Task, etc.).
| |
What’s happening here?
- This snippet is a minimal reference shape: abstract over effect types using higher-kinded type parameters, enabling polymorphic code that works with.
- In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.
Why tagless final over Free monads:
- Simpler: No need for complex interpreters or natural transformations
- Better inference: Compiler can often infer types automatically
- Performance: No intermediate representation overhead
- Flexibility: Easy to swap effect types for testing
When NOT to use: llm4s avoids tagless final for most code because:
- Adds complexity for teams not familiar with HKT
Result[A]is sufficient for synchronous code- Constructor injection is simpler for dependency management
Review note: If reviewing tagless final code, ensure algebras are minimal (ISP), interpreters are lawful, and there’s clear justification for the added abstraction.
Pattern: accumulate logs alongside computations without side effects.
| |
What’s happening here?
- This snippet is a minimal reference shape: accumulate logs alongside computations without side effects.
- In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.
Review note: Consider Writer for tracing or audit logging code where isolation from shared mutable state matters. Useful in concurrent tool execution where logs from different tools shouldn’t interleave.
Pattern: add pattern matching support to any type via unapply methods.
| |
What’s happening here?
- This snippet is a minimal reference shape: add pattern matching support to any type via
unapplymethods. - In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.
Review note: Consider custom extractors to simplify complex guards or enable matching on wrapped types (like matching directly on JSON structures).
Pattern: abstract over type constructors to write generic code.
| |
What’s happening here?
- This snippet is a minimal reference shape: abstract over type constructors to write generic code.
- In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.
Review note: When reviewing generic utilities, check if higher-kinded types would reduce code duplication. Relevant
for operations that should work uniformly across Result, Option, and Future.
Pattern: use Eq[A] type class to prevent comparing incompatible types.
| |
What’s happening here?
- This snippet is a minimal reference shape: use
Eq[A]type class to prevent comparing incompatible types. - In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.
Review note: When reviewing equality checks on newtypes or domain objects, suggest using Cats Eq to catch type
mismatches at compile time. Valuable for ID types like ConversationId, ModelName.
Pattern: structure implicit instances for discoverability and override control.
| |
What’s happening here?
- This snippet is a minimal reference shape: structure implicit instances for discoverability and override control.
- In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.
Implicit resolution priority (lowest to highest):
- Companion objects of involved types
- Imported scope
- Local scope (same block)
Review checklist:
- Default instances go in companion objects
- Alternative instances are in clearly named objects
- Tests can easily swap instances by importing
Pattern: combine independent effects in parallel using Semigroupal and mapN.
| |
What’s happening here?
- This snippet is a minimal reference shape: combine independent effects in parallel using
SemigroupalandmapN. - In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.
Review note: Use mapN when validations are independent. Use for-comprehension when later validations depend on
earlier results.
Pattern: typeclass instances that derive automatically for composite types.
| |
What’s happening here?
- This snippet is a minimal reference shape: typeclass instances that derive automatically for composite types.
- In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.
Review note: Check if recursive typeclass instances could reduce boilerplate when reviewing JSON serialization, tool schema generation, or configuration parsing. llm4s uses uPickle which provides this pattern for JSON.
| |
What’s happening here?
- This snippet is a minimal reference shape: typeclass instances that derive automatically for composite types.
- In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.
TL;DR
- Errors are values, not exceptions - Use
Result[A]or equivalent - Types prevent confusion -
ApiKeyandModelNameare different types - Config lives at the edge - Core code receives typed config, never reads env vars
- Immutability by default - State transitions return new states
- Exhaustive matching - No wildcard catches on sealed types
- Small, focused interfaces - No god traits with 20 methods
- Test both paths - Success and failure get equal attention
- Review severity matters - Blocker/Must-fix/Should-fix/Nit
- Rigor speeds delivery - 30-minute review prevents 15-hour incident
The goal isn’t perfect code. The goal is code that doesn’t wake you up at 2 AM.
References
llm4s code
llm4s - llm4s Large language models for Scala
Safety.scala - Error handling utilities
types/package.scala - Type system, newtypes, Result
LLMClient.scala - Core trait
Agent.scala - Agent implementation
Official documentation
- Scala 3 Reference - Language documentation
- Cats Library - Functional programming abstractions
Design patterns
- Zero Overhead Tree Processing with the Visitor Pattern - Li Haoyi’s original article on zero-allocation Visitor pattern
Online tutorials
Related reading
- Functional Programming in Scala - The red book
- Domain Modeling Made Functional - F# but principles apply
Appendix A: comment templates
A.1 Blocker template
| |
A.2 Must-fix template
| |
A.3 Should-fix template
| |
A.4 Question template
| |
Document maintenance
This document should be updated when:
- New patterns are established in the codebase
- Scalafix rules are added or changed
- New modules are added
- Security requirements change
- Cross-version strategy changes
Last reviewed: 2026-01-29
Appendix D: changelog
| Version | Date | Changes |
|---|---|---|
| 0.9 | 2026-01-30 | |
| 0.8 | 2026-01-30 | |
| 0.7 | 2026-01-30 | |
| 0.6 | 2026-01-30 | |
| 0.5 | 2026-01-30 | |
| 0.4 | 2026-01-29 | |
| 0.3 | 2026-01-29 | |
| 0.2 | 2026-01-29 | |
| 0.1 | 2026-01-29 |
