llm4s Master review guidelines
Version: 0.9 Last Updated: 2026-01-30 Purpose: Single source of truth for CI PR review bots and human reviewers
Table of contents
- Repo Map
- Review Principles for llm4s
- PR Review Workflow (CI Playbook)
- Code Review Checklist (llm4s specific)
- API and Design Review Checklist
- Architecture Review Checklist
- FP Patterns in llm4s
- Design Patterns in llm4s
- SOLID in llm4s
- Testing Strategy Review
- Documentation Review
- Build, Release, CI, DevOps Review
- Security and Dependency Hygiene
- Performance and Resource Safety
- Common PR Smells and How to Comment
- Mermaid Diagrams in Reviews
- Appendix A: Comment Templates
- Appendix B: llm4s Examples Index
- Appendix C: Advanced FP Patterns Reference
- Appendix D: Changelog
1. Repository map
1.1 Module structure
| |
1.2 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 |
1.3 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.
Version-specific code goes in src/main/scala-2.13 or src/main/scala-3.
1.4 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 |
2. Review principles for llm4s
2.1 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.
2.2 PR quality bar
A good PR 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
2.3 What to review rigorously
| Area | Review bar |
|---|---|
modules/core public APIs | Highest - treat as product |
modules/core internals | High - must follow patterns |
| Error handling changes | High - affects all consumers |
| Config changes | High - affects deployment |
| Security-relevant code | Highest - tool calling, secrets |
modules/samples | Medium - working examples |
modules/workspace | Medium - containerized runner |
3. PR review workflow (CI playbook)
3.1 CI review bot contract
When analyzing a PR, the CI bot MUST produce:
Required outputs
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
Mermaid diagram policy for CI bot
The CI bot MAY include Mermaid diagrams in comments when they materially improve clarity over plain text:
MUST:
- Include 1-2 line plain-text summary above the diagram
- Cite PR diff file path + line range
- Reference the relevant guideline section
- Keep diagrams small (<10 nodes)
- Use semantic color coding (see section 16)
- Validate diagram renders correctly in GitHub
MUST NOT:
- Use diagrams for trivial logic explainable in 1-2 sentences
- Create diagrams with >10 nodes (too large for PR comments)
- Post multiple diagrams in a single comment (overwhelming)
- Use diagrams to restate obvious code structure
Use diagrams for:
- Complex control flow with multiple error paths
- Unclear async/cancellation semantics (sequence diagrams)
- State machine transitions with missing cases
- Architecture boundary violations spanning multiple files
See Section 16: Mermaid Diagrams in Reviews for templates and examples.
3.2 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 |
3.3 Comment format
Every review comment MUST follow this format:
| |
3.4 Bot prompt template
| |
4. Code review checklist (llm4s specific)
4.1 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):
| |
4.2 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):
| |
4.3 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):
| |
4.4 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
Review aid: If control flow spans >5 cases or has unclear decision paths, consider requesting a Mermaid flowchart showing all branches (see section 16).
llm4s pattern (modules/core/src/main/scala/org/llm4s/llmconnect/provider/LLMProvider.scala:15-24):
| |
4.5 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)
4.6 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):
| |
5. API and design review checklist
5.1 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)
5.2 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):
| |
5.3 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):
| |
6. Architecture review checklist
6.1 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
6.2 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)
Review aid: If async/cancellation semantics are unclear across module boundaries, consider requesting a Mermaid sequence diagram showing call order and cleanup paths (see section 16).
6.3 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):
| |
6.4 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
7. FP patterns in llm4s
Advanced Patterns: See Appendix C for detailed coverage of advanced patterns including MonadError, Validated, Type Classes, Variance, and more.
7.1 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) |
7.2 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 |
7.3 Monad transformer usage
Use EitherT or OptionT only when nesting becomes unreadable:
| |
8. Design patterns in llm4s
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 and Cats patterns.
Philosophy: Use the simplest pattern that solves the problem. Prefer FP (ADTs, type classes, monads) for data transformation and composition. Use OOP (traits, classes) for stateful components and external integrations.
8.0 Unified pattern philosophy: How all patterns work together
llm4s uses a hybrid approach where SOLID Principles, Design Patterns (Creational, Structural, Behavioral), * FP Patterns*, Type Classes, Phantom Types, and Cats Patterns support and strengthen each other. This section shows how they integrate cohesively with 100% precision, using real llm4s code and textbook snippets where applicable.
8.0.1 The pattern spectrum
Patterns exist on a continuum. Use the appropriate paradigm based on the problem domain, with SOLID principles guiding all zones:
| |
Guidelines:
Pure FP (Left): Data transformations, validation, business logic, parsing
- Examples:
Result[A],Conversation,CompletionOptions, guardrails - Patterns: ADTs (C.1), Type Classes (C.4), Monads (C.11), Pattern Matching (C.2)
- SOLID alignment: SRP (pure functions do one thing), ISP (type classes as focused interfaces)
1 2 3 4 5 6 7 8// SRP: Each function has single responsibility def parseMessage(json: String): Result[Message] = ??? def validateMessage(msg: Message): Result[Message] = ??? def enrichMessage(msg: Message): Result[Message] = ??? // ISP: Type classes provide focused interfaces trait Show[A] { def show(a: A): String } // Only show, nothing else implicit val showMessage: Show[Message] = msg => msg.content- Examples:
Hybrid (Center): Object construction, resource management, external integrations
- Examples:
LLMClienttrait + concrete providers,Agent,ToolRegistry - Patterns: Factory + Smart Constructors, Builder + Phantom Types, Adapter + Type Classes
- SOLID alignment: All 5 principles apply (DIP for abstraction, OCP for extension, LSP for substitution)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15// DIP: Depend on LLMClient trait, not concrete OpenAIClient class Agent(client: LLMClient) { // ← abstraction, not concretion def run(query: String): Result[AgentState] = ??? } // OCP: Extend via new case, don't modify existing code sealed trait ProviderConfig case class OpenAIConfig(...) extends ProviderConfig case class AnthropicConfig(...) extends ProviderConfig // Adding GeminiConfig doesn't modify existing classes // LSP: All LLMClient implementations are substitutable val openAI: LLMClient = OpenAIClient(config) val anthropic: LLMClient = AnthropicClient(config) // Both work identically in Agent(client)- Examples:
Pure OOP (Right): Stateful systems, plugin architectures, legacy integrations
- Examples: Rare in llm4s - only when external APIs demand it
- Patterns: Use sparingly, wrap in
Resource[F, _]for safety - SOLID alignment: LSP (subtype substitution), DIP (depend on interfaces)
1 2 3 4 5 6 7 8 9 10// LSP: Subtypes must honor base type contracts trait ResourceManager { def acquire(): Unit def release(): Unit // MUST be called, else resource leak } class FileManager extends ResourceManager { def acquire(): Unit = openFile() def release(): Unit = closeFile() // Honors contract }
8.0.2 Pattern synergies: Combinations that strengthen each other
Each combination shows how Design Patterns, FP Patterns, Type Classes, Cats Patterns, and SOLID principles reinforce each other:
| Pattern Combination | SOLID Principle | Why They Work Together | llm4s Example / Code Snippet | Benefit |
|---|---|---|---|---|
| Factory + Smart Constructors + Result + DIP | DIP | Factory returns trait (abstraction), smart constructors validate, Result for errors | def buildClient(cfg: ProviderConfig): Result[LLMClient] - returns LLMClient trait, not concrete class | Type-safe creation, testable, swappable implementations |
| Builder + Phantom Types + SRP | SRP | Each builder method has one job; phantom types track state; types enforce correctness | class Builder[S <: State] { def withModel(m: ModelName): Builder[HasModel] } | Impossible states unrepresentable, each step single concern |
| Strategy + ADTs + Pattern Matching + OCP | OCP | Strategy as sealed trait; add new strategy = add new case; no modification needed | sealed trait ExecutionStrategy; case object Sequential extends ExecutionStrategy | Exhaustive checks, open for extension, closed for modification |
| Adapter + Type Classes + ISP | ISP | Type classes adapt types without inheritance; each type class is focused interface | trait Show[A] { def show(a: A): String }; implicit val showError: Show[LLMError] | Non-intrusive, focused interfaces, no fat interfaces |
| Decorator + HOFs + Kleisli + SRP | SRP | Each decorator does one thing; HOFs compose; Kleisli chains | val withRetry: Result[A] => Result[A]; val withLog: Result[A] => Result[A] | Composable, testable, no class explosion, single responsibility |
| Observer + fs2.Stream + Monads + OCP | OCP | Stream is open for new subscribers; monadic composition; no modification to source | agent.runWithEvents().evalMap(handleEvent) - add handlers without changing agent | Backpressure, resource safety, extensible |
| Composite + Recursive ADTs + Semigroup + LSP | LSP | Leaf and composite nodes both implement same interface; Semigroup combines | trait Guardrail; case class Leaf extends Guardrail; case class Composite(children: Seq[Guardrail]) | Type-safe trees, substitutable parts, algebraic combination |
| State + State Monad + for-comprehensions + SRP | SRP | Each state transformation is pure function with single job; State monad manages threading | val updateState: State[AgentState, Unit] = State.modify(s => s.addMessage(msg)) | Pure, testable, no mutation, clear responsibilities |
| Command + Free Monads + ADTs + OCP | OCP | Commands as ADT; new command = new case; interpreter separate from definition | sealed trait Command[A]; case class RunTool(name: String) extends Command[Result] | Interpreter pattern, multiple backends, extensible |
| Singleton + object + DIP | DIP | Use object for stateless utilities; if stateful, inject as dependency | object TokenCounter { def count(s: String): Int } - stateless OK; class Config - inject, don’t use singleton | Testable (can mock injected deps), no global state |
8.0.3 Pattern hierarchy: Dependencies and relationships
This diagram shows how SOLID principles connect to all major pattern families:
graph TB
%% SOLID as foundation
SOLID[SOLID Principles §9]
SRP[SRP: Single Responsibility]
OCP[OCP: Open/Closed]
LSP[LSP: Liskov Substitution]
ISP[ISP: Interface Segregation]
DIP[DIP: Dependency Inversion]
SOLID --> SRP
SOLID --> OCP
SOLID --> LSP
SOLID --> ISP
SOLID --> DIP
%% ADTs and Pattern Matching
ADT[ADTs C.1] --> PM[Pattern Matching C.2]
ADT --> SmartCtor[Smart Constructors C.3]
ADT --> Strategy[Strategy Pattern 8.4.1]
OCP -.-> ADT
OCP -.-> Strategy
%% Type Classes
TC[Type Classes C.4] --> Implicits[Implicits C.5]
TC --> Adapter[Adapter Pattern 8.3.1]
ISP -.-> TC
ISP -.-> Adapter
%% Phantom Types and Builders
Phantom[Phantom Types C.30] --> Builder[Builder Pattern 8.2.2]
Phantom --> TypeSafety[Compile-time Safety]
SRP -.-> Builder
%% Monads and Effect Types
Monads[Monads C.11] --> Result[Result Type]
Monads --> StateMonad[State Monad C.16]
Monads --> Kleisli[Kleisli C.15]
SRP -.-> StateMonad
%% Higher-Order Functions
HOF[Higher-Order Functions] --> Decorator[Decorator Pattern 8.3.2]
SRP -.-> Decorator
%% Factory and Abstraction
Factory[Factory Pattern 8.2.1] --> SmartCtor
DIP -.-> Factory
%% Composite and Inheritance
Composite[Composite Pattern 8.3.3]
LSP -.-> Composite
%% Cats Patterns
Cats[Cats Patterns] --> Validated[Validated C.12]
Cats --> EitherT[EitherT C.13]
Cats --> Semigroup[Semigroup C.17]
style SOLID fill: #f5e1ff, stroke:#8800cc, stroke-width: 3px
style SRP fill:#f5e1ff, stroke: #8800cc
style OCP fill:#f5e1ff, stroke: #8800cc
style LSP fill:#f5e1ff, stroke: #8800cc
style ISP fill:#f5e1ff, stroke: #8800cc
style DIP fill:#f5e1ff, stroke: #8800cc
style ADT fill:#e1f5ff, stroke: #0066cc
style TC fill:#e1ffe1, stroke: #00cc00
style Phantom fill:#fff5e1, stroke: #cc8800
style Monads fill:#ffe1f5, stroke: #cc0088
Key relationships:
- SRP → Builder (each step single concern), Decorator (each decorator one job), State Monad (pure transformations)
- OCP → ADTs (extend via new cases), Strategy (add strategies without modification)
- LSP → Composite (leaf/composite substitutable), all trait implementations
- ISP → Type Classes (focused interfaces), Adapter (minimal interface exposure)
- DIP → Factory (return abstractions), all dependency injection patterns
8.0.4 When to use which paradigm (with SOLID guidance)
Use Pure FP when:
- ✅ Transforming data (parsing, validation, serialization)
- ✅ Composing operations (pipelines, workflows)
- ✅ Testing is critical (pure functions are trivial to test)
- ✅ Immutability is required (concurrency, distributed systems)
- ✅ Error handling needs to be explicit (
Result,Validated)
SOLID principles in Pure FP:
- SRP: Each pure function does exactly one thing
- ISP: Type classes provide minimal, focused interfaces
Examples with SOLID:
| |
Use Hybrid (FP + OOP) when:
- ✅ Building complex objects (builders with phantom types)
- ✅ External integrations (wrap imperative APIs in functional interfaces)
- ✅ Polymorphic behavior (traits with ADT-based dispatch)
- ✅ Resource management (traits with
Resource[F, _]semantics)
SOLID principles in Hybrid:
- DIP: Depend on traits (abstractions), not concrete classes
- OCP: Extend via new implementations, don’t modify existing code
- LSP: All trait implementations must be substitutable
Examples with SOLID:
| |
Use Pure OOP when:
- ⚠️ External library demands it (rare in llm4s)
- ⚠️ Stateful plugin systems (wrap in
Resource[F, _]for safety) - ⚠️ Legacy integrations (create thin adapter, keep functional core)
SOLID principles in Pure OOP:
- LSP: Subclasses must honor superclass contracts (preconditions, postconditions, invariants)
- All 5: OOP benefits most from strict SOLID adherence
Example with SOLID:
| |
Anti-patterns (SOLID violations):
- ❌ Deep inheritance hierarchies (violates SRP, OCP, LSP)
- ❌ Mutable state without synchronization (violates SRP, hard to test)
- ❌ God objects with many responsibilities (violates SRP, ISP)
- ❌ Depending on concrete classes (violates DIP)
- ❌ Modifying existing code to add features (violates OCP)
8.0.5 Pattern composition principles (with SOLID integration)
Principle 1: Pure core, imperative shell (aligns with SRP, DIP)
Keep business logic pure (FP), push side effects to boundaries (OOP/hybrid). Each layer has single responsibility.
| |
Principle 2: Compose small patterns, not large frameworks (aligns with SRP, ISP, OCP)
Prefer small, composable patterns over large abstractions. Each component does one thing well.
| |
Principle 3: Use types to prevent errors, not just document them (aligns with LSP, OCP)
Phantom types, ADTs, and smart constructors make invalid states unrepresentable. Type system enforces correctness.
| |
Principle 4: Fail fast at compile time, recover gracefully at runtime (aligns with OCP, SRP)
Use types to catch errors early (compile-time); use Result/Validated to handle runtime failures gracefully.
| |
SOLID benefits across all principles:
- SRP: Each function/class has single, clear responsibility
- OCP: Extend via new types/functions, not modification
- LSP: Type-safe substitution (phantom types, trait implementations)
- ISP: Small, focused interfaces (HOFs, type classes) instead of fat interfaces
- DIP: Depend on abstractions (traits, ADTs), not concrete implementations
8.0.6 Pattern anti-synergies: Combinations to avoid (with SOLID violations)
| Pattern Combination | SOLID Violation | Why They Conflict | What to Do Instead | Code Example |
|---|---|---|---|---|
| Mutable State + Pure Functions | SRP | Breaks referential transparency, hard to test, mixed concerns | Use State monad (C.16) or Ref[F, A] | ❌ def add(x: Int): Int = { counter += 1; x + counter } → ✅ State.modify[Int](_ + 1) |
| Deep Inheritance + ADTs | LSP, SRP | ADTs can’t extend multiple sealed traits, fragile base class problem | Use composition, not inheritance | ❌ class A extends B with C → ✅ case class A(b: B, c: C) |
| Exceptions + Result | SRP | Mixing throw with Result breaks monad laws, unpredictable flow | Convert Try to Result at boundaries | ❌ def parse(s: String): Result[Int] = throw Ex → ✅ Try(s.toInt).toResult |
| Singleton + Dependency Injection | DIP | Global state makes testing hard, depends on concretion | Pass dependencies explicitly, use Reader (C.18) | ❌ object DB { var conn: Conn } → ✅ class Service(db: DB) |
| God Object + Many Responsibilities | SRP, ISP | One class does everything, hard to test, tight coupling | Split into focused classes/functions | ❌ class Agent { parse(); validate(); execute(); log(); ... } → ✅ Separate classes |
| Concrete Dependencies + Tight Coupling | DIP | Depends on concrete classes, hard to swap implementations | Depend on traits/abstractions | ❌ class Agent(client: OpenAIClient) → ✅ class Agent(client: LLMClient) |
| Modifying Existing Code + New Features | OCP | Every new feature requires changing existing code, brittle | Extend via new types/cases | ❌ Modify buildClient() → ✅ Add new case to sealed trait |
| Fat Interfaces + Unused Methods | ISP | Clients forced to implement methods they don’t use | Split into focused interfaces | ❌ trait Client { def complete(); stream(); embed(); ... } → ✅ Separate traits |
| Phantom Types + Runtime Reflection | N/A | Phantom types erased at runtime, can’t reflect on them | Use TypeTag if runtime type info needed | ❌ def check[S](b: Builder[S]): Boolean = ... → ✅ Use TypeTag |
| Free Monads + Performance-Critical Code | N/A | Free monads add overhead, interpretation cost | Use Tagless Final (C.20) or direct encoding | ❌ Free monad for hot path → ✅ Direct Result[A] |
| Type Classes + Inheritance | N/A | Type classes work via implicits, not subtyping; incompatible models | Choose one: type classes OR inheritance | ❌ trait Show extends Serializable → ✅ Separate concerns |
| Subclass Breaking Parent Contract | LSP | Subclass violates preconditions/postconditions of parent | Honor parent contracts or use composition | ❌ class BadList extends List { override def head = throw Ex } → ✅ Delegate |
8.0.7 Pattern review decision tree (with SOLID checkpoints)
Use this flowchart when reviewing code to decide which pattern to recommend. Each path includes relevant SOLID principles.
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 -->|Modifying Code| ApplyOCP[Violates OCP<br/>Extend, don't modify]
CheckSOLID -->|Fat Interface| ApplyISP[Violates ISP<br/>Split interfaces]
CheckSOLID -->|No Violations| Question1{Is it data<br/>transformation?}
Question1 -->|Yes| FP[Use Pure FP:<br/>ADTs C.1, Pattern Matching C.2,<br/>Monads C.11<br/><b>SOLID: SRP</b>]
Question1 -->|No| Question2{Is it object<br/>construction?}
Question2 -->|Yes| Question3{Complex validation<br/>or many params?}
Question3 -->|Yes| Builder[Use Builder + Phantom Types<br/>§8.2.2, C.30<br/><b>SOLID: SRP each step</b>]
Question3 -->|No|Factory[Use Factory + Smart Constructors<br/>§8.2.1, C.3<br/><b>SOLID: DIP return trait</b>]
Question2 -->|No|Question4{Is it external<br/>integration?}
Question4 -->|Yes|Question5{Adapting<br/>external types?}
Question5 -->|Yes|TypeClass[Use Type Classes<br/>§8.3.1, C.4<br/><b>SOLID: ISP focused interfaces</b>]
Question5 -->|No|Wrapper[Use Adapter Pattern<br/>+ Resource F, _<br/><b>SOLID: DIP wrap in abstraction</b>]
Question4 -->|No|Question6{Is it polymorphic<br/>behavior?}
Question6 -->|Yes|Question7{Fixed set<br/>of types?}
Question7 -->|Yes| ADT[Use ADTs + Pattern Matching<br/>§8.4.1, C.1, C.2<br/><b>SOLID: OCP add cases</b>]
Question7 -->|No|TraitDispatch[Use Trait + OOP Dispatch<br/>with SOLID §9<br/><b>SOLID: LSP substitution</b>]
Question6 -->|No|Question8{Is it composable<br/>operations?}
Question8 -->|Yes|HOF[Use Higher-Order Functions<br/>+ Kleisli C.15<br/><b>SOLID: SRP each function</b>]
Question8 -->|No|Question9{Is it state<br/>management?}
Question9 -->|Yes|StateMonad[Use State Monad<br/>C.16<br/><b>SOLID: SRP pure transformations</b>]
Question9 -->|No|Question10{Is it error<br/>handling?}
Question10 -->|Yes|Question11{Single error<br/>or multiple?}
Question11 -->|Single|ResultType[Use Result Type<br/>Either LLMError, A<br/><b>SOLID: SRP error handling</b>]
Question11 -->|Multiple|ValidatedNel[Use ValidatedNel<br/>C.12<br/><b>SOLID: SRP accumulate errors</b>]
Question10 -->|No|ReviewSOLID[Apply SOLID Principles<br/>§9 in depth]
style Start fill: #e1f5ff, stroke: #0066cc
style CheckSOLID fill: #f5e1ff, stroke: #8800cc, stroke-width:3px
style SplitSRP fill: #ffe1e1, stroke:#cc0000
style ApplyDIP fill: #ffe1e1, stroke:#cc0000
style ApplyOCP fill: #ffe1e1, stroke:#cc0000
style ApplyISP fill: #ffe1e1, stroke:#cc0000
style FP fill: #e1ffe1, stroke:#00cc00
style Builder fill: #fff5e1, stroke:#cc8800
style Factory fill: #fff5e1, stroke:#cc8800
style TypeClass fill: #ffe1f5, stroke:#cc0088
style ADT fill: #e1ffe1, stroke:#00cc00
style StateMonad fill: #ffe1f5, stroke:#cc0088
style ResultType fill: #ffe1f5, stroke:#cc0088
style ValidatedNel fill: #ffe1f5, stroke:#cc0088
style ReviewSOLID fill: #f5e1ff, stroke:#8800cc
How to use this tree:
- Start by checking SOLID violations - If code violates SRP, OCP, LSP, ISP, or DIP, address those first
- Follow the decision path - Each recommendation includes relevant SOLID principle
- Verify SOLID alignment - Ensure chosen pattern doesn’t introduce new violations
- Compose patterns - Many solutions combine multiple patterns (e.g., Factory + DIP, Builder + SRP)
8.0.8 Pattern maturity model: Learning progression (with SOLID integration)
Level 1: Beginner (Focus: Type safety, immutability, SOLID fundamentals)
- ✅ Master: ADTs (C.1), Pattern Matching (C.2), Smart Constructors (C.3)
- ✅ Master:
Resulttype,Option,Either - ✅ Master: Immutable collections,
copy()for updates - ✅ Master: SOLID: SRP and DIP - single responsibility functions, depend on traits
- 📖 Review: Factory pattern (§8.2.1), basic type classes (C.4), OCP basics
Code snippet (Beginner level):
| |
Level 2: Intermediate (Focus: Composition, abstraction, SOLID in practice)
- ✅ Master: Type Classes (C.4), Implicits (C.5), Higher-Order Functions
- ✅ Master: for-comprehensions (C.6), Monads (C.11)
- ✅ Master: Builder pattern + Phantom Types (§8.2.2, C.30)
- ✅ Master: SOLID: ISP and OCP - focused interfaces, extend without modification
- 📖 Review: Adapter/Decorator patterns (§8.3), Strategy pattern (§8.4.1), LSP
Code snippet (Intermediate level):
| |
Level 3: Advanced (Focus: Type-level programming, effect systems, SOLID mastery)
- ✅ Master: EitherT/OptionT (C.13), Semigroup/Monoid (C.17)
- ✅ Master: F-Bounded Polymorphism (C.31), Self Types (C.29)
- ✅ Master: Lazy Evaluation (C.28), Currying/PAFs (C.27)
- ✅ Master: All 5 SOLID principles - apply reflexively to all code
- 📖 Review: All design patterns (§8.2-8.4), Tagless Final (C.20), Free Monads
Code snippet (Advanced level):
| |
Level 4: Expert (Focus: Architecture, domain modeling, pattern synergy, SOLID as second nature)
- ✅ Master: Type Members (C.32), Opaque Types (C.33 - Scala 3)
- ✅ Master: Reader/Writer Monads (C.18, C.19), Traverse (C.21)
- ✅ Master: Combining all patterns cohesively (this section!)
- ✅ Master: SOLID violations detection - spot and refactor anti-patterns instantly
- 📖 Review: Advanced Cats patterns (C.22-C.26), multi-paradigm architecture, domain-driven design
Code snippet (Expert level):
| |
Review Guidance by Level (with SOLID focus):
Beginner PRs:
- ✅ Ensure ADTs, pattern matching, Result usage
- ✅ Check SRP - functions do one thing
- ✅ Check DIP - inject dependencies via constructors, depend on traits
- ⚠️ Suggest simpler alternatives if over-engineered
- ⚠️ Flag global state, mutable vars, exceptions
Intermediate PRs:
- ✅ Look for proper abstraction (type classes, monads)
- ✅ Ensure composition over inheritance
- ✅ Check ISP - interfaces are focused, not fat
- ✅ Check OCP - extending via new types, not modifying existing code
- ⚠️ Flag tight coupling, concrete dependencies
Advanced PRs:
- ✅ Verify type-level constraints work correctly
- ✅ Check for phantom type/F-bounded bugs
- ✅ Verify LSP - all implementations honor contracts
- ✅ Check all 5 SOLID principles applied correctly
- ⚠️ Flag LSP violations (subtype breaks parent contract)
Expert PRs:
- ✅ Focus on architecture coherence
- ✅ Ensure patterns support each other, not conflict (see §8.0.2, §8.0.6)
- ✅ Verify SOLID mastery - no violations, elegant solutions
- ✅ Check pattern synergies (Factory+DIP, Builder+SRP, etc.)
- ⚠️ Flag unnecessary complexity, over-engineering
8.1 Pattern landscape in llm4s
| Pattern Category | OOP Pattern | FP Alternative | llm4s Approach | SOLID Connection | Cats Pattern |
|---|---|---|---|---|---|
| Creational | Factory | Smart constructors + ADTs | Hybrid | DIP - depend on abstractions | N/A |
| Creational | Builder | Phantom types + currying | Hybrid with phantom types (C.30) | SRP - each step one concern | N/A |
| Creational | Singleton | object | Pure FP with object | N/A | N/A |
| Structural | Adapter | Type classes | FP with type classes (C.4) | ISP - focused interfaces | Type class instances |
| Structural | Decorator | Higher-order functions | FP with function composition | OCP - extend via composition | Kleisli (C.15) |
| Structural | Composite | Recursive ADTs | FP with sealed traits | N/A | Semigroup (C.17) |
| Behavioral | Strategy | ADT + pattern matching | FP with ADTs | OCP - new strategies via new cases | N/A |
| Behavioral | State | State monad | FP with State monad (C.16) | N/A | State monad |
| Behavioral | Observer | fs2.Stream / Callbacks | Hybrid - callbacks for simplicity | N/A | N/A |
| Behavioral | Command | ADT representing operations | FP with ADTs | N/A | Free monad (advanced) |
8.2 Creational patterns
8.2.1 Factory pattern: Smart constructors + ADTs
OOP approach (classic Factory):
| |
FP approach (Smart constructors + ADTs):
| |
llm4s hybrid approach (modules/core/src/main/scala/org/llm4s/llmconnect/LLMConnect.scala:10-46):
| |
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
8.2.2 Builder pattern: Phantom types vs currying
OOP approach (classic Builder):
| |
FP approach 1 (Phantom types for compile-time safety):
| |
FP approach 2 (Currying for step-by-step configuration):
| |
llm4s approach (modules/core/src/main/scala/org/llm4s/toolapi/ToolFunction.scala:101-119):
| |
When to use which:
- Phantom types: Complex builders with many steps, order matters (e.g., ToolBuilder, ClientBuilder)
- Currying: Simple configuration, partial application needed (e.g., factory presets)
- Case class with copy: Immutable config objects (e.g.,
CompletionOptions.copy(maxTokens = 100))
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)
8.2.3 Singleton pattern: object in Scala
OOP approach (Java singleton):
| |
Scala approach (object is already a singleton):
| |
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
8.3 Structural patterns
8.3.1 Adapter pattern: Type classes
OOP approach (classic Adapter):
| |
FP approach (Type classes - see C.4):
| |
llm4s hybrid approach:
| |
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 (see
Semigroup,Monoid)
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)
8.3.2 Decorator pattern: Higher-order functions vs traits
OOP approach (classic Decorator):
| |
FP approach (Higher-order functions + Kleisli):
| |
llm4s approach (Hybrid - traits for stateful, HOFs for stateless):
| |
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 (see C.15)
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
8.3.3 Composite pattern: Recursive ADTs + Semigroup
OOP approach (classic Composite):
| |
FP approach (Recursive ADTs + Semigroup):
| |
llm4s approach (modules/core/src/main/scala/org/llm4s/agent/guardrails/CompositeGuardrail.scala):
| |
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
8.4 Behavioral patterns
8.4.1 Strategy pattern: ADTs + pattern matching
OOP approach (classic Strategy):
| |
FP approach (ADTs + pattern matching):
| |
llm4s approach (modules/core/src/main/scala/org/llm4s/toolapi/ToolExecutionStrategy.scala +
ToolRegistry.scala:64-77):
| |
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
8.4.2 State pattern: State monad + ADTs
OOP approach (classic State):
| |
FP approach 1 (ADT for state, pure transitions):
| |
FP approach 2 (State monad - see C.16):
| |
llm4s approach (ADT + immutable state with copy):
| |
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
8.4.3 Observer pattern: Callbacks vs fs2.Stream
OOP approach (classic Observer):
| |
FP approach 1 (Callbacks - simple):
| |
FP approach 2 (fs2.Stream - reactive):
| |
llm4s approach (Callbacks for simplicity -
modules/core/src/main/scala/org/llm4s/agent/streaming/AgentEvent.scala):
| |
Why callbacks over full Observer pattern:
- Simplicity: No registration/unregistration boilerplate
- Type safety: Events are ADT, exhaustive matching enforced
- Composition: Can compose event handlers with function composition
- No mutable state: No observable/observer list to manage
When to use fs2.Stream:
- Complex event processing pipelines
- Backpressure management needed
- Integration with fs2-based systems
- Async/concurrent event streams
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)
8.4.4 Command pattern: ADTs as operations
OOP approach (classic Command):
| |
FP approach (ADTs - operations as data):
| |
llm4s approach (Tool calls as commands - modules/core/src/main/scala/org/llm4s/toolapi/ToolCallRequest.scala):
| |
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
8.5 Pattern selection guide
When reviewing code, ask:
| 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 |
8.6 Anti-patterns to avoid
- God object: Class with too many responsibilities (violates SRP) → Split into focused classes
- Anemic domain model: Objects with only getters/setters, no behavior → Add behavior to domain objects
- Feature envy: Method uses another class’s data more than its own → Move method to that class
- Primitive obsession: Using primitives instead of domain types → Use newtypes (ApiKey, ModelName)
- Pattern for pattern’s sake: Adding abstraction when 1 implementation → Wait until 3+ implementations
- Mutable builder in FP code: Java-style builders → Use phantom types or currying
- Null checks everywhere: Defensive nulls → Use Option or ADT
- Type checking with isInstanceOf: Runtime type checks → Use pattern matching on ADT
8.7 Patterns and SOLID connections
| Pattern | Primary SOLID Principle | Why |
|---|---|---|
| Factory | DIP - depend on LLMClient, not OpenAIClient | High-level code independent of concrete implementations |
| Builder | SRP - each step one concern | Separate construction from representation |
| Adapter | ISP - focused interfaces | Adapt to small, cohesive interfaces |
| Decorator | OCP - extend via composition | Add behavior without modifying original |
| Strategy | OCP - new strategies via new cases | Extend behavior by adding ADT cases |
| State | LSP - all states honor contract | Each state substitutable for State interface |
9. SOLID in llm4s
SOLID principles prevent common design mistakes that make code brittle, hard to test, and difficult to extend. This section shows how llm4s applies SOLID and how to review for violations.
9.1 Single Responsibility Principle (SRP)
Principle: Each class should have one reason to change (one responsibility).
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
Good example (modules/core/src/main/scala/org/llm4s/toolapi/ToolRegistry.scala):
| |
Bad example (violation):
| |
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
How to fix: Extract classes by responsibility. Example:
| |
9.2 Open/Closed Principle (OCP)
Principle: Open for extension, closed for modification. Add new behavior without changing existing code.
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
Good example (modules/core/src/main/scala/org/llm4s/llmconnect/LLMConnect.scala:10-26):
| |
Bad example (violation):
| |
llm4s approach:
| Component | Closed (Stable) | Open (Extensible) |
|---|---|---|
LLMClient trait | ✅ API stable | ✅ New implementations welcome |
LLMProvider sealed trait | ✅ Known set | ❌ Intentionally closed |
ToolFunction trait | ✅ API stable | ✅ New tools via new implementations |
Guardrail trait | ✅ API stable | ✅ New guardrails composable |
Review question: “Can I add a new provider/tool/guardrail without editing existing classes?” → Should be YES.
9.3 Liskov Substitution Principle (LSP)
Principle: Subtypes must be substitutable for their base types without breaking behavior.
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 LSP means for llm4s:
| |
Review questions:
Error semantics: Does subtype return
Result[A]or does it throw?- ✅ Good: All paths return
Result - ❌ Bad: Some paths throw exceptions
- ✅ Good: All paths return
Side effects: Does subtype have side effects base type doesn’t?
- ✅ Good: Pure transformation, logging only
- ❌ Bad: Mutates input, modifies global state
Preconditions: Does subtype require more than base type?
- ✅ Good: Accepts all valid inputs base type accepts
- ❌ Bad: Rejects inputs base type would accept
Postconditions: Does subtype guarantee less than base type?
- ✅ Good: Returns all fields base type promises
- ❌ Bad: Returns partial data or different structure
Real-world review scenario:
| |
9.4 Interface Segregation Principle (ISP)
Principle: Clients shouldn’t depend on methods they don’t use. Prefer small, focused interfaces.
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
Good example (modules/core/src/main/scala/org/llm4s/llmconnect/LLMClient.scala):
| |
Bad example (violation):
| |
How to fix: Split into focused traits:
| |
Review questions:
- Are there methods that throw
NotImplementedError? → Violates ISP - Does the trait have >10 abstract methods? → Likely too broad
- Can implementations be grouped by capabilities? → Split into separate traits
9.5 Dependency Inversion Principle (DIP)
Principle: High-level modules depend on abstractions, not concrete implementations. Abstractions shouldn’t depend on details.
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
Good example (modules/core/src/main/scala/org/llm4s/agent/Agent.scala):
| |
Dependency flow (correct):
| |
Bad example (violation):
| |
How to fix:
| |
Review questions:
Dependency direction: Does core depend on infrastructure, or vice versa?
- ✅ Good: Core defines traits, infrastructure implements them
- ❌ Bad: Core imports concrete infrastructure classes
Creation location: Where are concrete objects created?
- ✅ Good: Factories at edges (
LLMConnect.getClient) - ❌ Bad:
new ConcreteClassin business logic
- ✅ Good: Factories at edges (
Configuration: Where is config read?
- ✅ Good:
Llm4sConfigat app edges, injected as typed objects - ❌ Bad:
sys.env("KEY")in core classes
- ✅ Good:
Dependency inversion in llm4s architecture:
| |
Anti-pattern: Layers depending downwards:
| |
Correct: Layers depending on abstractions:
| |
10. Testing strategy review
10.1 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)
10.2 Test patterns
Naming: class FooSpec for class Foo, using ScalaTest AnyFlatSpec with Matchers.
| |
10.3 Property-based tests
Strongly recommended for:
- Serialization round-trips
- Parser correctness
- Tool schema validation
10.4 Cross-version tests
- Tests under
modules/crossTest/scala2andmodules/crossTest/scala3 - Run with
sbt testCross - Ensure behavior matches between versions
10.5 Coverage requirements
- Minimum 50% statement coverage (enforced by scoverage)
- Critical paths (error handling, security) should have higher coverage
- Coverage excludes: samples, workspace, runner
11. Documentation review
11.1 Code documentation
- Public APIs have ScalaDoc
- Complex algorithms have inline comments explaining “why”
- No comments explaining obvious “what”
- Examples in ScalaDoc actually compile
11.2 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
- Introduces advanced type patterns (phantom types, tagless final)
ADRs go in docs/adr/ with template from guidelines.
11.3 README updates
- User-facing features documented in README
- Breaking changes noted in CHANGELOG
- New samples added for new features
- Environment variables documented if added
12. Build, release, CI, DevOps review
12.1 Build requirements
-
sbt +compilesucceeds (both Scala versions) -
sbt +testsucceeds -
sbt scalafmtCheckAllpasses - No new compiler warnings introduced
12.2 Dependency changes
- New dependencies justified (not just “nice to have”)
- License compatible with MIT
- No transitive dependency conflicts
- Cross-published for both Scala versions
12.3 Version compatibility
- MiMa checks pass or exception justified
- Deprecations have migration path
- Breaking changes require version bump
13. Security and dependency hygiene
13.1 Secret handling
- API keys never logged
- Secrets redacted in error messages
-
ApiKey,JwtToken,OAuthTokentypes have safetoString - No secrets in stack traces
13.2 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
13.3 Resource limits
- Token budgets enforced
- Max tool calls per request
- Timeouts on all external calls
- Max payload sizes
- Retry limits with backoff
13.4 Tool calling security
- Strict schemas for tool inputs
- Allowlists for outbound network calls
- Audit logging of tool invocations
- Idempotent where possible
14. Performance and resource safety
14.1 Performance review checklist
- Hot paths identified before optimization
- Benchmarks provided for performance claims (JMH preferred)
- Tradeoffs explicitly documented (readability vs speed)
- No premature optimization
14.2 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
14.3 Concurrency safety
- No blocking on global ExecutionContext
- Blocking isolated to dedicated thread pool
- No
Await.resultorThread.sleepin core - Shared mutable state has explicit synchronization
14.4 Resource leaks
- Connections properly closed
- File handles released
- Thread pools shut down
- Cleanup in error paths
15. Common PR smells and How to comment
15.1 Smell: Null checks
Bad:
| |
Comment:
| |
Location: path/to/file.scala:42
15.2 Smell: Exception in domain logic
Bad:
| |
Comment:
| |
Location: path/to/file.scala:15-18
15.3 Smell: Direct config read in core
Bad:
| |
Comment:
| |
Location: path/to/file.scala:10
15.4 Smell: Non-exhaustive match
Bad:
| |
Comment:
| |
Location: path/to/file.scala:55-58
15.5 Smell: Blocking on global EC
Bad:
| |
Comment:
| |
Location: path/to/file.scala:78
15.6 Smell: Missing error cause
Bad:
| |
Comment:
| |
15.7 Smell: Fail-fast when accumulation needed
Bad:
| |
Comment:
[Should-fix] Use Validated for multi-field validation
Guideline: Appendix C - Validated for Error Accumulation
Issue: For-comprehension short-circuits on first error. Users must fix errors one by one instead of seeing all problems at once.
Suggestion: Use Validated to accumulate all errors:
| |
Location: path/to/file.scala:15-19
15.8 Smell: Unsafe partial function
Bad:
| |
Comment:
[Must-fix] Unguarded partial function can throw MatchError
Guideline: Appendix C - Partial Function Safety
Issue: If an unexpected event type appears, this throws a MatchError at runtime.
Suggestion: Use .lift to safely return Option, or add explicit fallback:
| |
Location: path/to/file.scala:42-45
15.9 Smell: Comparing newtypes with ==
Bad:
| |
Comment:
[Should-Fix] Use typed comparison for newtypes
Guideline: Appendix C - Cats Eq for Type-Safe Equality
Issue: Comparing ConversationId.toString with a raw String bypasses type safety. Could compare wrong ID types.
Suggestion: Parse to typed ID first, use typed comparison:
| |
Location: path/to/file.scala:28
16. Mermaid diagrams in reviews
GitHub renders Mermaid diagrams in PRs, issues, and markdown files. Use them strategically to clarify complex logic when plain text isn’t sufficient.
16.1 When to use Mermaid
Use diagrams to explain:
- Control flow complexity: Nested pattern matches, multiple error paths, decision trees with 3+ branches
- Architecture boundaries: Module dependencies, trait hierarchies, provider selection logic
- Sequence of events: Async operations, tool call flows, handoff sequences, retry/backoff logic
- State machines: AgentStatus transitions, conversation lifecycle, connection states
- Error propagation: How errors flow through Result chains, where recovery happens
Example scenarios: PR adds complex validation with multiple error paths → use flowchart. PR changes agent execution flow → use sequence diagram. PR refactors provider selection → use flowchart showing match cases.
16.2 When NOT to use Mermaid
Skip diagrams for:
- Trivial logic that fits in 1-2 sentences
- Restating code: Don’t just translate code to diagram form
- Large diagrams: >10 nodes become unreadable in PR comments
- Simple linear flows: “A calls B calls C” doesn’t need a diagram
Rule: If you can explain it clearly in 3 sentences, don’t diagram it.
16.3 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
- Always cite file path and line range
16.4 Templates
Flowchart template
| |
Suggestion: Extract validation to separate function.
Location: path/to/file.scala:42-68
| |
Suggestion: Use ManagedResource for connection cleanup on cancellation.
Location: modules/core/.../ToolRegistry.scala:85-120
| |
Suggestion: Add explicit transitions or document why they’re invalid.
Location: modules/core/.../AgentState.scala:28-45
| |
Use tilde for generics:
classDiagram
class Kleisli~F,A, B~
Color standards (always use all three):
| |
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 |
Appendix A: Comment templates
A.1 Blocker template
| |
A.2 Must-fix template
[Must-Fix] {brief title}
Guideline: {Section Name}
Issue: {What is wrong}
Suggestion:
| |
Location: {file}:{lines}
A.3 Should-fix template
| |
A.4 Question template
| |
Appendix B: llm4s examples Index
B.1 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 |
B.2 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 |
B.3 ADTs and pattern matching
| Pattern | File | Lines |
|---|---|---|
| LLMProvider sealed ADT | modules/core/src/main/scala/org/llm4s/llmconnect/provider/LLMProvider.scala | 12-107 |
| ToolCallError ADT | modules/core/src/main/scala/org/llm4s/toolapi/ToolCallError.scala | (entire file) |
| AgentStatus ADT | modules/core/src/main/scala/org/llm4s/agent/AgentState.scala | (AgentStatus section) |
B.4 Factory pattern
| Pattern | File | Lines |
|---|---|---|
| LLMConnect factory | modules/core/src/main/scala/org/llm4s/llmconnect/LLMConnect.scala | 10-46 |
| ToolRegistry.empty | modules/core/src/main/scala/org/llm4s/toolapi/ToolRegistry.scala | 138-143 |
B.5 Builder pattern
| Pattern | File | Lines |
|---|---|---|
| ToolBuilder | modules/core/src/main/scala/org/llm4s/toolapi/ToolFunction.scala | 101-119 |
B.6 Resource management
| Pattern | File | Lines |
|---|---|---|
| ManagedResource | modules/core/src/main/scala/org/llm4s/resource/ManagedResource.scala | 11-45 |
| fileInputStream | modules/core/src/main/scala/org/llm4s/resource/ManagedResource.scala | 60-64 |
B.7 Strategy pattern
| Pattern | File | Lines |
|---|---|---|
| ToolExecutionStrategy | modules/core/src/main/scala/org/llm4s/toolapi/ToolExecutionStrategy.scala | (entire file) |
| executeAll dispatch | modules/core/src/main/scala/org/llm4s/toolapi/ToolRegistry.scala | 64-77 |
B.8 Guardrails
| Pattern | File | Lines |
|---|---|---|
| Guardrail trait | modules/core/src/main/scala/org/llm4s/agent/guardrails/Guardrail.scala | 14-47 |
| InputGuardrail | modules/core/src/main/scala/org/llm4s/agent/guardrails/Guardrail.scala | 57-67 |
| CompositeGuardrail | modules/core/src/main/scala/org/llm4s/agent/guardrails/CompositeGuardrail.scala | (entire file) |
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 C: Advanced FP patterns reference
This appendix documents advanced functional programming patterns learned from studying Scala best practices. These patterns are applicable to llm4s code review and should inform both implementation and review decisions.
C.1 MonadError and ApplicativeError
Pattern: Unified error handling across effect types using Cats MonadError[F, E].
Why It Matters in Review:
- Ensures consistent error handling regardless of whether code uses
Try,Either,Future, orValidated - Enables generic error recovery code that works across effect types
Scala 2.13 Example (adapted from cats-course):
| |
Applicability to llm4s: While llm4s uses Result[A] = Either[LLMError, A] directly, reviewers should understand
that
MonadError operations like ensure can be used for validation in a more composable way. Consider when reviewing
validation code.
C.2 Validated for error accumulation
Pattern: Use Validated[E, A] with Semigroup[E] to accumulate multiple errors instead of fail-fast.
Why It Matters in Review:
- Unlike
Eitherwhich short-circuits on first error,Validatedcollects all errors - Essential for form validation, config validation, and guardrail results
Scala 2.13 Example (adapted from cats-course):
| |
Applicability to llm4s: Review guardrail implementations to ensure they use ValidatedNec when multiple validations
should report all failures. The existing guidelines mention this; this pattern shows how to implement it.
C.3 EitherT monad transformer
Pattern: Flatten nested F[Either[E, A]] structures using EitherT.
Why it matters in review:
- Reduces nesting in async code that can fail
- Makes error handling explicit while keeping code readable
Scala 2.13 example (adapted from cats-course):
| |
Applicability to llm4s: Section 7.3 already covers this. When reviewing code with nested Future[Result[A]],
suggest
EitherT if nesting makes the code hard to follow.
C.4 Type class pattern (4-Part recipe)
Pattern: Structured approach to implementing type classes for extensibility.
Why it matters in review:
- Ensures type classes are implemented consistently
- Enables extending functionality to new types without modifying existing code
- Type-safe alternative to inheritance for ad-hoc polymorphism
Scala 2.13 Recipe (adapted from scala-2-advanced):
| |
Scala 3 equivalent:
| |
Applicability to llm4s: When reviewing code that needs extensibility (e.g., custom serializers, validators, formatters), check if the type class pattern would be more appropriate than inheritance. Ensure instances are organized properly (companion objects for defaults, separate objects for alternatives).
C.5 Partial function safety
Pattern: Use .lift to safely handle partial functions.
Why it matters in review:
- Partial functions throw
MatchErroron undefined inputs .liftconverts toOption, enabling safe composition
Scala 2.13 example (adapted from scala-2-advanced):
| |
Applicability to llm4s: When reviewing code that uses partial functions (especially in collect, case chains in
message handlers), ensure unhandled cases don’t cause runtime exceptions. Prefer .lift or explicit fallbacks.
C.6 Variance rules for API design
Pattern: Covariance (+T) for producers/collections, contravariance (-T) for consumers/actions.
Why it matters in review:
- Correct variance enables type-safe substitution
- Wrong variance causes compile errors or requires unsafe casts
Rules:
| |
Applicability to llm4s: When reviewing generic types (especially public APIs), verify variance annotations match usage patterns. Common mistakes: making mutable state covariant, or making callback parameters invariant when they could be contravariant.
C.7 Reader monad for dependency injection
Pattern: Encode dependencies as function arguments using Reader[Config, A].
Why it matters in review:
- Pure functional alternative to constructor injection
- Enables deferred dependency resolution and composition
Scala 2.13 example:
| |
Applicability to llm4s: llm4s uses constructor injection (which is simpler and more explicit). However, understanding Reader helps when reviewing code that composes multiple config-dependent operations. Consider Reader when:
- 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
C.8 Writer monad for logging
Pattern: Accumulate logs alongside computations without side effects.
Why it matters in review:
- Thread-safe logging (each computation has its own log)
- Logs can be tested and inspected
- Pure functions remain pure
Scala 2.13 example (adapted from cats-course):
| |
Applicability to llm4s: When reviewing tracing or audit logging code, consider whether Writer would provide better isolation than shared mutable state. Particularly useful in concurrent tool execution where logs from different tools shouldn’t interleave unpredictably.
C.9 Custom extractors (unapply)
Pattern: Add pattern matching support to any type via unapply methods.
Why it matters in review:
- Enables pattern matching on types you don’t control
- Boolean extractors simplify guard patterns
Scala 3 example:
| |
Applicability to llm4s: When reviewing pattern matching code, check if custom extractors would simplify complex guards or enable matching on wrapped types (like matching directly on JSON structures).
C.10 Higher-Kinded types for generic abstractions
Pattern: Abstract over type constructors to write generic code.
Why it matters in review:
- Enables single implementation that works with List, Option, Future, etc.
- Foundation for type class patterns
Scala 2.13 example:
| |
Applicability to llm4s: When reviewing generic utilities, check if higher-kinded types would reduce code
duplication.
This is particularly relevant for operations that should work uniformly across Result, Option, and Future.
C.11 Cats Eq for type-safe equality
Pattern: Use Eq[A] type class to prevent comparing incompatible types.
Why it matters in review:
==compares any two objects (compiles but may be wrong)===from Cats only compiles if types match
Scala 2.13 example (adapted from cats-course):
| |
Applicability to llm4s: When reviewing equality checks on newtypes or domain objects, suggest using Cats Eq to
catch type mismatches at compile time. Particularly valuable for ID types like ConversationId, ModelName.
C.12 Implicit/Given organization
Pattern: Structure implicit instances for discoverability and override control.
Why it matters in review:
- Unorganized implicits cause ambiguity errors
- Wrong scope makes testing difficult
Scala 2.13 pattern:
| |
Implicit resolution priority (lowest to highest):
- Companion objects of involved types
- Imported scope
- Local scope (same block)
Applicability to llm4s: When reviewing implicit definitions, ensure:
- Default instances go in companion objects
- Alternative instances are in clearly named objects
- Tests can easily swap instances by importing
C.13 Semigroupal and parallel composition (mapN)
Pattern: Combine independent effects in parallel using Semigroupal and mapN.
Why it Matters in review:
- Unlike
flatMap(sequential),mapNruns effects independently - Essential for combining validations that don’t depend on each other
- With
Validated, accumulates all errors; withEither, short-circuits
Scala 2.13 example:
| |
Applicability to llm4s: When reviewing validation code, check if mapN would be more appropriate than
for-comprehensions. Use mapN when validations are independent; use for-comprehension when later validations
depend on earlier results.
C.14 Traverse and sequence
Pattern: Convert List[F[A]] to F[List[A]] using traverse or sequence.
Why it matters in review:
- Essential for running multiple async operations and collecting results
- More concise than manual folding with for-comprehensions
- Works with any
Applicative(Future, Option, Either, Validated)
Scala 2.13 example:
| |
Applicability to llm4s: Tool execution often involves running multiple operations and collecting results.
When reviewing code that converts List[Future[Result[A]]] to Future[List[Result[A]]], suggest using
traverse or sequence instead of manual folding.
C.15 Kleisli for function composition with effects
Pattern: Compose functions that return effects (A => F[B]) using Kleisli.
Why it matters in review:
- Enables composing validation/transformation pipelines
- Avoids deeply nested flatMaps
- Reader monad is actually
Kleisli[Id, Config, A]
Scala 2.13 example:
| |
Applicability to llm4s: When reviewing middleware chains, validation pipelines, or guardrail composition,
consider if Kleisli would make the composition cleaner. Particularly useful when composing A => Result[B]
functions.
C.16 State monad for pure state management
Pattern: Manage state transformations purely using State[S, A].
Why it matters in review:
- Encapsulates state changes as pure functions
- Makes state transitions explicit and testable
- Composes via for-comprehensions
Scala 2.13 example:
| |
Applicability to llm4s: While llm4s uses AgentState.copy() for state management (simpler and sufficient),
understanding State monad helps when reviewing complex state transformations. Consider State when state changes
need to be composed, logged, or tested in isolation.
C.17 Semigroup and the |+| operator
Pattern: Combine values of the same type using Semigroup and |+|.
Why it matters in review:
- Defines how to merge/combine instances
- Works with any type that has a sensible combine operation
- Essential for aggregating results, merging configs
Scala 2.13 example:
| |
Applicability to llm4s: When reviewing code that merges configurations, aggregates metrics, or combines
partial results, check if a Semigroup instance would make the code cleaner. The |+| operator is more
expressive than custom merge functions.
C.18 Eval for lazy and memoized computation
Pattern: Control evaluation timing and memoization with Eval.
Why it matters in review:
Eval.now- evaluated immediately (like val)Eval.later- evaluated lazily, memoized (like lazy val)Eval.always- evaluated lazily, every time (like def)- Stack-safe recursion via
Eval.defer
Scala 2.13 example:
| |
Applicability to llm4s: When reviewing code with expensive computations that may not always be needed,
consider Eval.later for memoization. For recursive algorithms on large inputs, Eval.defer provides
stack safety. Useful for lazy config loading, deferred prompt generation, or recursive tool expansions.
Hands-on Scala patterns
The following patterns are inspired by Hands-on Scala Programming by Li Haoyi. These emphasize practical, production-ready patterns that complement the more theoretical patterns above.
C.19 Loan pattern / context managers
Pattern: Resource acquisition with guaranteed cleanup using higher-order functions.
Why it matters in review:
- Ensures resources are always released, even on exceptions
- More composable than try-finally blocks spread throughout code
- Foundation for llm4s’s
ManagedResourcepattern
Scala 2.13 example:
| |
Applicability to llm4s: llm4s uses ManagedResource for this pattern. When reviewing code that opens connections,
files, or HTTP clients, ensure the loan pattern or ManagedResource.use is used for cleanup. Bare try-finally should
be encapsulated in a reusable loan function.
C.20 Recursive typeclass inference
Pattern: Typeclass instances that derive automatically for composite types.
Why it matters in review:
- Enables parsing/serialization of arbitrarily nested structures
- Reduces boilerplate for new types
- Must be designed carefully to avoid ambiguous implicits
Scala 2.13 Example:
| |
Applicability to llm4s: When reviewing JSON serialization, tool schema generation, or configuration parsing, check if recursive typeclass instances could reduce boilerplate. llm4s uses uPickle which provides this pattern for JSON.
C.21 Exponential backoff retry
Pattern: Retry operations with configurable exponential delay.
Why it matters in review:
- Essential for resilient network operations
- Prevents thundering herd on service recovery
- Must have max retries to avoid infinite loops
Scala 2.13 example:
| |
Applicability to llm4s: LLM API calls should use retry with backoff. When reviewing HTTP client code or provider implementations, ensure:
- Maximum retry count is bounded
- Delay increases exponentially (or at least linearly)
- Transient errors (429, 503) are distinguished from permanent errors (400, 401)
- The retry mechanism logs attempts for debugging
C.22 Parallel execution with dedicated thread pool
Pattern: Execute CPU-bound work in parallel using a fixed-size thread pool.
Why it matters in review:
- Avoids blocking the global ExecutionContext
- Provides predictable parallelism
- Essential for CPU-bound operations like hashing
Scala 2.13 example:
| |
Applicability to llm4s: When reviewing code that processes files, computes embeddings, or performs other CPU-bound
operations, ensure it uses a dedicated ExecutionContext rather than scala.concurrent.ExecutionContext.global. The
global context is designed for non-blocking I/O, not CPU-bound work.
C.23 Throttled async operations
Pattern: Limit concurrent async operations to avoid overwhelming resources.
Why it matters in review:
- Prevents connection exhaustion
- Respects rate limits
- Maintains system stability under load
Scala 2.13 example:
| |
Applicability to llm4s: Tool execution, embedding generation, and multi-agent workflows may need throttling. When reviewing concurrent code, check:
- Is there a maximum concurrency limit?
- Does the code respect external rate limits (LLM API quotas)?
- Is backpressure handled (don’t spawn unlimited futures)?
C.24 Synchronized state for concurrent access
Pattern: Proper synchronization when mutable state is shared across threads.
Why it matters in review:
- Prevents race conditions
- Ensures visibility of updates
- Critical for websocket/streaming handlers
Scala 2.13 example:
| |
Applicability to llm4s: Streaming responses, websocket handlers, and agent state updates require careful synchronization. When reviewing code with shared mutable state:
- 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?
C.25 Sealed trait RPC protocol
Pattern: Define network protocols as sealed ADTs for type-safe communication.
Why it matters in review:
- Exhaustive matching ensures all messages handled
- Adding new message types is compile-time safe
- Serialization can be derived automatically
Scala 2.13 example:
| |
Applicability to llm4s: Tool call requests, agent messages, and provider responses should use sealed ADTs. When reviewing message/protocol definitions:
- 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)?
C.26 Tuple pattern matching for state comparison
Pattern: Use tuple patterns to handle state transition combinations concisely.
Why it matters in review:
- Makes all state combinations explicit
- Compiler warns about missing cases
- Self-documenting state transitions
Scala 2.13 example:
| |
Applicability to llm4s: State machines (AgentStatus transitions), file operations, and diff/sync logic benefit from tuple patterns. When reviewing state transition code:
- Are all state combinations handled?
- Is the
case _fallback intentional and documented? - Would an explicit enum of transitions be clearer?
C.27 Currying and partially applied functions
Pattern: Transform multi-parameter functions into chains of single-parameter functions for flexible API design.
Why it matters in review:
- Enables partial application for reusable function configurations
- Cleaner API design for functions with many parameters
- Natural fit for creating specialized versions of generic operations
Scala 2.13 example (from scala-3-advanced):
| |
Applicability to llm4s: When reviewing API design, especially tool builders and configuration functions:
- Tool execution strategies with multiple config params → curry for preset strategies
- Validation functions that share common predicates → partially apply shared logic
- Factory methods with many optional params → curry to create specialized constructors
Example in llm4s context:
| |
C.28 Lazy evaluation patterns
Pattern: Defer computation until needed using lazy val, call-by-name parameters, and memoization.
Why it matters in review:
- Prevents unnecessary computation in hot paths
- Enables safe circular dependencies
- Trade-off: adds complexity, can hide performance characteristics
Scala 2.13 example (from scala-3-advanced):
| |
Applicability to llm4s: When reviewing initialization code, caching, and performance-sensitive paths:
- Config loading → lazy val for expensive validation
- Tool execution → delay argument evaluation until tool is called
- Prompt templates → don’t interpolate until needed
- Connection pools → lazy initialization, eager cleanup
Current usage in llm4s (found 7 instances):
| |
Anti-pattern to watch for:
| |
C.29 Self types for trait composition
Pattern: Use self types to declare dependencies between traits without inheritance.
Why it matters in review:
- Makes trait dependencies explicit at compile time
- “Requires a” relationship instead of “is a” (cleaner than inheritance)
- Enables dependency injection patterns (cake pattern)
Scala 2.13 example (from scala-3-advanced):
| |
Cake pattern for dependency injection:
| |
Applicability to llm4s: When reviewing trait design and module dependencies:
- Agent components that need LLMClient → self-type instead of passing in constructor
- Guardrails that require config → self-type on ConfigProvider trait
- Tool implementations that need logging/tracing → self-type on TracingComponent
When NOT to use: Don’t use self-types just to avoid constructor parameters. Constructor injection is simpler and more explicit for most cases.
C.30 Phantom types for compile-time safety
Pattern: Use type parameters that exist only at compile time to encode state or constraints in the type system.
Why it matters in review:
- Prevents invalid state transitions at compile time
- Enforces correct API usage without runtime overhead
- Common in builder patterns and state machines
Scala 2.13 example:
| |
Applicability to llm4s: When reviewing complex builders or stateful APIs:
- ToolBuilder → ensure schema is set before function
- Agent construction → ensure LLMClient configured before tools
- Conversation lifecycle → prevent operations on closed conversations
Trade-off: Adds type complexity. Only use when compile-time safety significantly reduces bugs.
C.31 F-bounded polymorphism for type-safe composition
Pattern: Recursive type bounds A <: Animal[A] to ensure methods return the correct concrete type.
Why it matters in review:
- Prevents mixing incompatible types in hierarchies
- Common in ORMs, builders, and fluent APIs
- Combine with self-types for maximum safety
Scala 2.13 example (from scala-3-advanced):
| |
Applicability to llm4s: When reviewing fluent APIs and type hierarchies:
- Provider configs that return their own type in
withXmethods - Tool builders that should return the same builder type
- Guardrails that compose and return their type
Example for llm4s:
| |
C.32 Type members and path-dependent types
Pattern: Use type members instead of type parameters for more flexible type relationships.
Why it matters in review:
- Type members can be abstract and refined in subclasses
- Path-dependent types (
outer.Inner) enable type-safe relationships - Useful for deeply nested type hierarchies
Scala 2.13 example (from scala-3-advanced):
| |
Applicability to llm4s: When reviewing type hierarchies and provider abstractions:
- Provider responses with provider-specific metadata → type member for metadata type
- Tool schemas with tool-specific validation → type member for schema type
- Conversation state with provider-specific extensions → type member for state type
Example for llm4s:
| |
When to use type members vs type parameters:
- Use type parameters: When type is known at call site (more common)
- Use type members: When type is defined by implementation, not caller
C.33 Opaque types (Scala 3)
Pattern: Create zero-cost wrapper types that enforce abstraction boundaries without runtime overhead.
Why it matters in review:
- Better than
AnyValwrappers (no boxing, supports inheritance) - Enforces API boundaries at compile time, erased at runtime
- Zero performance cost compared to using raw types
Scala 3 example (from scala-3-advanced):
| |
Opaque types with bounds:
| |
Applicability to llm4s: When llm4s migrates to Scala 3 only:
- Replace
AnyValnewtypes (ApiKey, ModelName, ConversationId) with opaque types - Better performance (no boxing) and safer API (controlled surface)
- Opaque types support inheritance,
AnyValdoesn’t
Migration path:
| |
Review consideration: When reviewing type design in cross-compiled code:
- Keep using
AnyValfor now (Scala 2.13 + 3.x) - Document migration to opaque types when dropping Scala 2.13 support
- Don’t mix opaque types with
AnyVal(causes confusion)
Appendix D: Changelog
| Version | Date | Changes |
|---|---|---|
| 0.9 | 2026-01-30 | Comprehensive refactor of section 8.0 Unified Pattern Philosophy integrating SOLID principles throughout all subsections with code snippets, pattern synergies table with SOLID column, enhanced decision tree with SOLID checkpoints, and maturity model with SOLID progression. |
| 0.8 | 2026-01-30 | Added section 8.0 Unified Pattern Philosophy showing how Design Patterns, FP Patterns, Type Classes, Phantom Types, and Cats Patterns integrate cohesively with pattern synergies, decision trees, and maturity model. |
| 0.7 | 2026-01-30 | Comprehensive refactor of section 8 (Design Patterns) covering Creational, Structural, and Behavioral patterns. Shows OOP vs FP approaches, hybrid Scala solutions, SOLID connections, and Cats pattern integration. |
| 0.6 | 2026-01-30 | Comprehensive refactor of section 9 (SOLID) with real llm4s code examples, review checklists, violation detection, and practical guidance for each principle. |
| 0.5 | 2026-01-30 | Added section 16 on Mermaid diagrams in PR reviews with templates and usage policy. Added 7 advanced patterns to Appendix C (Currying, Lazy evaluation, Self types, Phantom types, F-bounded polymorphism, Type members, Opaque types). Updated CI bot contract. |
| 0.4 | 2026-01-29 | Added 6 more Cats patterns to Appendix C covering Semigroupal, Traverse, Kleisli, State, Semigroup, and Eval. |
| 0.3 | 2026-01-29 | Added 8 practical Scala patterns from Hands-on Scala Programming covering resource management, retry logic, parallel execution, and concurrency. |
| 0.2 | 2026-01-29 | Added Appendix C with 12 advanced functional programming patterns from Cats including MonadError, Validated, type classes, and variance rules. |
| 0.1 | 2026-01-29 | Initial version with CI bot contract, 15 review sections, severity rubric, comment templates, and llm4s code examples. |