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

  1. Repo Map
  2. Review Principles for llm4s
  3. PR Review Workflow (CI Playbook)
  4. Code Review Checklist (llm4s specific)
  5. API and Design Review Checklist
  6. Architecture Review Checklist
  7. FP Patterns in llm4s
  8. Design Patterns in llm4s
  9. SOLID in llm4s
  10. Testing Strategy Review
  11. Documentation Review
  12. Build, Release, CI, DevOps Review
  13. Security and Dependency Hygiene
  14. Performance and Resource Safety
  15. Common PR Smells and How to Comment
  16. Mermaid Diagrams in Reviews

1. Repository map

1.1 Module structure

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
llm4s/
├── modules/
│   ├── core/           # Published library (highest review bar)
│   │   └── src/main/scala/org/llm4s/
│   │       ├── agent/          # Agent framework, guardrails, memory, handoffs, streaming
│   │       ├── config/         # Llm4sConfig - ONLY place for env/config reads
│   │       ├── llmconnect/     # LLMClient trait, LLMConnect factory, providers
│   │       ├── toolapi/        # Tool calling, ToolFunction, ToolRegistry
│   │       ├── types/          # Result[A], newtypes (ModelName, ApiKey, etc.)
│   │       ├── error/          # LLMError hierarchy (ADT)
│   │       ├── core/safety/    # Safety helpers, ErrorMapper
│   │       ├── rag/            # RAG, embeddings, vector stores
│   │       ├── trace/          # Observability, Langfuse integration
│   │       └── resource/       # ManagedResource
│   ├── samples/        # Usage examples (lower review bar)
│   ├── workspace/      # Containerized runner/client (lower review bar)
│   └── crossTest/      # Scala 2.13 and 3.x behavior checks
├── docs/               # User-facing documentation
├── docs/internals/     # Internal docs, design decisions
├── project/            # SBT config (Common.scala, Deps)
└── build.sbt

1.2 Key public API surfaces

PackageKey TypesPurpose
org.llm4s.typesResult[A], AsyncResult[A], ModelName, ApiKey, ConversationIdCore types, newtypes
org.llm4s.llmconnectLLMClient, LLMConnect, Conversation, CompletionLLM client interface
org.llm4s.agentAgent, AgentState, AgentStatus, HandoffAgent framework
org.llm4s.toolapiToolFunction, ToolRegistry, ToolBuilder, SchemaTool calling
org.llm4s.configLlm4sConfig, ProviderConfigConfiguration loading
org.llm4s.errorLLMError, RecoverableError, NonRecoverableErrorError 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:

RulePattern BannedAllowed In
NoConfigFactoryConfigFactoryorg.llm4s.config only
NoSysEnvsys.envorg.llm4s.config, samples, workspace
NoSystemGetenvSystem.getenvorg.llm4s.config, samples, workspace
NoKeywordTrytry {org.llm4s.core.safety, org.llm4s.agent.orchestration
NoKeywordCatchcatch {Same as above
NoKeywordFinallyfinallySame as above

2. Review principles for llm4s

2.1 Core values

  1. Correct first, clever never: LLM systems fail in creative ways. Our job is to make failures uncreative.
  2. Explicit over implicit: Errors are typed, effects are at boundaries, dependencies are injected.
  3. 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

AreaReview bar
modules/core public APIsHighest - treat as product
modules/core internalsHigh - must follow patterns
Error handling changesHigh - affects all consumers
Config changesHigh - affects deployment
Security-relevant codeHighest - tool calling, secrets
modules/samplesMedium - working examples
modules/workspaceMedium - 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 categoryLevel (None/Low/Medium/High/Blocker)Notes
API Breaking ChangeNew params, removed methods, changed signatures
Binary CompatibilityMiMa violations, ABI changes
Source CompatibilityDeprecations, renames
Scala 2/3 Cross-BuildVersion-specific code, ScalaCompat changes
PerformanceHot path changes, allocation patterns
ConcurrencyThread safety, blocking, resource leaks
SecuritySecrets, 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

SeverityDefinitionAction
BlockerBreaks build, leaks secrets, causes data loss, violates core invariantMUST fix before merge
Must-FixCorrectness issue, missing error handling, security concern, breaks patternMUST fix before merge
Should-FixNon-idiomatic code, missing tests, unclear naming, minor inefficiencyShould fix, can defer with justification
NitStyle preference, minor readabilityUse sparingly, only if pattern is established

3.3 Comment format

Every review comment MUST follow this format:

1
2
3
4
5
6
7
8
9
**[SEVERITY]** Brief title

**Guideline**: [Section Name from this doc]

**Issue**: What is wrong and why it matters

**Suggestion**: How to fix (with code if applicable)

**Location**: `path/to/file.scala:42-48`

3.4 Bot prompt template

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
You are a Scala code reviewer for the llm4s project.

Load the review guidelines from: docs/internals/code-review-guidelines/MASTER_REVIEW_GUIDELINES.md

Analyze the PR diff provided. For each file changed:

1. Check against the guidelines in this document
2. Focus on correctness, safety, and pattern adherence
3. Ignore formatting (scalafmt handles that)
4. Cite specific guideline sections by name

Produce the outputs specified in "CI Review Bot Contract":

- PR Summary
- Risk Assessment table
- Must-Fix findings (Blocker/Must-Fix severity)
- Should-Fix findings
- Optional questions

For each finding, include:

- Severity level
- Guideline section reference
- File path and line range
- Clear explanation of issue
- Concrete suggestion to fix

4. Code review checklist (llm4s specific)

4.1 Error handling

  • Uses Result[A] for operations that can fail
  • No throw in domain logic (only at hard integration boundaries)
  • No try/catch in core (use Safety.safely or Try(...).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):

1
2
3
4
5
6
7
def safely[A](thunk: => A)(implicit em: ErrorMapper = DefaultErrorMapper): Result[A] =
  fromTry(Try(thunk))

def fromTry[A](t: Try[A])(implicit em: ErrorMapper = DefaultErrorMapper): Result[A] = t match {
  case Success(v) => Right(v)
  case Failure(e) => Left(em(e))
}

4.2 Configuration

  • No sys.env, System.getenv, or ConfigFactory in 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):

1
2
3
4
object Llm4sConfig {
  def provider(): Result[ProviderConfig] =
    ProviderConfigLoader.load(ConfigSource.default)
}

4.3 Type safety

  • Uses newtypes where confusion is possible (ModelName, ApiKey, ConversationId)
  • Secrets never logged (ApiKey.toString returns "ApiKey(***)")
  • ADTs used for state machines (sealed trait + case objects)
  • No sentinel values (-1, "", null) - use Option or ADT

llm4s pattern (modules/core/src/main/scala/org/llm4s/types/package.scala:88-92):

1
2
3
4
5
6
7
final case class ApiKey(private val value: String) extends AnyVal {
  override def toString: String = "ApiKey(***)"

  def reveal: String = value

  def masked: String = s"${value.take(4)}${"*" * (value.length - 4)}"
}

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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
sealed trait LLMProvider {
  def name: String = this match {
    case LLMProvider.OpenAI => "openai"
    case LLMProvider.Azure => "azure"
    case LLMProvider.Anthropic => "anthropic"
    case LLMProvider.OpenRouter => "openrouter"
    case LLMProvider.Ollama => "ollama"
    case LLMProvider.Zai => "zai"
    case LLMProvider.Gemini => "gemini"
  }
}

4.5 Immutability

  • Default to val, immutable collections, case classes
  • var only 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):

1
2
3
4
5
6
7
8
def run(
...): Result[AgentState] =
  for {
    validatedQuery <- validateInput(query, inputGuardrails)
    initialState = initialize(validatedQuery, tools, handoffs, systemPromptAddition, completionOptions)
    finalState <- run(initialState, maxSteps, traceLogPath, debug)
    validatedState <- validateOutput(finalState, outputGuardrails)
  } yield validatedState

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 @deprecated with 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):

1
2
3
4
5
6
7
8
object ConversationId {
  def create(value: String): Result[ConversationId] =
    if (value.trim.nonEmpty) {
      Right(ConversationId(value.trim))
    } else {
      Left(ValidationError("Conversation ID cannot be empty", "conversationId"))
    }
}

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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
trait LLMClient {
  def complete(conversation: Conversation, options: CompletionOptions = CompletionOptions()): Result[Completion]

  def streamComplete(conversation: Conversation, options: CompletionOptions = CompletionOptions(),
                     onChunk: StreamedChunk => Unit): Result[Completion]

  def getContextWindow(): Int

  def getReserveCompletion(): Int

  def validate(): Result[Unit] = Right(())

  def close(): Unit = ()
}

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/flatMap chains
  • 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
  • ManagedResource or Resource[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):

1
2
3
4
5
def use[A](f: R => Result[A]): Result[A] =
  acquire().flatMap { resource =>
    val result = f(resource)
    release(resource).flatMap(_ => result)
  }

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.13 or src/main/scala-3
  • ScalaCompat used only for tiny shims (not dumping ground)
  • modules/crossTest tests 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

PatternWhen to usellm4s example
Result[A] / EitherSingle operation that can failparseJson(raw): Result[ujson.Value]
OptionValue may be absenttools.find(_.name == name): Option[ToolFunction]
ValidatedNecAccumulate multiple errorsMulti-field form validation
for-comprehensionSequential dependent operationsAgent run pipeline
map/flatMapTransform success valuesresult.map(_.field)
fold / matchHandle both success and failureError recovery
ADTsClosed state machinesLLMProvider, AgentStatus
Smart constructorsValidated type creationConversationId.create(value)

7.2 Avoided patterns in core

PatternWhy avoidedAlternative
try/catchHides error types, loses exhaustivenessSafety.safely, Try.toResult
nullNPE risk, unclear semanticsOption
Implicit conversionsSurprise behavior at distanceExplicit type classes
Structural typesReflection overhead, unclear contractsExplicit traits
Cake patternComplex wiring, hard to testConstructor injection
Free monadsExcessive complexity for our use caseTagless final if needed

7.3 Monad transformer usage

Use EitherT or OptionT only when nesting becomes unreadable:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// Before: nested Future[Result[A]]
def process(): Future[Result[User]] =
  fetchUser().flatMap {
    case Right(user) => fetchPermissions(user.id).map {
      case Right(perms) => Right(user.withPerms(perms))
      case Left(e) => Left(e)
    }
    case Left(e) => Future.successful(Left(e))
  }

// After: EitherT
def process(): Future[Result[User]] =
  (for {
    user <- EitherT(fetchUser())
    perms <- EitherT(fetchPermissions(user.id))
  } yield user.withPerms(perms)).value

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:

1
2
3
4
5
6
7
Pure FP ←──────────── Hybrid ──────────→ Pure OOP
   │                     │                    │
ADTs, Monads      Smart Constructors    Mutable State,
Type Classes      + Builder Pattern     Inheritance Trees
Immutable         Phantom Types         Imperative Logic
   │                     │                    │
SOLID: SRP, ISP   SOLID: All 5          SOLID: LSP, DIP

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
  • Hybrid (Center): Object construction, resource management, external integrations

    • Examples: LLMClient trait + 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)
  • 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 CombinationSOLID PrincipleWhy They Work Togetherllm4s Example / Code SnippetBenefit
Factory + Smart Constructors + Result + DIPDIPFactory returns trait (abstraction), smart constructors validate, Result for errorsdef buildClient(cfg: ProviderConfig): Result[LLMClient] - returns LLMClient trait, not concrete classType-safe creation, testable, swappable implementations
Builder + Phantom Types + SRPSRPEach builder method has one job; phantom types track state; types enforce correctnessclass Builder[S <: State] { def withModel(m: ModelName): Builder[HasModel] }Impossible states unrepresentable, each step single concern
Strategy + ADTs + Pattern Matching + OCPOCPStrategy as sealed trait; add new strategy = add new case; no modification neededsealed trait ExecutionStrategy; case object Sequential extends ExecutionStrategyExhaustive checks, open for extension, closed for modification
Adapter + Type Classes + ISPISPType classes adapt types without inheritance; each type class is focused interfacetrait Show[A] { def show(a: A): String }; implicit val showError: Show[LLMError]Non-intrusive, focused interfaces, no fat interfaces
Decorator + HOFs + Kleisli + SRPSRPEach decorator does one thing; HOFs compose; Kleisli chainsval withRetry: Result[A] => Result[A]; val withLog: Result[A] => Result[A]Composable, testable, no class explosion, single responsibility
Observer + fs2.Stream + Monads + OCPOCPStream is open for new subscribers; monadic composition; no modification to sourceagent.runWithEvents().evalMap(handleEvent) - add handlers without changing agentBackpressure, resource safety, extensible
Composite + Recursive ADTs + Semigroup + LSPLSPLeaf and composite nodes both implement same interface; Semigroup combinestrait Guardrail; case class Leaf extends Guardrail; case class Composite(children: Seq[Guardrail])Type-safe trees, substitutable parts, algebraic combination
State + State Monad + for-comprehensions + SRPSRPEach state transformation is pure function with single job; State monad manages threadingval updateState: State[AgentState, Unit] = State.modify(s => s.addMessage(msg))Pure, testable, no mutation, clear responsibilities
Command + Free Monads + ADTs + OCPOCPCommands as ADT; new command = new case; interpreter separate from definitionsealed trait Command[A]; case class RunTool(name: String) extends Command[Result]Interpreter pattern, multiple backends, extensible
Singleton + object + DIPDIPUse object for stateless utilities; if stateful, inject as dependencyobject TokenCounter { def count(s: String): Int } - stateless OK; class Config - inject, don’t use singletonTestable (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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// SRP: Single responsibility per function
def parseConfig(json: String): Result[Config] = ??? // Only parse
def validateConfig(cfg: Config): Result[Config] = ??? // Only validate
def applyDefaults(cfg: Config): Config = ??? // Only apply defaults

// ISP: Focused type class interfaces
trait Encoder[A] {
  def encode(a: A): String
} // Only encoding

trait Decoder[A] {
  def decode(s: String): Result[A]
} // Only decoding
// Not: trait Codec[A] { def encode...; def decode...; def validate... } 

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// DIP: Depend on abstraction (LLMClient trait), not concretion
trait LLMClient {
  def complete(conv: Conversation): Result[Completion]
}

class Agent(client: LLMClient) { // ← Depends on trait, not OpenAIClient
  def run(query: String): Result[AgentState] = ???
}

// OCP: Add new provider without modifying Agent
class OpenAIClient extends LLMClient {
...
}

class AnthropicClient extends LLMClient {
...
}

class GeminiClient extends LLMClient {
...
} // ← Extension, not modification

// LSP: All implementations honor LLMClient contract
val agent1 = Agent(OpenAIClient(..
.) ) // Works
val agent2 = Agent(AnthropicClient(..
.) ) // Works identically
val agent3 = Agent(GeminiClient(..
.) ) // Works identically

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// LSP: Subclass honors base class contract
abstract class Connection {
  def open(): Unit

  def close(): Unit // MUST be idempotent (calling twice is safe)

  def isOpen: Boolean
}

class HttpConnection extends Connection {
  def open(): Unit = if (!isOpen) openSocket()

  def close(): Unit = if (isOpen) closeSocket() // ✅ Idempotent, honors contract

  def isOpen: Boolean = socket.isDefined
}

// ❌ LSP violation:
class BadConnection extends Connection {
  def close(): Unit = throw new Exception("Can't close!") // Breaks contract!
}

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// Pure core: business logic (SRP - validation is its only job)
def validateMessage(msg: Message): Result[Message] =
  if (msg.content.nonEmpty) Right(msg)
  else Left(LLMError.ValidationError("Empty message"))

// Imperative shell: I/O operations (DIP - depends on LLMClient abstraction)
class Agent(client: LLMClient) { // ← DIP: trait, not concrete class
  def run(query: String, tools: ToolRegistry): Result[AgentState] = {
    for {
      msg <- validateMessage(Message.user(query)) // FP: pure validation (SRP)
      response <- client.complete(Conversation(msg)) // OOP: side effect (DIP)
    } yield AgentState.fromResponse(response) // FP: pure transformation (SRP)
  }
}

// SOLID benefits:
// - SRP: Pure functions have single, testable responsibility
// - DIP: Shell depends on abstractions (LLMClient trait)
// - OCP: Add new validators without modifying Agent

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// ✅ GOOD: Small, composable (SRP - each decorator single job, ISP - minimal interface)
val withRetry: (=> Result[A]) => Result[A] = op => // SRP: Only retries
  Try(op).recoverWith { case _ => Try(op) }.toResult

val withLogging: (=> Result[A]) => Result[A] = op => // SRP: Only logs
{
  println("Starting...");
  val r = op;
  println(s"Result: $r");
  r
}

// Compose them (OCP - extend behavior without modifying functions)
val safeClient = withRetry(withLogging(client.complete(conv)))

// ❌ BAD: Large framework (violates SRP, ISP - God object)
class MegaClientWrapper extends BaseClient
  with RetryMixin // Retry logic
  with LoggingMixin // Logging logic
  with CachingMixin // Caching logic
  with MetricsMixin // Metrics logic
  with

... //  Violates SRP (many responsibilities), ISP (fat interface)

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// ✅ GOOD: Phantom types prevent invalid builder states (LSP - type-safe substitution)
sealed trait BuilderState

sealed trait Empty extends BuilderState

sealed trait HasModel extends BuilderState

sealed trait Complete extends BuilderState

class LLMClientBuilder[State <: BuilderState] private(
                                                       model: Option[ModelName],
                                                       apiKey: Option[ApiKey]
                                                     ) {
  def withModel(m: ModelName): LLMClientBuilder[HasModel] =
    new LLMClientBuilder[HasModel](Some(m), apiKey)

  def withApiKey(k: ApiKey): LLMClientBuilder[Complete] =
    new LLMClientBuilder[Complete](model, Some(k))

  // Only builds if State is Complete (compile-time check)
  def build()(implicit ev: State =:= Complete): Result[LLMClient] =
    OpenAIClient(model.get, apiKey.get) // Safe - guaranteed present by types
}

// Usage:
val builder = new LLMClientBuilder[Empty](None, None)
builder.build() // ❌ Compile error: State =:= Empty, not Complete
val configured = builder.withModel(ModelName("gpt-4")).withApiKey(ApiKey("sk-..."))
configured.build() // ✅ Compiles: State =:= Complete

// ❌ BAD: Runtime validation (violates LSP - can fail at runtime)
class LLMClientBuilder {
  private var model: Option[ModelName] = None

  def withModel(m: ModelName): this.type = {
    model = Some(m);
    this
  }

  def build(): Result[LLMClient] =
    if (model.isEmpty) Left(Error("Model not set")) // Runtime check!
    else OpenAIClient(model.get)
}

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// Compile-time: sealed trait ensures exhaustive matches (OCP - add cases without modification)
sealed trait ProviderConfig // OCP: closed for modification

case class OpenAIConfig(apiKey: ApiKey, model: ModelName) extends ProviderConfig

case class AnthropicConfig(apiKey: ApiKey, model: ModelName) extends ProviderConfig

case class GeminiConfig(apiKey: ApiKey, model: ModelName) extends ProviderConfig

// Exhaustive match - compiler warns if we miss a case
def buildClient(config: ProviderConfig): Result[LLMClient] =
  config match {
    case cfg: OpenAIConfig => OpenAIClient(cfg) // ✅ All cases covered
    case cfg: AnthropicConfig => AnthropicClient(cfg)
    case cfg: GeminiConfig => GeminiClient(cfg)
  } // Adding new case class forces us to handle it here

// Runtime: Result for graceful error handling (SRP - each validator single job)
def parseApiKey(s: String): Result[ApiKey] =
  if (s.startsWith("sk-")) Right(ApiKey(s))
  else Left(LLMError.ConfigError(s"Invalid API key format: $s"))

def validateModel(m: String): Result[ModelName] =
  if (m.nonEmpty) Right(ModelName(m))
  else Left(LLMError.ConfigError("Model name cannot be empty"))

// Compose validations (SRP - each function single responsibility)
def parseProviderConfig(json: String): Result[ProviderConfig] =
  for {
    parsed <- parseJson(json) // SRP: Only parses JSON
    apiKey <- parseApiKey(parsed.apiKey) // SRP: Only validates API key
    model <- validateModel(parsed.model) // SRP: Only validates model
  } yield OpenAIConfig(apiKey, model)

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 CombinationSOLID ViolationWhy They ConflictWhat to Do InsteadCode Example
Mutable State + Pure FunctionsSRPBreaks referential transparency, hard to test, mixed concernsUse State monad (C.16) or Ref[F, A]def add(x: Int): Int = { counter += 1; x + counter } → ✅ State.modify[Int](_ + 1)
Deep Inheritance + ADTsLSP, SRPADTs can’t extend multiple sealed traits, fragile base class problemUse composition, not inheritanceclass A extends B with C → ✅ case class A(b: B, c: C)
Exceptions + ResultSRPMixing throw with Result breaks monad laws, unpredictable flowConvert Try to Result at boundariesdef parse(s: String): Result[Int] = throw Ex → ✅ Try(s.toInt).toResult
Singleton + Dependency InjectionDIPGlobal state makes testing hard, depends on concretionPass dependencies explicitly, use Reader (C.18)object DB { var conn: Conn } → ✅ class Service(db: DB)
God Object + Many ResponsibilitiesSRP, ISPOne class does everything, hard to test, tight couplingSplit into focused classes/functionsclass Agent { parse(); validate(); execute(); log(); ... } → ✅ Separate classes
Concrete Dependencies + Tight CouplingDIPDepends on concrete classes, hard to swap implementationsDepend on traits/abstractionsclass Agent(client: OpenAIClient) → ✅ class Agent(client: LLMClient)
Modifying Existing Code + New FeaturesOCPEvery new feature requires changing existing code, brittleExtend via new types/cases❌ Modify buildClient() → ✅ Add new case to sealed trait
Fat Interfaces + Unused MethodsISPClients forced to implement methods they don’t useSplit into focused interfacestrait Client { def complete(); stream(); embed(); ... } → ✅ Separate traits
Phantom Types + Runtime ReflectionN/APhantom types erased at runtime, can’t reflect on themUse TypeTag if runtime type info neededdef check[S](b: Builder[S]): Boolean = ... → ✅ Use TypeTag
Free Monads + Performance-Critical CodeN/AFree monads add overhead, interpretation costUse Tagless Final (C.20) or direct encoding❌ Free monad for hot path → ✅ Direct Result[A]
Type Classes + InheritanceN/AType classes work via implicits, not subtyping; incompatible modelsChoose one: type classes OR inheritancetrait Show extends Serializable → ✅ Separate concerns
Subclass Breaking Parent ContractLSPSubclass violates preconditions/postconditions of parentHonor parent contracts or use compositionclass 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:

  1. Start by checking SOLID violations - If code violates SRP, OCP, LSP, ISP, or DIP, address those first
  2. Follow the decision path - Each recommendation includes relevant SOLID principle
  3. Verify SOLID alignment - Ensure chosen pattern doesn’t introduce new violations
  4. 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: Result type, 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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// SRP: Each function single responsibility
def parseJson(s: String): Result[JsonValue] = ???
def validateConfig(json: JsonValue): Result[Config] = ???

// DIP: Depend on trait, not concrete class
trait Storage {
  def save(data: String): Result[Unit]
}

class Service(storage: Storage) { // ← DIP: inject abstraction
  def process(input: String): Result[Unit] =
    for {
      json <- parseJson(input) // SRP: only parse
      config <- validateConfig(json) // SRP: only validate
      _ <- storage.save(config.toString) // DIP: trait, not FileStorage
    } yield ()
}

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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// ISP: Focused type class interfaces
trait Encoder[A] {
  def encode(a: A): String
} // Only encode

trait Decoder[A] {
  def decode(s: String): Result[A]
} // Only decode

// OCP: Extend via new cases, don't modify existing code
sealed trait ValidationRule

case class LengthRule(min: Int, max: Int) extends ValidationRule

case class RegexRule(pattern: String) extends ValidationRule
// Adding new rule doesn't modify existing validators

def validate(input: String, rules: Seq[ValidationRule]): Result[String] =
  rules.foldLeft[Result[String]](Right(input)) { (acc, rule) =>
    acc.flatMap { s =>
      rule match { // OCP: exhaustive, compiler warns on new cases
        case LengthRule(min, max) if s.length >= min && s.length <= max => Right(s)
        case RegexRule(p) if s.matches(p) => Right(s)
        case _ => Left(LLMError.ValidationError(s"Failed rule: $rule"))
      }
    }
  }

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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// F-Bounded Polymorphism + LSP: Type-safe fluent API
trait Builder[A <: Builder[A]] {
  self: A =>
  def build(): Result[Config]

  def withTimeout(t: Int): A // Returns self type, not Builder
}

class ConfigBuilder extends Builder[ConfigBuilder] {
  private var timeout: Int = 30

  def withTimeout(t: Int): ConfigBuilder = {
    timeout = t;
    this
  }

  def build(): Result[Config] = Right(Config(timeout))
}

// LSP: All Builders substitutable, fluent API preserved
def configure[B <: Builder[B]](builder: B): B =
  builder.withTimeout(60) // Returns B, not Builder - type-safe!

// Tagless Final + DIP: Abstract over effect type
trait Logger[F[_]] {
  def log(msg: String): F[Unit]
}

class Service[F[_] : Monad](logger: Logger[F]) { // DIP: abstract Logger[F]
  def process(input: String): F[Result[String]] =
    for {
      _ <- logger.log(s"Processing: $input") // F[_] abstraction
      result <- Monad[F].pure(Right(input.toUpperCase))
    } yield result
}

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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// Combining: Phantom Types + F-Bounded + Type Members + SOLID
sealed trait BuilderState

trait Incomplete extends BuilderState

trait Complete extends BuilderState

// F-Bounded + Phantom Types + SRP (each method single job)
trait ConfigBuilder[S <: BuilderState, Self <: ConfigBuilder[S, Self]] {
  self: Self =>
  type Out // Type member for configuration output

  def withModel(m: String): ConfigBuilder[Complete, Self]

  def build()(implicit ev: S =:= Complete): Result[Out]
}

// DIP + LSP: Depend on ConfigBuilder abstraction, all implementations substitutable
class Service[S <: BuilderState, B <: ConfigBuilder[S, B]](builder: B) {
  def configure(model: String): ConfigBuilder[Complete, B] =
    builder.withModel(model) // Type-safe state transition
}

// OCP: Extend via new implementations without modifying Service
class OpenAIBuilder extends ConfigBuilder[Incomplete, OpenAIBuilder] {
  type Out = OpenAIConfig
  private var model: Option[String] = None

  def withModel(m: String): ConfigBuilder[Complete, OpenAIBuilder] =
    new OpenAIBuilder {
      override type Out = OpenAIConfig;
      /* ... */}.asInstanceOf[ConfigBuilder[Complete, OpenAIBuilder]]

  def build()(implicit ev: Incomplete =:= Complete): Result[OpenAIConfig] =
    ??? // Won't compile - correctly enforces state
}

// ISP: Focused trait per concern
trait ModelConfigurable {
  def withModel(m: String): this.type
}

trait ApiKeyConfigurable {
  def withApiKey(k: String): this.type
}

trait Buildable[A] {
  def build(): Result[A]
}
// Clients only depend on what they need, not fat ConfigBuilder interface

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 CategoryOOP PatternFP Alternativellm4s ApproachSOLID ConnectionCats Pattern
CreationalFactorySmart constructors + ADTsHybridDIP - depend on abstractionsN/A
CreationalBuilderPhantom types + curryingHybrid with phantom types (C.30)SRP - each step one concernN/A
CreationalSingletonobjectPure FP with objectN/AN/A
StructuralAdapterType classesFP with type classes (C.4)ISP - focused interfacesType class instances
StructuralDecoratorHigher-order functionsFP with function compositionOCP - extend via compositionKleisli (C.15)
StructuralCompositeRecursive ADTsFP with sealed traitsN/ASemigroup (C.17)
BehavioralStrategyADT + pattern matchingFP with ADTsOCP - new strategies via new casesN/A
BehavioralStateState monadFP with State monad (C.16)N/AState monad
BehavioralObserverfs2.Stream / CallbacksHybrid - callbacks for simplicityN/AN/A
BehavioralCommandADT representing operationsFP with ADTsN/AFree monad (advanced)

8.2 Creational patterns

8.2.1 Factory pattern: Smart constructors + ADTs

OOP approach (classic Factory):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// Classic OOP factory with inheritance
abstract class LLMClientFactory {
  def createClient(): LLMClient
}

class OpenAIFactory extends LLMClientFactory {
  def createClient(): LLMClient = new OpenAIClient(.

..)
}

class AnthropicFactory extends LLMClientFactory {
  def createClient(): LLMClient = new AnthropicClient(.

..)
}

FP approach (Smart constructors + ADTs):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Sealed ADT represents all possible configs
sealed trait ProviderConfig

case class OpenAIConfig(model: ModelName, apiKey: ApiKey) extends ProviderConfig

case class AnthropicConfig(model: ModelName, apiKey: ApiKey) extends ProviderConfig

// Smart constructor returns Result for validation
object ProviderConfig {
  def create(provider: String, model: String, key: String): Result[ProviderConfig] =
    (provider, model, key) match {
      case ("openai", m, k) =>
        for {
          modelName <- ModelName.create(m)
          apiKey <- ApiKey.create(k)
        } yield OpenAIConfig(modelName, apiKey)
      case _ => Left(ValidationError(s"Unknown provider: $provider"))
    }
}

// Factory method uses pattern matching (ADT dispatch)
def buildClient(config: ProviderConfig): Result[LLMClient] =
  config match {
    case cfg: OpenAIConfig => OpenAIClient(cfg)
    case cfg: AnthropicConfig => AnthropicClient(cfg)
  }

llm4s hybrid approach (modules/core/src/main/scala/org/llm4s/llmconnect/LLMConnect.scala:10-46):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
object LLMConnect {
  // Public factory method - returns Result for validation
  def getClient(config: ProviderConfig): Result[LLMClient] =
    Safety.safely(buildClient(config))

  // Private factory - ADT pattern matching
  private def buildClient(config: ProviderConfig): Result[LLMClient] =
    config match {
      case cfg: OpenAIConfig => OpenAIClient(cfg)
      case cfg: AnthropicConfig => AnthropicClient(cfg)
      case cfg: OllamaConfig => OllamaClient(cfg)
      case cfg: GeminiConfig => GeminiClient(cfg)
    }
}

Why this works:

  • DIP: High-level code depends on LLMClient trait, 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] not T (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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Mutable builder (classic Java style)
class LLMClientBuilder {
  private var model: Option[ModelName] = None
  private var apiKey: Option[ApiKey] = None

  def withModel(m: ModelName): LLMClientBuilder = {
    model = Some(m)
    this
  }

  def withApiKey(k: ApiKey): LLMClientBuilder = {
    apiKey = Some(k)
    this
  }

  def build(): LLMClient = {
    require(model.isDefined && apiKey.isDefined) // runtime check!
    new OpenAIClient(model.get, apiKey.get)
  }
}

// Usage: can call build() too early (runtime error)
val client = new LLMClientBuilder()
  .withModel(m)
  .build() // compiles but fails at runtime (missing apiKey)

FP approach 1 (Phantom types for compile-time safety):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
sealed trait BuilderState

sealed trait Empty extends BuilderState

sealed trait HasModel extends BuilderState

sealed trait Complete extends BuilderState

class LLMClientBuilder[S <: BuilderState] private(
                                                   model: Option[ModelName],
                                                   apiKey: Option[ApiKey]
                                                 ) {
  // Only available when Empty
  def withModel(m: ModelName)(implicit ev: S =:= Empty): LLMClientBuilder[HasModel] =
    new LLMClientBuilder[HasModel](Some(m), apiKey)

  // Only available when HasModel
  def withApiKey(k: ApiKey)(implicit ev: S =:= HasModel): LLMClientBuilder[Complete] =
    new LLMClientBuilder[Complete](model, Some(k))

  // Only available when Complete
  def build()(implicit ev: S =:= Complete): Result[LLMClient] =
    for {
      m <- model.toRight(ConfigError("Missing model"))
      k <- apiKey.toRight(ConfigError("Missing API key"))
    } yield OpenAIClient(OpenAIConfig(m, k))
}

// Usage: compile-time safety
val client = LLMClientBuilder.empty()
  .withModel(m)
  .withApiKey(k)
  .build() // OK

// val bad = LLMClientBuilder.empty().build() // ERROR: won't compile

FP approach 2 (Currying for step-by-step configuration):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// Curried constructor functions
def createClient(model: ModelName)
                (apiKey: ApiKey)
                (timeout: Duration): Result[LLMClient] =
  OpenAIClient(OpenAIConfig(model, apiKey, timeout))

// Partially apply for specialized factories
val createGPT4Client = createClient(ModelName("gpt-4"))(_: ApiKey)(_: Duration)
val createWithKey = createGPT4Client(myApiKey)(_: Duration)
val client = createWithKey(30.seconds)

llm4s approach (modules/core/src/main/scala/org/llm4s/toolapi/ToolFunction.scala:101-119):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// ToolBuilder uses type parameters for input/output
class ToolBuilder[T, R] {
  def withName(name: String): ToolBuilder[T, R] = ???

  def withDescription(desc: String): ToolBuilder[T, R] = ???

  def withFunction(f: T => Result[R]): ToolBuilder[T, R] = ???

  def withSchema(schema: Schema): ToolBuilder[T, R] = ???

  def build(): Result[ToolFunction[T, R]] = ???
}

// Usage
val tool = ToolBuilder[WeatherInput, WeatherOutput]()
  .withName("get_weather")
  .withDescription("Get weather for location")
  .withFunction(getWeather)
  .build()

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() returns Result[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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// Classic Java singleton (don't do this in Scala)
class MySingleton private() {
  // ...
}

object MySingleton {
  private val instance = new MySingleton()

  def getInstance(): MySingleton = instance
}

Scala approach (object is already a singleton):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// object is singleton by definition
object ToolRegistry {
  def empty: ToolRegistry = new ToolRegistry(Seq.empty)

  // Predefined singletons
  val core: ToolRegistry = new ToolRegistry(BuiltinTools.core)
  val safe: ToolRegistry = new ToolRegistry(BuiltinTools.safe())
}

// No getInstance() needed, just use the object
val registry = ToolRegistry.core

Review checklist:

  • Use object for 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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// Third-party API we don't control
trait ThirdPartyLLM {
  def generate(prompt: String): String
}

// Adapter wraps third-party API to match our interface
class LLMClientAdapter(thirdParty: ThirdPartyLLM) extends LLMClient {
  def complete(conversation: Conversation, options: CompletionOptions): Result[Completion] = {
    val prompt = conversation.messages.map(_.content).mkString("\n")
    val response = thirdParty.generate(prompt)
    Right(Completion(Message.assistant(response)))
  }
}

FP approach (Type classes - see C.4):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Type class defines the capability
trait JsonWriter[A] {
  def write(value: A): ujson.Value
}

// Instances adapt specific types
implicit val completionWriter: JsonWriter[Completion] = new JsonWriter[Completion] {
  def write(c: Completion): ujson.Value = ujson.Obj(
    "content" -> c.message.content,
    "role" -> c.message.role
  )
}

implicit val conversationWriter: JsonWriter[Conversation] = new JsonWriter[Conversation] {
  def write(c: Conversation): ujson.Value = ujson.Arr(
    c.messages.map(m => ujson.Obj("role" -> m.role, "content" -> m.content)): _*
  )
}

// Generic serialization function
def toJson[A: JsonWriter](value: A): ujson.Value =
  implicitly[JsonWriter[A]].write(value)

llm4s hybrid approach:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// Provider clients are adapters wrapping HTTP APIs
class OpenAIClient(config: OpenAIConfig) extends LLMClient {
  // Adapts OpenAI's HTTP API to LLMClient interface
  def complete(conversation: Conversation, options: CompletionOptions): Result[Completion] = {
    val requestBody = toOpenAIFormat(conversation, options) // adapt our format
    val response = httpClient.post("/v1/chat/completions", requestBody)
    fromOpenAIFormat(response) // adapt their format
  }

  private def toOpenAIFormat(conv: Conversation, opts: CompletionOptions): ujson.Value = ???

  private def fromOpenAIFormat(response: ujson.Value): Result[Completion] = ???
}

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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// Component interface
trait LLMClient {
  def complete(conversation: Conversation): Result[Completion]
}

// Concrete component
class OpenAIClient extends LLMClient {
  def complete(conversation: Conversation): Result[Completion] = ???
}

// Decorator adds behavior
class LoggingDecorator(client: LLMClient) extends LLMClient {
  def complete(conversation: Conversation): Result[Completion] = {
    println(s"Calling LLM with ${conversation.messages.size} messages")
    val result = client.complete(conversation)
    println(s"LLM returned: ${result.isRight}")
    result
  }
}

class CachingDecorator(client: LLMClient, cache: Cache) extends LLMClient {
  def complete(conversation: Conversation): Result[Completion] = {
    cache.get(conversation) match {
      case Some(cached) => Right(cached)
      case None =>
        val result = client.complete(conversation)
        result.foreach(c => cache.put(conversation, c))
        result
    }
  }
}

// Stack decorators
val client = new CachingDecorator(
  new LoggingDecorator(
    new OpenAIClient()
  ),
  cache
)

FP approach (Higher-order functions + Kleisli):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// Function type alias
type LLMCall = Conversation => Result[Completion]

// Base function
val baseCall: LLMCall = conv => OpenAIClient(config).complete(conv)

// Decorators as HOFs
def withLogging(f: LLMCall): LLMCall = conv => {
  println(s"Calling LLM with ${conv.messages.size} messages")
  val result = f(conv)
  println(s"LLM returned: ${result.isRight}")
  result
}

def withCaching(cache: Cache)(f: LLMCall): LLMCall = conv => {
  cache.get(conv).map(Right(_)).getOrElse {
    val result = f(conv)
    result.foreach(c => cache.put(conv, c))
    result
  }
}

// Compose decorators
val decoratedCall: LLMCall = baseCall
  .pipe(withLogging)
  .pipe(withCaching(cache))

// Or with Kleisli (see C.15)

import cats.data.Kleisli

val baseK = Kleisli[Result, Conversation, Completion](baseCall)
val withLogK = Kleisli[Result, Conversation, Completion](withLogging(baseCall))
val composed = baseK andThen withLogK

llm4s approach (Hybrid - traits for stateful, HOFs for stateless):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// For stateful decorators: use traits
trait TracedLLMClient extends LLMClient {
  def tracer: Tracer

  abstract override def complete(conversation: Conversation, options: CompletionOptions): Result[Completion] = {
    val span = tracer.startSpan("llm.complete")
    val result = super.complete(conversation, options)
    span.end()
    result
  }
}

// For stateless decorators: use HOFs
def withRetry[A](maxAttempts: Int)(f: => Result[A]): Result[A] = {
  @tailrec
  def attempt(remaining: Int, lastError: LLMError): Result[A] = {
    if (remaining <= 0) Left(lastError)
    else f match {
      case Right(value) => Right(value)
      case Left(err) if err.isRetryable => attempt(remaining - 1, err)
      case Left(err) => Left(err)
    }
  }

  f match {
    case Right(value) => Right(value)
    case Left(err) => attempt(maxAttempts - 1, err)
  }
}

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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// Component interface
trait Guardrail {
  def check(input: String): Result[Unit]
}

// Leaf
class LengthCheck(min: Int, max: Int) extends Guardrail {
  def check(input: String): Result[Unit] =
    if (input.length >= min && input.length <= max) Right(())
    else Left(ValidationError(s"Length must be $min-$max"))
}

// Composite
class CompositeGuardrail(guardrails: Seq[Guardrail]) extends Guardrail {
  def check(input: String): Result[Unit] = {
    guardrails.foldLeft(Right(()): Result[Unit]) { (acc, g) =>
      acc.flatMap(_ => g.check(input))
    }
  }
}

// Build tree
val composite = new CompositeGuardrail(Seq(
  new LengthCheck(1, 1000),
  new ProfanityFilter(),
  new CompositeGuardrail(Seq( // nested composite
    new RegexValidator(pattern1),
    new RegexValidator(pattern2)
  ))
))

FP approach (Recursive ADTs + Semigroup):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// ADT represents composite structure
sealed trait Guardrail

case class Single(check: String => Result[Unit]) extends Guardrail

case class All(guardrails: List[Guardrail]) extends Guardrail

case class Any(guardrails: List[Guardrail]) extends Guardrail

// Interpreter traverses ADT
def evaluate(g: Guardrail, input: String): Result[Unit] = g match {
  case Single(check) => check(input)
  case All(guards) => guards.traverse(g => evaluate(g, input)).map(_ => ())
  case Any(guards) => guards.collectFirst {
    case g if evaluate(g, input).isRight => ()
  }.toRight(ValidationError("No guardrail passed"))
}

// Semigroup for combining guardrails (see C.17)

import cats.Semigroup

implicit val guardrailSemigroup: Semigroup[Guardrail] = new Semigroup[Guardrail] {
  def combine(g1: Guardrail, g2: Guardrail): Guardrail = All(List(g1, g2))
}

// Compose with |+|
val combined = lengthCheck |+| profanityFilter |+| regexValidator

llm4s approach (modules/core/src/main/scala/org/llm4s/agent/guardrails/CompositeGuardrail.scala):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
object CompositeGuardrail {
  // All guardrails must pass (AND)
  def all(guardrails: Seq[Guardrail]): CompositeGuardrail =
    new CompositeGuardrail(guardrails, CompositionStrategy.All)

  // At least one guardrail must pass (OR)
  def any(guardrails: Seq[Guardrail]): CompositeGuardrail =
    new CompositeGuardrail(guardrails, CompositionStrategy.Any)

  // Run guardrails in sequence, stop on first failure
  def sequence(guardrails: Seq[Guardrail]): CompositeGuardrail =
    new CompositeGuardrail(guardrails, CompositionStrategy.Sequence)
}

// Usage
val composite = CompositeGuardrail.all(Seq(
  new LengthCheck(1, 10000),
  new ProfanityFilter(),
  CompositeGuardrail.any(Seq( // nested composition
    new JSONValidator(),
    new XMLValidator()
  ))
))

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 Semigroup for algebraic composition

8.4 Behavioral patterns

8.4.1 Strategy pattern: ADTs + pattern matching

OOP approach (classic Strategy):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Strategy interface
trait ToolExecutionStrategy {
  def execute(tools: Seq[ToolCallRequest])(implicit ec: ExecutionContext): Future[Seq[Result[String]]]
}

// Concrete strategies
class SequentialStrategy extends ToolExecutionStrategy {
  def execute(tools: Seq[ToolCallRequest])(implicit ec: ExecutionContext) = ???
}

class ParallelStrategy extends ToolExecutionStrategy {
  def execute(tools: Seq[ToolCallRequest])(implicit ec: ExecutionContext) = ???
}

class ThrottledStrategy(maxConcurrent: Int) extends ToolExecutionStrategy {
  def execute(tools: Seq[ToolCallRequest])(implicit ec: ExecutionContext) = ???
}

// Context uses strategy
class ToolRegistry(strategy: ToolExecutionStrategy) {
  def executeAll(requests: Seq[ToolCallRequest])(implicit ec: ExecutionContext) =
    strategy.execute(requests)
}

FP approach (ADTs + pattern matching):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// ADT represents all strategies
sealed trait ToolExecutionStrategy

object ToolExecutionStrategy {
  case object Sequential extends ToolExecutionStrategy

  case object Parallel extends ToolExecutionStrategy

  case class ParallelWithLimit(maxConcurrent: Int) extends ToolExecutionStrategy
}

// Pattern matching dispatches to implementation
def executeAll(requests: Seq[ToolCallRequest], strategy: ToolExecutionStrategy)
              (implicit ec: ExecutionContext): AsyncResult[Seq[Result[String]]] = {
  strategy match {
    case ToolExecutionStrategy.Sequential => executeSequential(requests)
    case ToolExecutionStrategy.Parallel => executeParallel(requests)
    case ToolExecutionStrategy.ParallelWithLimit(n) => executeWithLimit(requests, n)
  }
}

llm4s approach (modules/core/src/main/scala/org/llm4s/toolapi/ToolExecutionStrategy.scala + ToolRegistry.scala:64-77):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Sealed ADT - exhaustive matching enforced
sealed trait ToolExecutionStrategy

object ToolExecutionStrategy {
  case object Sequential extends ToolExecutionStrategy

  case object Parallel extends ToolExecutionStrategy

  case class ParallelWithLimit(maxConcurrent: Int) extends ToolExecutionStrategy
}

// Pattern matching in ToolRegistry
def executeAll(requests: Seq[ToolCallRequest], strategy: ToolExecutionStrategy)
              (implicit ec: ExecutionContext): AsyncResult[Seq[ToolCallResponse]] = {
  strategy match {
    case ToolExecutionStrategy.Sequential =>
      executeSequential(requests)
    case ToolExecutionStrategy.Parallel =>
      executeParallel(requests)
    case ToolExecutionStrategy.ParallelWithLimit(n) =>
      executeWithLimit(requests, n)
  }
}

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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// State interface
trait AgentState {
  def handleMessage(msg: Message): AgentState

  def isComplete: Boolean
}

// Concrete states
class InProgressState extends AgentState {
  def handleMessage(msg: Message): AgentState = {
    if (msg.content.contains("DONE")) new CompleteState()
    else if (msg.toolCalls.nonEmpty) new WaitingForToolsState(msg.toolCalls)
    else this
  }

  def isComplete = false
}

class WaitingForToolsState(toolCalls: Seq[ToolCall]) extends AgentState {
  def handleMessage(msg: Message): AgentState = {
    // Process tool results...
    new InProgressState()
  }

  def isComplete = false
}

class CompleteState extends AgentState {
  def handleMessage(msg: Message) = this // terminal state

  def isComplete = true
}

FP approach 1 (ADT for state, pure transitions):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// ADT represents all possible states
sealed trait AgentStatus

object AgentStatus {
  case object InProgress extends AgentStatus

  case object WaitingForTools extends AgentStatus

  case object Complete extends AgentStatus

  case class Failed(error: String) extends AgentStatus

  case class HandoffRequested(toAgent: String, reason: String) extends AgentStatus
}

// Pure state transition function
def transition(state: AgentStatus, event: Event): AgentStatus = (state, event) match {
  case (AgentStatus.InProgress, Event.ToolCallRequested(_)) =>
    AgentStatus.WaitingForTools
  case (AgentStatus.WaitingForTools, Event.ToolCallCompleted(_)) =>
    AgentStatus.InProgress
  case (AgentStatus.InProgress, Event.Completed) =>
    AgentStatus.Complete
  case (_, Event.Error(msg)) =>
    AgentStatus.Failed(msg)
  case (s, _) => s // no transition
}

FP approach 2 (State monad - see C.16):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import cats.data.State

// State monad encapsulates state transformations
def addMessage(msg: Message): State[AgentState, Unit] = State { state =>
  (state.copy(messages = state.messages :+ msg), ())
}

def incrementStep: State[AgentState, Int] = State { state =>
  val newStep = state.step + 1
  (state.copy(step = newStep), newStep)
}

def checkComplete: State[AgentState, Boolean] = State { state =>
  (state, state.status == AgentStatus.Complete)
}

// Compose state transformations
val workflow: State[AgentState, Boolean] = for {
  _ <- addMessage(userMessage)
  step <- incrementStep
  _ <- addMessage(assistantResponse)
  complete <- checkComplete
} yield complete

// Run state transformation
val (finalState, result) = workflow.run(initialState).value

llm4s approach (ADT + immutable state with copy):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// ADT for status (from org.llm4s.agent.AgentState)
sealed trait AgentStatus

case object InProgress extends AgentStatus

case object WaitingForTools extends AgentStatus

case object Complete extends AgentStatus

case class Failed(error: LLMError) extends AgentStatus

// Immutable state with pure transitions
case class AgentState(
                       conversation: Conversation,
                       status: AgentStatus,
                       step: Int,
                       toolCalls: Seq[ToolCallRequest]
                     ) {
  // Pure state transitions
  def addMessage(msg: Message): AgentState =
    copy(conversation = conversation.addMessage(msg))

  def nextStep: AgentState =
    copy(step = step + 1)

  def markComplete: AgentState =
    copy(status = AgentStatus.Complete)

  def requestTools(calls: Seq[ToolCallRequest]): AgentState =
    copy(status = AgentStatus.WaitingForTools, toolCalls = calls)
}

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 copy for updates)
  • Illegal transitions return error or are no-ops

8.4.3 Observer pattern: Callbacks vs fs2.Stream

OOP approach (classic Observer):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Observer interface
trait AgentObserver {
  def onStepStart(step: Int): Unit

  def onToolCall(name: String): Unit

  def onComplete(state: AgentState): Unit
}

// Observable subject
class Agent(client: LLMClient) {
  private val observers = mutable.ListBuffer[AgentObserver]()

  def addObserver(obs: AgentObserver): Unit = observers += obs

  def removeObserver(obs: AgentObserver): Unit = observers -= obs

  def run(query: String): Result[AgentState] = {
    observers.foreach(_.onStepStart(0))
    // ... execution logic
    observers.foreach(_.onToolCall("get_weather"))
    // ... more logic
    val finalState = ???
    observers.foreach(_.onComplete(finalState))
    Right(finalState)
  }
}

FP approach 1 (Callbacks - simple):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// Event ADT
sealed trait AgentEvent

case class StepStarted(step: Int) extends AgentEvent

case class ToolCallStarted(name: String) extends AgentEvent

case class AgentCompleted(state: AgentState) extends AgentEvent

// Agent takes event callback
class Agent(client: LLMClient) {
  def run(query: String, onEvent: AgentEvent => Unit = _ => ()): Result[AgentState] = {
    onEvent(StepStarted(0))
    // ... execution logic
    onEvent(ToolCallStarted("get_weather"))
    // ... more logic
    val finalState = ???
    onEvent(AgentCompleted(finalState))
    Right(finalState)
  }
}

FP approach 2 (fs2.Stream - reactive):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import fs2.Stream

def runStreaming(query: String): Stream[IO, AgentEvent] = {
  Stream.emit(StepStarted(0)) ++
    Stream.eval(IO(executeLLM())).flatMap(response =>
      Stream.emit(ToolCallStarted(response.toolName))
    ) ++
    Stream.eval(IO(finalize())).map(state =>
      AgentCompleted(state)
    )
}

// Consume events
runStreaming("query").foreach(event => IO(println(event))).compile.drain

llm4s approach (Callbacks for simplicity - modules/core/src/main/scala/org/llm4s/agent/streaming/AgentEvent.scala):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Event ADT
sealed trait AgentEvent

case class TextDelta(text: String, timestamp: Instant) extends AgentEvent

case class ToolCallStarted(name: String, args: ujson.Value, timestamp: Instant) extends AgentEvent

case class ToolCallCompleted(name: String, result: String, timestamp: Instant) extends AgentEvent

case class AgentCompleted(state: AgentState, timestamp: Instant) extends AgentEvent

// Agent provides callback-based API
def runWithEvents(
                   query: String,
                   tools: ToolRegistry,
                   onEvent: AgentEvent => Unit
                 ): Result[AgentState] = {
  onEvent(AgentStarted(query, Instant.now()))
  // ... execution with event emissions
  onEvent(TextDelta("Thinking...", Instant.now()))
  // ...
  onEvent(AgentCompleted(finalState, Instant.now()))
  Right(finalState)
}

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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// Command interface
trait Command {
  def execute(): Result[String]

  def undo(): Result[Unit]
}

// Concrete commands
class CreateFileCommand(path: String, content: String) extends Command {
  def execute(): Result[String] = {
    // Write file
    Right(s"Created $path")
  }

  def undo(): Result[Unit] = {
    // Delete file
    Right(())
  }
}

// Command invoker
class CommandExecutor {
  private val history = mutable.Stack[Command]()

  def execute(cmd: Command): Result[String] = {
    val result = cmd.execute()
    if (result.isRight) history.push(cmd)
    result
  }

  def undo(): Result[Unit] = {
    if (history.nonEmpty) history.pop().undo()
    else Left(CommandError("Nothing to undo"))
  }
}

FP approach (ADTs - operations as data):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// ADT represents all possible operations
sealed trait Operation

case class CreateFile(path: String, content: String) extends Operation

case class DeleteFile(path: String) extends Operation

case class UpdateFile(path: String, newContent: String) extends Operation

// Interpreter executes operations
def execute(op: Operation): Result[String] = op match {
  case CreateFile(path, content) =>
    // Write file
    Right(s"Created $path")
  case DeleteFile(path) =>
    // Delete file
    Right(s"Deleted $path")
  case UpdateFile(path, content) =>
    // Update file
    Right(s"Updated $path")
}

// Undo is just the inverse operation
def undo(op: Operation): Operation = op match {
  case CreateFile(path, _) => DeleteFile(path)
  case DeleteFile(path) => CreateFile(path, "") // need original content
  case UpdateFile(path, newContent) => UpdateFile(path, "") // need old content
}

// Command history as immutable list
def executeWithHistory(op: Operation, history: List[Operation]): (Result[String], List[Operation]) = {
  val result = execute(op)
  if (result.isRight) (result, op :: history)
  else (result, history)
}

llm4s approach (Tool calls as commands - modules/core/src/main/scala/org/llm4s/toolapi/ToolCallRequest.scala):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// Tool call request is a command (operation as data)
case class ToolCallRequest(
                            id: String,
                            name: String,
                            arguments: ujson.Value
                          )

// Tool registry is command executor
class ToolRegistry(tools: Seq[ToolFunction[_, _]]) {
  def execute(request: ToolCallRequest): Result[ToolCallResponse] = {
    find(request.name) match {
      case Some(tool) => tool.execute(request.arguments)
      case None => Left(ToolCallError.UnknownFunction(request.name))
    }
  }
}

// Commands can be queued, retried, logged
def executeWithRetry(request: ToolCallRequest, maxAttempts: Int): Result[ToolCallResponse] = {
  @tailrec
  def attempt(remaining: Int): Result[ToolCallResponse] = {
    registry.execute(request) match {
      case Right(response) => Right(response)
      case Left(err) if remaining > 0 && err.isRetryable =>
        Thread.sleep(1000)
        attempt(remaining - 1)
      case Left(err) => Left(err)
    }
  }

  attempt(maxAttempts)
}

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 Result for error handling

8.5 Pattern selection guide

When reviewing code, ask:

QuestionPatternParadigm
Creating objects with validation?Smart constructors + FactoryFP
Building complex objects step-by-step?Phantom type BuilderHybrid
Need a global instance?objectScala
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 eventsHybrid
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

PatternPrimary SOLID PrincipleWhy
FactoryDIP - depend on LLMClient, not OpenAIClientHigh-level code independent of concrete implementations
BuilderSRP - each step one concernSeparate construction from representation
AdapterISP - focused interfacesAdapt to small, cohesive interfaces
DecoratorOCP - extend via compositionAdd behavior without modifying original
StrategyOCP - new strategies via new casesExtend behavior by adding ADT cases
StateLSP - all states honor contractEach 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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class ToolRegistry(tools: Seq[ToolFunction[_, _]]) {
  // ONLY responsible for managing tool lookup and execution
  def find(name: String): Option[ToolFunction[_, _]] = ???

  def execute(request: ToolCallRequest): Result[ToolCallResponse] = ???

  def executeAll(requests: Seq[ToolCallRequest], strategy: ToolExecutionStrategy): AsyncResult[Seq[ToolCallResponse]] = ???
}

// Separate classes for separate concerns:
// ToolSchemaValidator - validates schemas
// ToolFunction - defines individual tools
// ToolExecutionStrategy - execution strategy

Bad example (violation):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// BAD: ToolManager has TOO MANY responsibilities
class ToolManager {
  def register(tool: ToolFunction): Unit = ??? // registry

  def validate(schema: Schema): Result[Unit] = ??? // validation

  def execute(request: ToolCallRequest): Result[String] = ??? // execution

  def log(message: String): Unit = ??? // logging

  def formatOutput(result: Any): String = ??? // formatting

  def cacheResult(key: String, value: String): Unit = ??? // caching
}
// Six different reasons to change! Split into separate classes.

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:

1
2
3
4
5
6
7
8
// Split ToolManager into focused classes
class ToolRegistry(tools: Seq[ToolFunction]) // manages collection

class ToolExecutor(registry: ToolRegistry) // executes tools

class ToolValidator(schemas: Seq[Schema]) // validates

class ToolCache(store: Cache) // caching

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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// Adding new LLM provider doesn't modify buildClient
private def buildClient(config: ProviderConfig): Result[LLMClient] =
  config match {
    case cfg: OpenAIConfig => OpenAIClient(cfg)
    case cfg: AnthropicConfig => AnthropicClient(cfg)
    case cfg: OllamaConfig => OllamaClient(cfg)
    case cfg: GeminiConfig => GeminiClient(cfg)
    // Adding new provider: just add new case, no modification to existing cases
  }

// LLMClient trait is closed (stable API)
trait LLMClient {
  def complete(conversation: Conversation, options: CompletionOptions): Result[Completion]

  def streamComplete(conversation: Conversation, options: CompletionOptions, onChunk: StreamedChunk => Unit): Result[Completion]
  // This API rarely changes
}

Bad example (violation):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// BAD: Adding new provider requires modifying existing code
class LLMService {
  def complete(provider: String, prompt: String): Result[String] = {
    if (provider == "openai") {
      // OpenAI-specific code
    } else if (provider == "anthropic") {
      // Anthropic-specific code
    } else if (provider == "gemini") { // <-- had to modify existing code
      // Gemini-specific code
    }
    // Every new provider requires editing this method
  }
}

llm4s approach:

ComponentClosed (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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// All LLMClient implementations must honor the contract:
trait LLMClient {
  // Contract: Returns Result[Completion] (never throws)
  // Contract: conversation is not mutated
  // Contract: options.maxTokens is respected if supported
  def complete(conversation: Conversation, options: CompletionOptions): Result[Completion]
}

// GOOD: OpenAIClient honors contract
class OpenAIClient(config: OpenAIConfig) extends LLMClient {
  def complete(conversation: Conversation, options: CompletionOptions): Result[Completion] =
    Safety.safely {
      // Returns Result, doesn't throw
      // Respects maxTokens
      // Doesn't mutate conversation
      ???
    }
}

// BAD: BrokenClient violates LSP
class BrokenClient(config: Config) extends LLMClient {
  def complete(conversation: Conversation, options: CompletionOptions): Result[Completion] = {
    if (options.maxTokens.isEmpty) throw new IllegalArgumentException("maxTokens required!") // VIOLATES: throws instead of returning Left
    conversation.messages.clear() // VIOLATES: mutates input
    Right(Completion("")) // VIOLATES: ignores options.maxTokens
  }
}

Review questions:

  1. Error semantics: Does subtype return Result[A] or does it throw?

    • ✅ Good: All paths return Result
    • ❌ Bad: Some paths throw exceptions
  2. Side effects: Does subtype have side effects base type doesn’t?

    • ✅ Good: Pure transformation, logging only
    • ❌ Bad: Mutates input, modifies global state
  3. Preconditions: Does subtype require more than base type?

    • ✅ Good: Accepts all valid inputs base type accepts
    • ❌ Bad: Rejects inputs base type would accept
  4. 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Base: Agent.run returns AgentState with final status
def run(query: String, tools: ToolRegistry): Result[AgentState]

// Implementation: SpecialistAgent
class SpecialistAgent(client: LLMClient) extends Agent(client) {
  override def run(query: String, tools: ToolRegistry): Result[AgentState] = {
    // ✅ GOOD: Returns AgentState
    // ✅ GOOD: Returns Result, doesn't throw
    // ✅ GOOD: Honors maxSteps limit
    super.run(query, tools).map(state => state.copy(metadata = specialistMetadata))
  }
}

// BAD: Would violate LSP
class BadAgent(client: LLMClient) extends Agent(client) {
  override def run(query: String, tools: ToolRegistry): Result[AgentState] = {
    if (query.isEmpty) throw new IllegalArgumentException() // ❌ THROWS instead of Result
    // ❌ Ignores maxSteps, runs unlimited
    // ❌ Returns incomplete AgentState
    ???
  }
}

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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// GOOD: Focused interface, only 6 methods, all essential
trait LLMClient {
  def complete(conversation: Conversation, options: CompletionOptions): Result[Completion]

  def streamComplete(conversation: Conversation, options: CompletionOptions, onChunk: StreamedChunk => Unit): Result[Completion]

  def getContextWindow(): Int

  def getReserveCompletion(): Int

  def getContextBudget(headroom: HeadroomPercent): TokenBudget

  def validate(): Result[Unit]

  def close(): Unit
}

// Embedding is separate interface (ISP - don't force LLMClient to provide embeddings)
trait EmbeddingClient {
  def embed(texts: Seq[String]): Result[Seq[Embedding]]

  def embedQuery(query: String): Result[Embedding]
}

// Agent depends only on what it needs
class Agent(client: LLMClient) // doesn't depend on EmbeddingClient

Bad example (violation):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// BAD: God interface with too many methods
trait LLMService {
  // Completion methods
  def complete(conversation: Conversation): Result[Completion]

  def streamComplete(conversation: Conversation, onChunk: StreamedChunk => Unit): Result[Completion]

  // Embedding methods (not all LLMs provide embeddings!)
  def embed(texts: Seq[String]): Result[Seq[Embedding]]

  def embedQuery(query: String): Result[Embedding]

  // Fine-tuning methods (most providers don't support)
  def createFineTuneJob(config: FineTuneConfig): Result[Job]

  def listFineTuneJobs(): Result[Seq[Job]]

  def cancelFineTuneJob(id: String): Result[Unit]

  // Moderation methods (OpenAI-specific)
  def moderate(text: String): Result[ModerationResult]

  // Token counting (provider-specific)
  def countTokens(text: String): Int

  // Rate limiting (not all providers need this)
  def getRateLimit(): RateLimit

  def setRateLimit(limit: RateLimit): Unit
}

// Problem: Ollama doesn't provide embeddings, fine-tuning, or moderation
// but must implement 12 methods, most throwing NotImplementedError

How to fix: Split into focused traits:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// Split into cohesive interfaces
trait LLMClient // completion only

trait EmbeddingClient // embeddings only

trait FineTuneClient // fine-tuning only

trait ModerationClient // moderation only

// Implementations pick what they support
class OpenAIClient extends LLMClient with EmbeddingClient with FineTuneClient with ModerationClient

class OllamaClient extends LLMClient with EmbeddingClient // no fine-tuning

class AnthropicClient extends LLMClient // no embeddings

Review questions:

  1. Are there methods that throw NotImplementedError? → Violates ISP
  2. Does the trait have >10 abstract methods? → Likely too broad
  3. 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 ConcreteClass in core business logic

Good example (modules/core/src/main/scala/org/llm4s/agent/Agent.scala):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// HIGH-LEVEL: Agent depends on LLMClient abstraction
class Agent(client: LLMClient) { // ✅ depends on trait, not OpenAIClient
  def run(query: String, tools: ToolRegistry): Result[AgentState] = {
    // Uses client polymorphically
    client.complete(conversation, options)
  }
}

// LOW-LEVEL: Concrete implementations
class OpenAIClient(config: OpenAIConfig) extends LLMClient

class AnthropicClient(config: AnthropicConfig) extends LLMClient

// WIRING: At application edge
for {
  config <- Llm4sConfig.provider() // edge: load config
  client <- LLMConnect.getClient(config) // edge: factory creates concrete client
  agent = new Agent(client) // inject abstraction
  result <- agent.run(query, tools)
} yield result

Dependency flow (correct):

1
2
3
4
High-level:  Agent  ------------>  LLMClient (abstraction)
                                        ^
                                        |
Low-level:                     OpenAIClient (implementation)

Bad example (violation):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// BAD: Agent depends on concrete implementation
class BadAgent {
  // ❌ Creates concrete dependency internally
  val client = new OpenAIClient(OpenAIConfig(
    ModelName("gpt-4"),
    ApiKey(sys.env("OPENAI_API_KEY")) // ❌ reads env directly
  ))

  def run(query: String): Result[String] = {
    client.complete(???) // ❌ locked to OpenAI, can't test with mock
  }
}

// Can't test BadAgent without real OpenAI API key
// Can't use BadAgent with Anthropic
// Can't configure client from outside

How to fix:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// GOOD: Inject abstraction
class GoodAgent(client: LLMClient) {
  def run(query: String): Result[String] = {
    client.complete(???)
  }
}

// Now testable, flexible, configurable
val testAgent = new GoodAgent(MockLLMClient())
val prodAgent = new GoodAgent(OpenAIClient(prodConfig))

Review questions:

  1. Dependency direction: Does core depend on infrastructure, or vice versa?

    • ✅ Good: Core defines traits, infrastructure implements them
    • ❌ Bad: Core imports concrete infrastructure classes
  2. Creation location: Where are concrete objects created?

    • ✅ Good: Factories at edges (LLMConnect.getClient)
    • ❌ Bad: new ConcreteClass in business logic
  3. Configuration: Where is config read?

    • ✅ Good: Llm4sConfig at app edges, injected as typed objects
    • ❌ Bad: sys.env("KEY") in core classes

Dependency inversion in llm4s architecture:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
┌─────────────────────────────────────┐
│   Application (samples/)            │ ← EDGE: wiring, factories
├─────────────────────────────────────┤
│   Core abstractions (org.llm4s)    │ ← HIGH-LEVEL: traits, business logic
│   - LLMClient trait                 │
│   - Agent, ToolRegistry             │
│   - Guardrail trait                 │
├─────────────────────────────────────┤
│   Implementations                   │ ← LOW-LEVEL: concrete classes
│   - OpenAIClient                    │
│   - AnthropicClient                 │
│   - Built-in tools                  │
└─────────────────────────────────────┘
        │ depends on (via traits)

Anti-pattern: Layers depending downwards:

1
2
3
4
5
6
7
8
9
// ❌ BAD: Core depending on concrete implementation
package org.llm4s.agent

import org.llm4s.llmconnect.openai.OpenAIClient // ❌ core imports concrete class

class Agent {
  val client = new OpenAIClient(.
..) // ❌ creates concrete dependency
}

Correct: Layers depending on abstractions:

1
2
3
4
5
6
7
8
// ✅ GOOD: Core depending on abstraction
package org.llm4s.agent

import org.llm4s.llmconnect.LLMClient // ✅ core imports trait

class Agent(client: LLMClient) { // ✅ injected abstraction
  // ...
}

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class ToolRegistrySpec extends AnyFlatSpec with Matchers {
  "ToolRegistry.execute" should "return result for known tool" in {
    val registry = new ToolRegistry(Seq(testTool))
    registry.execute(ToolCallRequest("test", ujson.Obj())) shouldBe a[Right[_, _]]
  }

  it should "return UnknownFunction error for unknown tool" in {
    val registry = ToolRegistry.empty
    registry.execute(ToolCallRequest("unknown", ujson.Obj())) shouldBe
      Left(ToolCallError.UnknownFunction("unknown"))
  }
}

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/scala2 and modules/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 +compile succeeds (both Scala versions)
  • sbt +test succeeds
  • sbt scalafmtCheckAll passes
  • 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, OAuthToken types have safe toString
  • 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 StringBuilder over string concatenation in loops
  • Avoid zip tuple churn when indices suffice

14.3 Concurrency safety

  • No blocking on global ExecutionContext
  • Blocking isolated to dedicated thread pool
  • No Await.result or Thread.sleep in 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:

1
if (user == null) throw new IllegalArgumentException("User required")

Comment:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
**[Must-fix]** Null check should use Option

**Guideline**: Code Review Checklist - Type Safety

**Issue**: Null checks hide the absence at type level and throw exceptions.

**Suggestion**:

```scala
user match {
  case Some(u) => processUser(u)
  case None => Left(ValidationError("User required", "user"))
}

Location: path/to/file.scala:42

15.2 Smell: Exception in domain logic

Bad:

1
2
3
4
def parseConfig(json: String): Config = {
  if (json.isEmpty) throw new IllegalArgumentException("Empty JSON")
  Config.parse(json)
}

Comment:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
**[Must-Fix]** Throwing exception in domain logic

**Guideline**: Code Review Checklist - Error Handling

**Issue**: Domain logic should return Result, not throw. Exceptions bypass type system.

**Suggestion**:

```scala
def parseConfig(json: String): Result[Config] =
  if (json.isEmpty) Left(ValidationError("Empty JSON", "json"))
  else Safety.safely(Config.parse(json))

Location: path/to/file.scala:15-18

15.3 Smell: Direct config read in core

Bad:

1
val apiKey = sys.env.getOrElse("API_KEY", "")

Comment:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
**[Blocker]** Direct environment read in core code

**Guideline**: Code Review Checklist - Configuration

**Issue**: Core code must not read config directly. This violates the config boundary enforced by Scalafix.

**Suggestion**: Accept config as constructor parameter, read at application edge:

```scala
class MyService(config: MyServiceConfig) {
  def doWork() = // use config.apiKey
}

Location: path/to/file.scala:10

15.4 Smell: Non-exhaustive match

Bad:

1
2
3
4
status match {
  case AgentStatus.Complete => "done"
  case _ => "other"
}

Comment:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
**[Should-fix]** Non-exhaustive pattern match on sealed ADT

**Guideline**: Code Review Checklist - Pattern Matching

**Issue**: Wildcard hides bugs if new cases are added. AgentStatus is sealed, so compiler can check exhaustiveness.

**Suggestion**: Match all cases explicitly:

```scala
status match {
  case AgentStatus.Complete => "done"
  case AgentStatus.InProgress => "running"
  case AgentStatus.WaitingForTools => "waiting"
  case AgentStatus.Failed(msg) => s"failed: $msg"
  case AgentStatus.HandoffRequested(_, _) => "handoff"
}

Location: path/to/file.scala:55-58

15.5 Smell: Blocking on global EC

Bad:

1
val result = Await.result(future, 10.seconds)

Comment:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
**[Must-Fix]** Blocking call on potentially shared ExecutionContext

**Guideline**: Performance and Resource Safety - Concurrency Safety

**Issue**: `Await.result` blocks a thread. If on global EC, can cause thread starvation.

**Suggestion**: Use async composition or isolate blocking to dedicated pool:

// Option 1: Stay async
future.map(processResult)

// Option 2: If blocking unavoidable, use dedicated pool
implicit val blockingEc = ExecutionContext.fromExecutor(Executors.newCachedThreadPool())

Location: path/to/file.scala:78

15.6 Smell: Missing error cause

Bad:

1
Try(operation).toEither.left.map(e => ProcessingError(e.getMessage))

Comment:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
**[Should-Fix]** Error cause not preserved

**Guideline**: Code Review Checklist - Error Handling

**Issue**: Stack trace lost. Makes debugging production issues very difficult.

**Suggestion**: Preserve the cause:

`Try(operation).toEither.left.map(e => ProcessingError(e.getMessage, cause = Some(e)))`
// Or use Safety.safely which preserves by default
`Safety.safely(operation)`

**Location**: `path/to/file.scala:32`

15.7 Smell: Fail-fast when accumulation needed

Bad:

1
2
3
4
5
6
def validateConfig(cfg: RawConfig): Result[Config] = for {
  name <- validateName(cfg.name)
  email <- validateEmail(cfg.email)
  age <- validateAge(cfg.age)
} yield Config(name, email, age)
// Problem: Returns only FIRST error, user must fix one at a time

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:

1
2
3
4
5
6
7
8
import cats.data.Validated
import cats.syntax.apply._
import cats.instances.list._

def validateConfig(cfg: RawConfig): Validated[List[String], Config] =
  (validateName(cfg.name), validateEmail(cfg.email), validateAge(cfg.age))
    .mapN(Config.apply)
// Returns ALL validation errors at once

Location: path/to/file.scala:15-19

15.8 Smell: Unsafe partial function

Bad:

1
2
3
4
5
val handler: PartialFunction[Event, Response] = {
  case UserCreated(id) => Created(id)
  case UserDeleted(id) => Deleted(id)
}
events.map(handler) // Throws MatchError for unknown events!

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// Option 1: Lift to Option
events.flatMap(handler.lift)

// Option 2: Explicit fallback
val safeHandler = handler.orElse[Event, Response] {
  case e => UnknownEvent(e.getClass.getSimpleName)
}
events.map(safeHandler)

// Option 3: collect with explicit filtering
events.collect(handler) // Silently drops unmatched

Location: path/to/file.scala:42-45

15.9 Smell: Comparing newtypes with ==

Bad:

1
2
def findConversation(id: String, convs: Seq[Conversation]): Option[Conversation] =
  convs.find(_.id.toString == id) // String comparison, loses type safety

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:

1
2
3
4
5
def findConversation(id: String, convs: Seq[Conversation]): Result[Conversation] =
  for {
    convId <- ConversationId.create(id)
    conv <- convs.find(_.id == convId).toRight(NotFoundError(s"Conversation $id"))
  } yield conv

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
**[Severity]** Complex control flow needs clarification

**Guideline**: [Section Name]

**Issue**: The nested pattern match has unclear error paths:

```mermaid
flowchart TD
    START["Input: String"] --> VALIDATE["Validate format"]
    VALIDATE -->|Valid| PARSE["Parse to Config"]
    VALIDATE -->|Invalid| ERR1["Left(ValidationError)"]
    PARSE -->|Success| BUILD["Build client"]
    PARSE -->|Failure| ERR2["Left(ParseError)"]
    BUILD -->|Success| DONE["Right(LLMClient)"]
    BUILD -->|Failure| ERR3["Left(ConnectionError)"]

    style START fill:#e1f5ff,stroke:#0066cc,color:#000
    style VALIDATE fill:#fff4e1,stroke:#cc8800,color:#000
    style PARSE fill:#f0e1ff,stroke:#8800cc,color:#000
    style BUILD fill:#f0e1ff,stroke:#8800cc,color:#000
    style DONE fill:#e1ffe1,stroke:#2d7a2d,color:#000
    style ERR1 fill:#ffe1e1,stroke:#cc0000,color:#000
    style ERR2 fill:#ffe1e1,stroke:#cc0000,color:#000
    style ERR3 fill:#ffe1e1,stroke:#cc0000,color:#000

Suggestion: Extract validation to separate function.

Location: path/to/file.scala:42-68

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

#### Sequence diagram template

```markdown
**[Severity]** Unclear cancellation semantics

**Guideline**: Architecture Review - Effect Boundaries

**Issue**: Cancellation flow between agent and tool execution is unclear:

```mermaid
sequenceDiagram
    participant A as Agent
    participant T as ToolRegistry
    participant E as External API

    A->>T: executeAll(tools)
    T->>E: HTTP request
    Note over E: Long operation
    A->>T: cancel()
    T->>E: Interrupt
    E-->>T: Cancelled
    T-->>A: Left(CancelledError)

Suggestion: Use ManagedResource for connection cleanup on cancellation.

Location: modules/core/.../ToolRegistry.scala:85-120

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20

#### State machine template

```markdown
**[Should-fix]** State transitions not exhaustive

**Guideline**: Pattern Matching

**Issue**: AgentStatus transitions don't handle all cases:

```mermaid
stateDiagram-v2
    [*] --> InProgress
    InProgress --> WaitingForTools
    WaitingForTools --> InProgress
    InProgress --> Complete
    InProgress --> Failed
    WaitingForTools --> Failed

    note right of WaitingForTools: Missing: WaitingForTools → Complete?

Suggestion: Add explicit transitions or document why they’re invalid.

Location: modules/core/.../AgentState.scala:28-45

1
2
3
4
5
6
7

### 16.5 Syntax rules

**Quote labels with special characters**:
```mermaid
flowchart LR
    A["Source: Unit => IO[Raw]"] --> B["Parse: Raw => IO[User]"]

Use tilde for generics:

classDiagram
    class Kleisli~F,A, B~

Color standards (always use all three):

1
style NodeName fill:#e1f5ff,stroke:#0066cc,color:#000

Semantic colors:

MeaningFillStrokeUsage
Success#e1ffe1#2d7a2dHappy path
Info#e1f5ff#0066ccInputs, neutral
Warning#fff4e1#cc8800Decisions
Error#ffe1e1#cc0000Failures
Process#f0e1ff#8800ccTransformations

Appendix A: Comment templates

A.1 Blocker template

1
2
3
4
5
6
7
8
9
**[Blocker]** {brief title}

**Guideline**: {Section Name}

**Issue**: {What is wrong and why it's a blocker}

**Suggestion**: {How to fix}

**Location**: `{file}:{lines}`

A.2 Must-fix template

[Must-Fix] {brief title}

Guideline: {Section Name}

Issue: {What is wrong}

Suggestion:

1
2
3
{
  code fix
}

Location: {file}:{lines}

A.3 Should-fix template

1
2
3
4
5
6
7
8
9
**[Should-Fix]** {brief title}

**Guideline**: {Section Name}

**Issue**: {What could be improved}

**Suggestion**: {How to improve}

**Location**: `{file}:{lines}`

A.4 Question template

1
2
3
4
5
6
7
**[Question]** {brief title}

Could you clarify {question}?

**Context**: {why you're asking}

**Location**: `{file}:{lines}`

Appendix B: llm4s examples Index

B.1 Error handling

PatternFileLines
Safety.safelymodules/core/src/main/scala/org/llm4s/core/safety/Safety.scala22-28
fromTrymodules/core/src/main/scala/org/llm4s/core/safety/Safety.scala25-28
Result chainingmodules/core/src/main/scala/org/llm4s/agent/Agent.scala929-949
Error ADTmodules/core/src/main/scala/org/llm4s/error/LLMError.scala21-50

B.2 Type safety

PatternFileLines
ApiKey redactionmodules/core/src/main/scala/org/llm4s/types/package.scala88-92
ConversationId smart constructormodules/core/src/main/scala/org/llm4s/types/package.scala115-121
ModelName validationmodules/core/src/main/scala/org/llm4s/types/package.scala719-738

B.3 ADTs and pattern matching

PatternFileLines
LLMProvider sealed ADTmodules/core/src/main/scala/org/llm4s/llmconnect/provider/LLMProvider.scala12-107
ToolCallError ADTmodules/core/src/main/scala/org/llm4s/toolapi/ToolCallError.scala(entire file)
AgentStatus ADTmodules/core/src/main/scala/org/llm4s/agent/AgentState.scala(AgentStatus section)

B.4 Factory pattern

PatternFileLines
LLMConnect factorymodules/core/src/main/scala/org/llm4s/llmconnect/LLMConnect.scala10-46
ToolRegistry.emptymodules/core/src/main/scala/org/llm4s/toolapi/ToolRegistry.scala138-143

B.5 Builder pattern

PatternFileLines
ToolBuildermodules/core/src/main/scala/org/llm4s/toolapi/ToolFunction.scala101-119

B.6 Resource management

PatternFileLines
ManagedResourcemodules/core/src/main/scala/org/llm4s/resource/ManagedResource.scala11-45
fileInputStreammodules/core/src/main/scala/org/llm4s/resource/ManagedResource.scala60-64

B.7 Strategy pattern

PatternFileLines
ToolExecutionStrategymodules/core/src/main/scala/org/llm4s/toolapi/ToolExecutionStrategy.scala(entire file)
executeAll dispatchmodules/core/src/main/scala/org/llm4s/toolapi/ToolRegistry.scala64-77

B.8 Guardrails

PatternFileLines
Guardrail traitmodules/core/src/main/scala/org/llm4s/agent/guardrails/Guardrail.scala14-47
InputGuardrailmodules/core/src/main/scala/org/llm4s/agent/guardrails/Guardrail.scala57-67
CompositeGuardrailmodules/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, or Validated
  • Enables generic error recovery code that works across effect types

Scala 2.13 Example (adapted from cats-course):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import cats.MonadError
import cats.instances.either._

type ErrorOr[A] = Either[String, A]
val monadError = MonadError[ErrorOr, String]

// Raise an error
val failure: ErrorOr[Int] = monadError.raiseError("invalid input")

// Handle/recover from errors
val recovered: ErrorOr[Int] = monadError.handleError(failure) {
  case "invalid input" => 0
  case _ => -1
}

// Ensure a condition holds (like filter with error)
val validated: ErrorOr[Int] = monadError.ensure(Right(5))("too small")(_ > 10)
// Result: Left("too small")

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 Either which short-circuits on first error, Validated collects all errors
  • Essential for form validation, config validation, and guardrail results

Scala 2.13 Example (adapted from cats-course):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import cats.data.Validated
import cats.instances.list._

def validateName(name: String): Validated[List[String], String] =
  Validated.cond(name.nonEmpty, name, List("Name cannot be empty"))

def validateEmail(email: String): Validated[List[String], String] =
  Validated.cond(email.contains("@"), email, List("Invalid email format"))

def validateAge(age: Int): Validated[List[String], Int] =
  Validated.cond(age >= 0 && age <= 150, age, List("Age must be 0-150"))

// Combine validations - ALL errors accumulated, not just first
def validateUser(name: String, email: String, age: Int): Validated[List[String], (String, String, Int)] =
  validateName(name)
    .combine(validateEmail(email))
    .combine(validateAge(age))
    .map { case ((n, e), a) => (n, e, a) }

// Example: validateUser("", "bad-email", -5)
// Returns: Invalid(List("Name cannot be empty", "Invalid email format", "Age must be 0-150"))

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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import cats.data.EitherT
import cats.instances.future._
import scala.concurrent.{Future, ExecutionContext}

implicit val ec: ExecutionContext = ExecutionContext.global

// Type alias for clarity
type AsyncResult[A] = EitherT[Future, String, A]

def fetchUser(id: Long): AsyncResult[User] =
  EitherT.fromOption(database.get(id), s"User $id not found")

def fetchPermissions(userId: Long): AsyncResult[Permissions] =
  EitherT.right(Future(loadPermissions(userId)))

def checkAccess(resource: String): AsyncResult[Boolean] = for {
  user <- fetchUser(42)
  perms <- fetchPermissions(user.id)
} yield perms.canAccess(resource)

// Extract the underlying Future[Either[String, Boolean]]
val result: Future[Either[String, Boolean]] = checkAccess("admin-panel").value

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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// Part 1: Type class definition (trait)
trait JsonWriter[T] {
  def write(value: T): String
}

// Part 2: Type class instances (implicit objects)
object JsonWriterInstances {
  implicit object StringWriter extends JsonWriter[String] {
    def write(value: String): String = s""""$value""""
  }

  implicit object IntWriter extends JsonWriter[Int] {
    def write(value: Int): String = value.toString
  }

  // Instance for custom types
  implicit def optionWriter[T](implicit w: JsonWriter[T]): JsonWriter[Option[T]] =
    new JsonWriter[Option[T]] {
      def write(value: Option[T]): String = value match {
        case Some(v) => w.write(v)
        case None => "null"
      }
    }
}

// Part 3: Companion object with summoner and apply
object JsonWriter {
  def write[T](value: T)(implicit writer: JsonWriter[T]): String =
    writer.write(value)

  def apply[T](implicit writer: JsonWriter[T]): JsonWriter[T] = writer
}

// Part 4: Extension methods (syntax)
object JsonWriterSyntax {
  implicit class JsonWriterOps[T](value: T) {
    def toJson(implicit writer: JsonWriter[T]): String = writer.write(value)
  }
}

// Usage

import JsonWriterInstances._
import JsonWriterSyntax._

"hello".toJson // "\"hello\""
42.toJson // "42"
Option(10).toJson // "10"
JsonWriter[String].write("test") // Summoner pattern

Scala 3 equivalent:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// Part 1: Type class definition
trait JsonWriter[T]:
  def write(value: T): String

// Part 2: Instances with 'given'
given stringWriter: JsonWriter[String] with
  def write(value: String): String = s""""$value""""

given intWriter: JsonWriter[Int] with
  def write(value: Int): String = value.toString

// Part 3: Companion with apply
object JsonWriter:
  def apply[T](using writer: JsonWriter[T]): JsonWriter[T] = writer

// Part 4: Extension methods
extension [T](value: T)
  def toJson(using writer: JsonWriter[T]): String = writer.write(value)

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 MatchError on undefined inputs
  • .lift converts to Option, enabling safe composition

Scala 2.13 example (adapted from scala-2-advanced):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// Dangerous: throws MatchError on unexpected input
val unsafeHandler: PartialFunction[String, String] = {
  case "hello" => "Hi there!"
  case "bye" => "Goodbye!"
}

// Safe: returns None for undefined inputs
val safeHandler: String => Option[String] = unsafeHandler.lift

safeHandler("hello") // Some("Hi there!")
safeHandler("unknown") // None (not MatchError)

// Chain with fallback
val combinedHandler = unsafeHandler.orElse[String, String] {
  case other => s"I don't understand: $other"
}

// Use isDefinedAt for checking
if (unsafeHandler.isDefinedAt(input)) {
  unsafeHandler(input)
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// COVARIANCE (+T): "produces T" - use for return types and immutable containers
trait Producer[+T] {
  def produce: T // OK: T in return position

  // def consume(t: T): Unit        // COMPILE ERROR: T in argument position
  def consumeSafe[U >: T](u: U): Unit // OK: widening workaround
}

// CONTRAVARIANCE (-T): "consumes T" - use for argument types
trait Consumer[-T] {
  def consume(t: T): Unit // OK: T in argument position

  // def produce: T                  // COMPILE ERROR: T in return position
  def produceSafe[U <: T]: U // OK: narrowing workaround
}

// INVARIANCE (T): both produces and consumes - use for mutable containers
trait MutableContainer[T] {
  def get: T // OK

  def set(t: T): Unit // OK
}

// Rule of thumb:
// - Collections of things: covariant (List[+A], Option[+A])
// - Actions on things: contravariant (Function1[-A, +B], Ordering[-A])
// - Mutable state: invariant

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import cats.data.Reader

case class AppConfig(dbUrl: String, apiKey: String, maxRetries: Int)

// Define computations that depend on config
val dbConnection: Reader[AppConfig, DbConnection] =
  Reader(config => DbConnection(config.dbUrl))

val apiClient: Reader[AppConfig, ApiClient] =
  Reader(config => ApiClient(config.apiKey))

// Compose dependent operations
def fetchUserData(userId: Long): Reader[AppConfig, UserData] = for {
  db <- dbConnection
  api <- apiClient
  user <- Reader[AppConfig, User](_ => db.getUser(userId))
  enriched <- Reader[AppConfig, UserData](_ => api.enrichUser(user))
} yield enriched

// Run with actual config at the edge
val config = AppConfig("jdbc:...", "sk-...", 3)
val userData: UserData = fetchUserData(42).run(config)

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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import cats.data.Writer
import cats.instances.vector._

type Logged[A] = Writer[Vector[String], A]

def compute(n: Int): Logged[Int] = {
  if (n <= 0) Writer(Vector("Starting computation"), 0)
  else for {
    _ <- Writer(Vector(s"Processing $n"), ())
    prev <- compute(n - 1)
    _ <- Writer(Vector(s"Computed result for ${n - 1}: $prev"), ())
  } yield prev + n
}

// Run and extract both value and logs
val (logs, result) = compute(5).run
// logs: Vector("Starting computation", "Processing 1", ..., "Processing 5", ...)
// result: 15

// Thread-safe: parallel computations have separate log buffers

import scala.concurrent.Future

Future(compute(100)).map(_.written) // Logs for this computation only
Future(compute(100)).map(_.written) // Separate logs

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// Add pattern matching to a Java class
class JavaPerson(val name: String, val age: Int)

object JavaPerson:
  def unapply(p: JavaPerson): Option[(String, Int)] =
    if p.age >= 0 then Some((p.name, p.age)) else None

val person = new JavaPerson("Alice", 30)
person match
  case JavaPerson(name, age) => s"$name is $age"
  case _ => "Invalid person"

// Boolean extractor for guards
object Adult:
  def unapply(p: JavaPerson): Boolean = p.age >= 18

object Minor:
  def unapply(p: JavaPerson): Boolean = p.age < 18

person match
  case Adult() => "Can vote"
  case Minor() => "Cannot vote"

// Sequence extractor for varargs patterns
object CommandArgs:
  def unapplySeq(cmd: String): Option[Seq[String]] =
    Some(cmd.split("\\s+").toSeq)

"run --verbose --dry-run" match
  case CommandArgs("run", args*) => s"Running with: ${args.mkString(", ")}"

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Without HKT: duplicate code for each container type
def sumList(nums: List[Int]): Int = nums.foldLeft(0)(_ + _)
def sumOption(num: Option[Int]): Int = num.getOrElse(0)

// With HKT: generic over any Foldable

import cats.Foldable
import cats.syntax.foldable._

def sumGeneric[F[_] : Foldable](nums: F[Int]): Int =
  nums.foldLeft(0)(_ + _)

sumGeneric(List(1, 2, 3)) // 6
sumGeneric(Option(5)) // 5
sumGeneric(Vector(1, 2, 3, 4)) // 10

// Define operations that work across effect types
trait Combinable[F[_]] {
  def map[A, B](fa: F[A])(f: A => B): F[B]

  def flatMap[A, B](fa: F[A])(f: A => F[B]): F[B]
}

def combine[F[_] : Combinable, A, B](fa: F[A], fb: F[B]): F[(A, B)] = {
  val C = implicitly[Combinable[F]]
  C.flatMap(fa)(a => C.map(fb)(b => (a, b)))
}

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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import cats.Eq
import cats.syntax.eq._
import cats.instances.int._
import cats.instances.string._

// These compile but are bugs:
1 == "1" // false (always), no compile warning
Some(1) == None // Works but Option[Int] vs Option[Nothing]

// Type-safe with Cats Eq:
// 1 === "1"        // COMPILE ERROR: no Eq[Int, String]
1 === 1 // true
"a" === "a" // true

// Custom Eq for domain types
case class UserId(value: Long)

implicit val userIdEq: Eq[UserId] = Eq.fromUniversalEquals

UserId(1) === UserId(1) // true
// UserId(1) === 1        // COMPILE ERROR

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
case class User(name: String, age: Int)

// Default instance in companion object (always in scope)
object User {
  implicit val defaultOrdering: Ordering[User] =
    Ordering.by(_.name) // Alphabetical by default
}

// Alternative instances in separate objects (import to use)
object UserOrderings {
  implicit val byAge: Ordering[User] =
    Ordering.by(_.age)

  implicit val byNameLength: Ordering[User] =
    Ordering.by(_.name.length)
}

// Usage
List(users).sorted // Uses defaultOrdering from companion

import UserOrderings.byAge

List(users).sorted // Now uses byAge (local scope wins)

// For tests: import alternative instances

import UserOrderings.byAge

testSortedByAge(users.sorted) // Uses test-friendly ordering

Implicit resolution priority (lowest to highest):

  1. Companion objects of involved types
  2. Imported scope
  3. 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), mapN runs effects independently
  • Essential for combining validations that don’t depend on each other
  • With Validated, accumulates all errors; with Either, short-circuits

Scala 2.13 example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import cats.Semigroupal
import cats.data.Validated
import cats.instances.list._
import cats.syntax.apply._ // for mapN

// Parallel validation - accumulates ALL errors
type ErrorsOr[T] = Validated[List[String], T]

def validateName(name: String): ErrorsOr[String] =
  if (name.nonEmpty) Validated.valid(name)
  else Validated.invalid(List("Name is empty"))

def validateAge(age: Int): ErrorsOr[Int] =
  if (age >= 0) Validated.valid(age)
  else Validated.invalid(List("Age is negative"))

def validateEmail(email: String): ErrorsOr[String] =
  if (email.contains("@")) Validated.valid(email)
  else Validated.invalid(List("Invalid email"))

// mapN combines ALL three validations, accumulating errors
case class User(name: String, age: Int, email: String)

def validateUser(name: String, age: Int, email: String): ErrorsOr[User] =
  (validateName(name), validateAge(age), validateEmail(email)).mapN(User.apply)

// Example: validateUser("", -1, "bad")
// Returns: Invalid(List("Name is empty", "Age is negative", "Invalid email"))

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import cats.Traverse
import cats.instances.list._
import cats.instances.future._
import cats.syntax.traverse._
import scala.concurrent.Future

val servers: List[String] = List("server1", "server2", "server3")
def getBandwidth(host: String): Future[Int] = Future(host.length * 80)

// BAD: Manual folding - verbose and error-prone
val allBandwidthsManual: Future[List[Int]] =
  servers.foldLeft(Future(List.empty[Int])) { (acc, host) =>
    for {
      list <- acc
      bandwidth <- getBandwidth(host)
    } yield list :+ bandwidth
  }

// GOOD: Using traverse - concise and clear
val allBandwidthsTraverse: Future[List[Int]] = servers.traverse(getBandwidth)

// If you already have List[Future[Int]], use sequence
val futures: List[Future[Int]] = servers.map(getBandwidth)
val allBandwidthsSequence: Future[List[Int]] = futures.sequence

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import cats.data.Kleisli
import cats.instances.option._

// Functions that return effects can't be composed with andThen
val validateLength: String => Option[String] = s =>
  if (s.length > 3) Some(s) else None

val validateFormat: String => Option[String] = s =>
  if (s.matches("[a-z]+")) Some(s) else None

// Kleisli enables composition
val validateLengthK: Kleisli[Option, String, String] = Kleisli(validateLength)
val validateFormatK: Kleisli[Option, String, String] = Kleisli(validateFormat)

// Compose: first validate length, then validate format
val validateBoth: Kleisli[Option, String, String] = validateLengthK.andThen(validateFormatK)

validateBoth.run("hello") // Some("hello")
validateBoth.run("hi") // None (length check fails)
validateBoth.run("HELLO") // None (format check fails)

// Can also use for-comprehension
val validateWithTransform: Kleisli[Option, String, String] = for {
  validated <- validateLengthK
  formatted <- validateFormatK
} yield formatted.toUpperCase

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import cats.data.State

// State[S, A] represents: S => (S, A)
case class ShoppingCart(items: List[String], total: Double)

def addToCart(item: String, price: Double): State[ShoppingCart, Double] =
  State { cart =>
    val newCart = ShoppingCart(item :: cart.items, cart.total + price)
    (newCart, newCart.total)
  }

// Compose state transformations
val shoppingSession: State[ShoppingCart, Double] = for {
  _ <- addToCart("Guitar", 500)
  _ <- addToCart("Strings", 19)
  total <- addToCart("Cable", 8)
} yield total

// Run with initial state
val (finalCart, finalTotal) = shoppingSession.run(ShoppingCart(Nil, 0)).value
// finalCart = ShoppingCart(List("Cable", "Strings", "Guitar"), 527.0)
// finalTotal = 527.0

// Useful State combinators
def get[S]: State[S, S] = State(s => (s, s))
def set[S](s: S): State[S, Unit] = State(_ => (s, ()))
def modify[S](f: S => S): State[S, Unit] = State(s => (f(s), ()))

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import cats.Semigroup
import cats.syntax.semigroup._ // for |+|
import cats.instances.int._
import cats.instances.string._
import cats.instances.option._
import cats.instances.map._

// Built-in instances
val sum = 1 |+| 2 |+| 3 // 6 (addition)
val concat = "Hello" |+| " World" // "Hello World"

// Option combines inner values
val opt1: Option[Int] = Some(1)
val opt2: Option[Int] = Some(2)
val combined = opt1 |+| opt2 // Some(3)

// Map combines values for matching keys
val map1 = Map("a" -> 1, "b" -> 2)
val map2 = Map("b" -> 3, "c" -> 4)
val merged = map1 |+| map2 // Map("a" -> 1, "b" -> 5, "c" -> 4)

// Custom Semigroup for domain types
case class Metrics(requests: Int, errors: Int, latencyMs: Double)

implicit val metricsSemigroup: Semigroup[Metrics] = Semigroup.instance { (m1, m2) =>
  Metrics(
    m1.requests + m2.requests,
    m1.errors + m2.errors,
    (m1.latencyMs + m2.latencyMs) / 2 // average
  )
}

val totalMetrics = metrics1 |+| metrics2 |+| metrics3

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import cats.Eval

// Immediate evaluation
val eager: Eval[Int] = Eval.now {
  println("Computing eagerly")
  42
} // Prints immediately

// Lazy, memoized (computed once when needed)
val lazyMemo: Eval[Int] = Eval.later {
  println("Computing lazily (once)")
  42
} // Nothing printed yet
lazyMemo.value // Prints "Computing lazily (once)", returns 42
lazyMemo.value // Returns 42 (no print - memoized)

// Lazy, not memoized (computed every time)
val lazyAlways: Eval[Int] = Eval.always {
  println("Computing always")
  42
}
lazyAlways.value // Prints and returns 42
lazyAlways.value // Prints again and returns 42

// Stack-safe recursion with defer
def factorial(n: BigInt): Eval[BigInt] =
  if (n <= 1) Eval.now(BigInt(1))
  else Eval.defer(factorial(n - 1).map(_ * n))

factorial(50000).value // Works! Regular recursion would stack overflow

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 ManagedResource pattern

Scala 2.13 example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
def withFileWriter[T](fileName: String)(handler: java.io.BufferedWriter => T): T = {
  val output = java.nio.file.Files.newBufferedWriter(java.nio.file.Paths.get(fileName))
  try handler(output)
  finally output.close()
}

def withFileReader[T](fileName: String)(handler: java.io.BufferedReader => T): T = {
  val input = java.nio.file.Files.newBufferedReader(java.nio.file.Paths.get(fileName))
  try handler(input)
  finally input.close()
}

// Usage - guaranteed cleanup
withFileWriter("output.txt") { writer =>
  writer.write("Hello, World!")
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
trait StrParser[T] {
  def parse(s: String): T
}

object StrParser {
  // Base instances
  implicit object ParseInt extends StrParser[Int] {
    def parse(s: String) = s.toInt
  }

  implicit object ParseBoolean extends StrParser[Boolean] {
    def parse(s: String) = s.toBoolean
  }

  // Recursive instance for Seq[T] - derives from StrParser[T]
  implicit def ParseSeq[T](implicit p: StrParser[T]): StrParser[Seq[T]] =
    new StrParser[Seq[T]] {
      def parse(s: String) = s.split(',').toSeq.map(p.parse)
    }

  // Recursive instance for tuples - derives from StrParser[T] and StrParser[V]
  implicit def ParseTuple[T, V](implicit p1: StrParser[T], p2: StrParser[V]): StrParser[(T, V)] =
    new StrParser[(T, V)] {
      def parse(s: String) = {
        val Array(left, right) = s.split('=')
        (p1.parse(left), p2.parse(right))
      }
    }
}

// Works for arbitrarily nested types!
def parseFromString[T](s: String)(implicit parser: StrParser[T]): T = parser.parse(s)

parseFromString[Seq[Int]]("1,2,3") // Seq(1, 2, 3)
parseFromString[(String, Int)]("foo=42") // ("foo", 42)
parseFromString[Seq[(Int, Boolean)]]("1=true,2=false") // Seq((1, true), (2, false))

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def retry[T](max: Int, delay: Int = 0)(f: => T): T = {
  var tries = 0
  var result: Option[T] = None
  var currentDelay = delay
  while (result.isEmpty) {
    try {
      result = Some(f)
    } catch {
      case e: Throwable =>
        Thread.sleep(currentDelay)
        currentDelay *= 2 // Exponential backoff
        tries += 1
        if (tries > max) throw e
        else println(s"failed, retry #$tries")
    }
  }
  result.get
}

// Usage
retry(max = 3, delay = 100) {
  httpClient.post(url, data)
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import scala.concurrent._, duration.Duration.Inf, java.util.concurrent.Executors

// GOOD: Dedicated thread pool for CPU-bound work
implicit val ec = ExecutionContext.fromExecutorService(
  Executors.newFixedThreadPool(8)
)

def hashFile(path: os.Path): String = {
  // CPU-intensive hashing
  val bytes = os.read.bytes(path)
  hashBytes(bytes)
}

// Sequential (slow)
val hashes1 = for (p <- os.list(dir)) yield hashFile(p)

// Parallel (fast) - uses dedicated pool
val futures = for (p <- os.list(dir)) yield Future {
  hashFile(p)
}
val hashes2 = futures.map(Await.result(_, Inf))

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def fetchAllThrottled(
                       urls: Seq[String],
                       maxConcurrency: Int
                     )(implicit ec: ExecutionContext): Future[Seq[Response]] = {

  def rec(remaining: Seq[String], inFlight: Seq[Future[Response]]): Future[Seq[Response]] = {
    if (remaining.isEmpty && inFlight.isEmpty) {
      Future.successful(Seq.empty)
    } else {
      // Take only maxConcurrency items at a time
      val (batch, rest) = remaining.splitAt(maxConcurrency - inFlight.size)
      val newFutures = batch.map(url => fetchAsync(url))

      // Wait for at least one to complete before continuing
      Future.firstCompletedOf(inFlight ++ newFutures).flatMap { _ =>
        val (done, stillInFlight) = (inFlight ++ newFutures).partition(_.isCompleted)
        done.map(_.value.get.get).toSeq +: rec(rest, stillInFlight)
      }
    }
  }

  rec(urls, Seq.empty).map(_.flatten)
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class ChatServer {
  // Shared mutable state
  private var messages = Vector.empty[(String, String)]
  private var openConnections = Set.empty[WsChannel]

  // GOOD: All access through synchronized blocks
  def addMessage(name: String, msg: String): Unit = synchronized {
    messages = messages :+ (name -> msg)
  }

  def getMessages(): Vector[(String, String)] = synchronized {
    messages
  }

  def addConnection(conn: WsChannel): Unit = synchronized {
    openConnections += conn
  }

  def removeConnection(conn: WsChannel): Unit = synchronized {
    openConnections -= conn
  }

  def broadcast(msg: String): Unit = synchronized {
    for (conn <- openConnections) conn.send(msg)
  }
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import upickle.default.{ReadWriter, macroRW}

sealed trait Rpc

object Rpc {
  case class IsDir(path: os.SubPath) extends Rpc

  case class Exists(path: os.SubPath) extends Rpc

  case class ReadBytes(path: os.SubPath) extends Rpc

  case class WriteOver(src: Array[Byte], path: os.SubPath) extends Rpc

  // Automatic serialization for all cases
  implicit val rw: ReadWriter[Rpc] = macroRW
}

// Handler with exhaustive matching
def handleRpc(rpc: Rpc): Response = rpc match {
  case Rpc.IsDir(path) => Response(os.isDir(root / path))
  case Rpc.Exists(path) => Response(os.exists(root / path))
  case Rpc.ReadBytes(path) => Response(os.read.bytes(root / path))
  case Rpc.WriteOver(src, path) => os.write.over(root / path, src); Response.ok
  // Compiler error if case is missing!
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
def sync(src: os.Path, dest: os.Path): Unit = {
  for (srcSubPath <- os.walk(src)) {
    val destSubPath = dest / srcSubPath.subRelativeTo(src)

    // Pattern match on (source state, dest state) tuple
    (os.isDir(srcSubPath), os.isDir(destSubPath)) match {
      case (false, true) | (true, false) =>
        // Type mismatch: one is file, other is dir
        os.copy.over(srcSubPath, destSubPath, createFolders = true)

      case (false, false) if !os.exists(destSubPath) ||
        !os.read.bytes(srcSubPath).sameElements(os.read.bytes(destSubPath)) =>
        // Both are files but content differs
        os.copy.over(srcSubPath, destSubPath, createFolders = true)

      case _ =>
        // Already in sync (both dirs, or both files with same content)
        ()
    }
  }
}

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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// Regular multi-param function
def concatenate(a: String, b: String, c: String): String = a + b + c

// Curried version enables partial application
def curriedConcat(a: String)(b: String)(c: String): String = a + b + c

// Create specialized functions
val greetWith = curriedConcat("Hello, ")(_: String)(", welcome!")
val greetDaniel = greetWith("Daniel") // "Hello, Daniel, welcome!"

// Partially applied functions (PAFs) with underscores
val insertName = concatenate("Hello, ", _: String, ", nice to meet you")
val greeting = insertName("Alice")

// Method to function conversion (eta-expansion)
def increment(x: Int): Int = x + 1
List(1, 2, 3).map(increment) // eta-expansion happens automatically

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// Instead of multiple overloads
def createAgent(client: LLMClient,
                tools: ToolRegistry,
                guardrails: Seq[Guardrail],
                memory: MemoryManager): Agent

// Curry for flexibility
def createAgent(client: LLMClient)
               (tools: ToolRegistry)
               (guardrails: Seq[Guardrail])
               (memory: MemoryManager): Agent

// Now create specialized factories
val standardAgent = createAgent(client)(ToolRegistry.core)(_: Seq[Guardrail])(_: MemoryManager)
val safeAgent = standardAgent(Seq(profanityFilter, lengthCheck))(_: MemoryManager)

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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// lazy val: computed once on first access
lazy val expensiveValue: String = {
  println("Computing...")
  Thread.sleep(1000)
  "Done"
}

// Call by name: evaluated every time
def byNameParam(n: => Int): Int = n + n + n // evaluates n three times

// Call by need: call by name + memoization
def byNeedParam(n: => Int): Int = {
  lazy val cached = n // evaluated once, reused
  cached + cached + cached
}

// withFilter is lazy (for-comprehension optimization)
val numbers = List(1, 25, 40, 5, 23)
val result = for {
  n <- numbers if n < 30 && n > 20 // lazy evaluation, short-circuits
} yield n

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):

1
2
3
4
5
6
// Good use case: delay expensive regex compilation
lazy val modelNamePattern = """^[a-zA-Z0-9\-_\.]+/[a-zA-Z0-9\-_\.]+$""".r

// Review question: Should this be lazy or eager?
val defaultClient = LLMConnect.getClient(config) // Eager - fails fast
lazy val cachedEmbeddings = loadEmbeddingsFromDisk() // Lazy - only if needed

Anti-pattern to watch for:

1
2
3
4
5
// BAD: Hides initialization errors
lazy val apiKey = sys.env("API_KEY") // Exception thrown on first use, not at startup

// GOOD: Fail fast
val apiKey = Llm4sConfig.apiKey() // Result[ApiKey], fails at app startup

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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
trait Instrumentalist {
  def play(): Unit
}

trait Singer {
  self: Instrumentalist => // self-type: must also implement Instrumentalist
  def sing(): Unit

  def perform(): Unit = {
    play() // can call Instrumentalist methods
    sing()
  }
}

// OK: implements both
class LeadSinger extends Singer with Instrumentalist {
  def sing() = println("singing")

  def play() = println("playing")
}

// ERROR: must also extend Instrumentalist
// class Vocalist extends Singer { } // won't compile

// Self-types vs inheritance
class A

class B extends A // B "is an" A (inheritance)

trait T

trait S {
  self: T =>} // S "requires a" T (self-type)

Cake pattern for dependency injection:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
trait LoggingComponent {
  def log(msg: String): Unit
}

trait ToolExecutionComponent {
  self: LoggingComponent =>
  def executeTool(name: String): Result[String] = {
    log(s"Executing $name") // can use LoggingComponent
    // ... execution logic
    Right("success")
  }
}

// Wire dependencies at edges
class Production extends ToolExecutionComponent with LoggingComponent {
  def log(msg: String) = println(s"[PROD] $msg")
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// Phantom type markers (no instances, just types)
sealed trait BuilderState

sealed trait Empty extends BuilderState

sealed trait HasModel extends BuilderState

sealed trait HasApiKey extends BuilderState

sealed trait Complete extends BuilderState

// Builder with phantom type tracking state
class LLMClientBuilder[State <: BuilderState] private(
                                                       model: Option[ModelName],
                                                       apiKey: Option[ApiKey]
                                                     ) {
  // Only available when Empty or HasModel
  def withModel(m: ModelName)(implicit ev: State =:= Empty): LLMClientBuilder[HasModel] =
    new LLMClientBuilder[HasModel](Some(m), apiKey)

  // Only available when HasModel
  def withApiKey(key: ApiKey)(implicit ev: State =:= HasModel): LLMClientBuilder[Complete] =
    new LLMClientBuilder[Complete](model, Some(key))

  // Only available when Complete
  def build()(implicit ev: State =:= Complete): Result[LLMClient] =
    for {
      m <- model.toRight(ConfigError("Missing model"))
      k <- apiKey.toRight(ConfigError("Missing API key"))
    } yield OpenAIClient(OpenAIConfig(m, k))
}

object LLMClientBuilder {
  def create(): LLMClientBuilder[Empty] = new LLMClientBuilder[Empty](None, None)
}

// Usage - compile-time safety
val client = LLMClientBuilder.create()
  .withModel(ModelName("gpt-4"))
  .withApiKey(ApiKey("sk-..."))
  .build() // OK

// Won't compile - missing steps
// LLMClientBuilder.create().build() // ERROR: Empty =!= Complete
// LLMClientBuilder.create().withApiKey(...) // ERROR: can't skip withModel

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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// Problem: losing type information
trait Animal {
  def breed: List[Animal] // returns generic Animal
}

class Cat extends Animal {
  override def breed: List[Animal] = List(new Cat, new Dog) // BUG! Can mix types
}

// Solution: F-bounded polymorphism
trait Animal[A <: Animal[A]] {
  def breed: List[Animal[A]] // A is bounded by Animal[A]
}

class Cat extends Animal[Cat] {
  override def breed: List[Animal[Cat]] = List(new Cat, new Cat) // type-safe
}

class Dog extends Animal[Dog] {
  override def breed: List[Animal[Dog]] = List(new Dog, new Dog, new Dog)
}

// Can still be broken
class Crocodile extends Animal[Dog] { // compiles but wrong
  override def breed = List(new Dog)
}

// Fix: F-bounded polymorphism + self-type
trait SafeAnimal[A <: SafeAnimal[A]] {
  self: A =>
  def breed: List[A]
}

class SafeCat extends SafeAnimal[SafeCat] {
  def breed: List[SafeCat] = List(new SafeCat)
}

// ERROR: Crocodile must also extend Dog
// class SafeCrocodile extends SafeAnimal[Dog] { } // won't compile

Applicability to llm4s: When reviewing fluent APIs and type hierarchies:

  • Provider configs that return their own type in withX methods
  • Tool builders that should return the same builder type
  • Guardrails that compose and return their type

Example for llm4s:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Without F-bounded: lose type information
trait CompletionOptions {
  def withMaxTokens(n: Int): CompletionOptions // returns base type
}

class OpenAIOptions extends CompletionOptions {
  def withTemperature(t: Double): OpenAIOptions = ???

  def withMaxTokens(n: Int): CompletionOptions = ??? // loses OpenAIOptions type!
}

// val opts = new OpenAIOptions().withMaxTokens(100).withTemperature(0.7) // ERROR

// With F-bounded: preserve concrete type
trait CompletionOptions[A <: CompletionOptions[A]] {
  self: A =>
  def withMaxTokens(n: Int): A
}

class OpenAIOptions extends CompletionOptions[OpenAIOptions] {
  def withTemperature(t: Double): OpenAIOptions = ???

  def withMaxTokens(n: Int): OpenAIOptions = ??? // returns OpenAIOptions
}

// Now this works
val opts = new OpenAIOptions().withMaxTokens(100).withTemperature(0.7) // OK

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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// Type members (alternative to type parameters)
trait Record {
  type Key // abstract type member

  def defaultValue: Key
}

class StringRecord extends Record {
  override type Key = String // refined in subclass

  override def defaultValue = ""
}

class IntRecord extends Record {
  override type Key = Int

  override def defaultValue = 0
}

// Dependent return type
def getDefault(record: Record): record.Key = record.defaultValue

val s: String = getDefault(new StringRecord) // returns String
val i: Int = getDefault(new IntRecord) // returns Int

// Path-dependent types
class Outer {
  class Inner

  def process(arg: Inner) = println(arg)

  def processGeneral(arg: Outer#Inner) = println(arg)
}

val outerA = new Outer
val outerB = new Outer

val innerA = new outerA.Inner // outerA.Inner is a unique type
val innerB = new outerB.Inner // outerB.Inner is a different type

// outerA.process(innerB) // ERROR: type mismatch
outerA.processGeneral(innerB) // OK: Outer#Inner is parent type

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
trait LLMProvider {
  type Config // type member
  type Response

  def createClient(config: Config): Result[LLMClient]

  def parseResponse(raw: String): Result[Response]
}

object OpenAIProvider extends LLMProvider {
  override type Config = OpenAIConfig
  override type Response = OpenAICompletion

  def createClient(config: OpenAIConfig) = ???

  def parseResponse(raw: String) = ???
}

// Generic function with dependent types
def initProvider[P <: LLMProvider](provider: P, config: provider.Config): Result[LLMClient] =
  provider.createClient(config) // config type matches provider.Config

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 AnyVal wrappers (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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
object SocialNetwork {
  // Opaque type: Name and String are separate types OUTSIDE this scope
  opaque type Name = String

  // Factory method in companion object
  object Name {
    def apply(str: String): Name = str
  }

  // Extension methods control API surface
  extension (name: Name)
    def length: Int = name.length // can use String API internally

  // Inside scope: Name <-> String freely
  def addFriend(person1: Name, person2: Name): Boolean =
    person1.length == person2.length // use full String API
}

// Outside scope: Name and String are unrelated

import SocialNetwork.*

val name = Name("Alice") // OK via factory
// val name2: Name = "Bob" // ERROR: Name != String
val len = name.length // OK via extension method

Opaque types with bounds:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
object Graphics {
  opaque type Color = Int
  opaque type ColorFilter <: Color = Int // ColorFilter is a subtype of Color

  val Red: Color = 0xFF000000
  val halfTransparency: ColorFilter = 0x88
}

case class OverlayFilter(c: Color)

val layer = OverlayFilter(halfTransparency) // ColorFilter <: Color, OK

Applicability to llm4s: When llm4s migrates to Scala 3 only:

  • Replace AnyVal newtypes (ApiKey, ModelName, ConversationId) with opaque types
  • Better performance (no boxing) and safer API (controlled surface)
  • Opaque types support inheritance, AnyVal doesn’t

Migration path:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// Current Scala 2.13 (llm4s)
final case class ApiKey(private val value: String) extends AnyVal {
  override def toString: String = "ApiKey(***)"

  def reveal: String = value
}

// Future Scala 3 (opaque types)
object types {
  opaque type ApiKey = String

  object ApiKey {
    def apply(value: String): ApiKey = value
  }

  extension (key: ApiKey)
    def reveal: String = key
    override def toString: String = "ApiKey(***)"
}

Review consideration: When reviewing type design in cross-compiled code:

  • Keep using AnyVal for 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

VersionDateChanges
0.92026-01-30Comprehensive 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.82026-01-30Added 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.72026-01-30Comprehensive 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.62026-01-30Comprehensive refactor of section 9 (SOLID) with real llm4s code examples, review checklists, violation detection, and practical guidance for each principle.
0.52026-01-30Added 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.42026-01-29Added 6 more Cats patterns to Appendix C covering Semigroupal, Traverse, Kleisli, State, Semigroup, and Eval.
0.32026-01-29Added 8 practical Scala patterns from Hands-on Scala Programming covering resource management, retry logic, parallel execution, and concurrency.
0.22026-01-29Added Appendix C with 12 advanced functional programming patterns from Cats including MonadError, Validated, type classes, and variance rules.
0.12026-01-29Initial version with CI bot contract, 15 review sections, severity rubric, comment templates, and llm4s code examples.
Next time, we'll talk about "Higher-Order Functions: Regular Functions, But With Trust Issues"