05 Feb 2026
Updated:

Build a code review operating system: prevent 2 AM incidents in serious codebases

You know how it goes. A pull-request gets approved with “LGTM” after a 1-2 minute glance. Three weeks later, that code crashes production at 2 AM because nobody noticed it was swallowing errors in a catch-all. The on-call engineer (probably you) spends four hours debugging something a reviewer could have caught in four minutes.

This post is about building a review system that prevents that. Not a checklist - an operating system.

This post is a practical code review system that works for two audiences at once: engineers who want actionable patterns, and PMs who want to understand why review rigor actually speeds things up.

The examples come from llm4s , a Scala library for building type-safe LLM applications. But the principles apply to any serious codebase. I’ll call out when something is llm4s-specific and when it generalizes.

This is a cleaned-up, public version of the code/design/infra/ops review checklist I use regularly.

What's in this guide
  • Executive summary for stakeholders (what this guarantees, what it prevents)
  • Copy/paste-ready review comments (see Common PR smells and how to comment )
  • 70+ functional programming patterns with Scala 2.13 and Scala 3 examples (Reader/Writer monads, Tagless Final, Kleisli, State, Traverse, HKT, Eq, mapN, recursive typeclasses, and more)
  • 10 design patterns with UML diagrams - how FP, OOP, and SOLID work together (Factory+ADT, Builder+Phantom Types, Singleton+DI, Strategy+Pattern Matching, State+ADT, Adapter+Type Classes, Decorator+HOFs, Composite+Semigroup, Observer+Callbacks, Command+ADTs)
  • Checklists for error handling, type safety, architecture, security, testing (including property-based testing)
  • 9 PR smell templates - copy-paste review comments with code fixes
  • Mermaid diagram templates for complex reviews with semantic color codes
  • Pattern maturity model (Level 1-4 progression with code examples)
  • llm4s examples index - find real patterns in the codebase
  • Phased adoption plan that doesn’t stall delivery
About the snippets
Some snippets here are shape-only and use ??? / ... as placeholders. When you want real, runnable code, jump to llm4s examples index and follow the links into the repo.

Executive summary (for PMs and stakeholders)

If you’re short on time, here’s what this review system guarantees, what it prevents, and why it matters for delivery.

What this system guarantees

GuaranteeHowBusiness impact
No silent failuresErrors are typed, explicit, and always handledFewer 2 AM pages, faster incident resolution
No accidental breaking changesAPI changes require explicit versioning decisionsPredictable upgrades for consumers
No security footgunsSecrets are redacted, LLM output is untrustedNo leaked API keys in logs, no prompt injection
No resource leaksConnections and handles are bracketedNo memory leaks in long-running services
Consistent patternsSame approach to the same problem across codebaseFaster onboarding, easier maintenance

What this system prevents

Common failure modes this catches:

  1. The swallowed exception - catch { case _ => } that hides real errors until production
  2. The config bomb - hardcoded secrets or environment reads scattered through core logic
  3. The breaking change surprise - API modification that breaks consumers without warning
  4. The concurrency bug - mutable state shared across threads without synchronization
  5. The unbounded cost - LLM calls without token limits or retry caps

How this affects velocity

Here’s the counterintuitive part: rigorous review speeds up delivery.

The math that convinced our PM

Short-term cost: PRs take 15-30 minutes longer to review thoroughly.

Long-term gain:

  • 60-80% reduction in production incidents from preventable bugs
  • 50% faster onboarding because code is consistent and documented
  • 70% fewer “fix the fix” PRs because issues are caught before merge
  • Near-zero emergency rollbacks from breaking changes

The ratio: 25:1. One hour of careful review prevents 25 hours of downstream work.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
Day 1:   PR merged with quick approval
Day 14:  Bug reported in production
Day 15:  4 hours debugging to find root cause
Day 16:  Fix PR, review, merge, deploy
Day 17:  Post-mortem meeting (5 people × 1 hour)
Day 18:  Follow-up tasks from post-mortem
─────────────────────────────────────────────
Total:   ~15 engineering hours + customer impact

vs.

Day 1:   Reviewer catches issue in 15 minutes
Day 1:   Author fixes in 20 minutes
Day 1:   PR merged correctly
─────────────────────────────────────────────
Total:   35 minutes

What contributors need to adopt

For engineers joining a codebase with this system:

  1. Use Result types for errors - No exceptions in domain logic
  2. Follow existing patterns - If there’s a factory, use it; if there’s a builder, use it
  3. Test both paths - Success and failure cases get equal attention
  4. Keep PRs small - Under 400 lines of diff, focused on one concern
  5. Write the “why” - PR description explains motivation, not just mechanics

Repository map and architecture invariants

Understanding the codebase structure is essential for effective review. Here’s the llm4s layout:

 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

Boundary map (core vs edge)

The same tree, in one diagram: what counts as core (highest review bar) vs edge (allowed to do IO) vs examples.

flowchart LR
    subgraph Core["Core library (highest review bar)"]
        CoreSrc["modules/core/src/main/scala/..."]
        Types["types/: Result[A], newtypes"]
        Errors["error/: LLMError ADT"]
        ToolApi["toolapi/: ToolRegistry, ToolFunction"]
        Agent["agent/: orchestration (core logic)"]
    end

    subgraph Boundaries["Allowed boundaries (still in core)"]
        Config["config/: ONLY place for env/config reads"]
        Safety["core/safety/: controlled try/catch boundary"]
        Resource["resource/: bracket/use/release"]
    end

    subgraph Edge["Edges + lower review bar"]
        Samples["modules/samples/: usage examples"]
        Workspace["modules/workspace/: runner/client"]
        Docs["docs/: user + internal docs"]
        Cross["modules/crossTest/: cross-version checks"]
    end

    Agent --> ToolApi
    Agent --> Types
    Agent --> Errors
    ToolApi --> Types
    Config --> Types
    Config --> Errors
    Safety --> Errors
    Resource --> Types
    Samples --> CoreSrc
    Workspace --> CoreSrc
    Docs --> CoreSrc
    Cross --> CoreSrc
    style Core fill:#e1f5ff,stroke:#0066cc,color:#000
    style Boundaries fill:#fff4e1,stroke:#cc8800,color:#000
    style Edge fill:#f0e1ff,stroke:#8800cc,color:#000
    style CoreSrc fill:#e1f5ff,stroke:#0066cc,color:#000
    style Types fill:#e1ffe1,stroke:#2d7a2d,color:#000
    style Errors fill:#e1ffe1,stroke:#2d7a2d,color:#000
    style ToolApi fill:#e1f5ff,stroke:#0066cc,color:#000
    style Agent fill:#e1f5ff,stroke:#0066cc,color:#000
    style Config fill:#fff4e1,stroke:#cc8800,color:#000
    style Safety fill:#fff4e1,stroke:#cc8800,color:#000
    style Resource fill:#fff4e1,stroke:#cc8800,color:#000
    style Samples fill:#f0e1ff,stroke:#8800cc,color:#000
    style Workspace fill:#f0e1ff,stroke:#8800cc,color:#000
    style Docs fill:#f0e1ff,stroke:#8800cc,color:#000
    style Cross fill:#f0e1ff,stroke:#8800cc,color:#000

Key public API surfaces

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

Architecture invariants

These are non-negotiable

Error Model: Result[A] = Either[LLMError, A] everywhere. No exceptions in domain logic.

Configuration Boundary: Core code never reads env vars or config directly. All config reads happen in org.llm4s.config. Enforced by Scalafix.

Purity Boundary: IO (HTTP, filesystem, time, randomness) stays at edges. Core logic is pure transformations.

Type Safety: Use newtypes (ModelName, ApiKey, ConversationId) to prevent confusion. ApiKey.toString is always redacted.

Cross-build: Scala 2.13.16 and Scala 3.7.1. Single shared source tree with ScalaCompat for tiny shims.

Scalafix enforcements

The .scalafix.conf enforces these rules in core sources:

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

Review principles and quality bar

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.

Pull request quality bar

A good pull request is:

  • Correct: Handles success, failure, edge cases, partial data
  • Safe: No secrets leaked, no tool abuse, no prompt injection footguns
  • Maintainable: Clear APIs, consistent patterns, minimal cleverness
  • Compatible: No accidental breaking changes to published artifacts
  • Measurable: Performance claims backed by benchmarks

What to review rigorously

AreaReview barWhy
modules/core public APIsHighest - treat as productConsumers depend on stability
modules/core internalsHigh - must follow patternsFoundation for everything else
Error handling changesHigh - affects all consumersErrors propagate everywhere
Config changesHigh - affects deploymentMisconfig breaks production
Security-relevant codeHighest - tool calling, secretsBreaches are existential
modules/samplesMedium - working examplesUsers copy these verbatim
modules/workspaceMedium - containerized runnerLower blast radius

Pull request review workflow (CI playbook)

PR review, in one diagram: the “operating system” is just a tight loop of gating -> classify -> fix -> re-check -> merge.

flowchart TD
    Start["Author opens PR"] --> Scope["Scope + risk summary<br/>(modules, blast radius)"]
    Scope --> CI["CI gates: +compile, +test, scalafmtCheckAll"]
    CI -->|Fail| FixCI["Fix failures<br/>(don’t review broken builds)"]
    FixCI --> CI
    CI -->|Pass| Bot["CI bot review:<br/>checklists + invariants"]
    Bot --> Human["Human review:<br/>design + readability + risks"]
    Human --> Classify{"Classify finding"}
    Classify -->|Blocker| Block["Blocker: must fix before merge"]
    Classify -->|Must - fix| Must["Must-fix: fix before merge"]
    Classify -->|Should - fix| Should["Should-fix: fix or justify"]
    Classify -->|Nit| Nit["Nit: optional, keep sparse"]
    Block --> Fix["Author fixes<br/>+ adds tests/docs"]
    Must --> Fix
    Should --> Fix
    Nit --> Fix
    Fix --> ReCI["Re-run CI + re-review"]
    ReCI --> CI
    Bot --> Merge{"All blockers + must-fix resolved?"}
    Human --> Merge
    Merge -->|No| Fix
    Merge -->|Yes| Ship["Merge + release notes<br/>(README/CHANGELOG if needed)"]
    style Start fill:#e1f5ff,stroke:#0066cc,color:#000
    style Scope fill:#fff4e1,stroke:#cc8800,color:#000
    style CI fill:#e1f5ff,stroke:#0066cc,color:#000
    style FixCI fill:#ffe1e1,stroke:#cc0000,color:#000
    style Bot fill:#f0e1ff,stroke:#8800cc,color:#000
    style Human fill:#f0e1ff,stroke:#8800cc,color:#000
    style Classify fill:#fff4e1,stroke:#cc8800,color:#000
    style Block fill:#ffe1e1,stroke:#cc0000,color:#000
    style Must fill:#ffe1e1,stroke:#cc0000,color:#000
    style Should fill:#fff4e1,stroke:#cc8800,color:#000
    style Nit fill:#e1f5ff,stroke:#0066cc,color:#000
    style Fix fill:#f0e1ff,stroke:#8800cc,color:#000
    style ReCI fill:#e1f5ff,stroke:#0066cc,color:#000
    style Merge fill:#fff4e1,stroke:#cc8800,color:#000
    style Ship fill:#e1ffe1,stroke:#2d7a2d,color:#000

CI review bot contract

When analyzing a PR, the CI bot MUST produce:

1. PR summary (3-5 sentences)

  • What the PR changes
  • Which modules are affected
  • Whether it’s a feature, fix, refactor, or docs change

2. Risk assessment

Risk 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

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

Severity triage, in one diagram: if you and I classify differently, the system breaks.

flowchart TD
    Start["Found an issue in PR"] --> Q1{"Does it break a gate<br/>or violate a core invariant?"}
    Q1 -->|Yes| Blocker["Blocker<br/>Fix before merge"]
    Q1 -->|No| Q2{"Is it correctness, security,<br/>or missing error handling?"}
    Q2 -->|Yes| Must["Must-fix<br/>Fix before merge"]
    Q2 -->|No| Q3{"Is it readability, naming,<br/>tests, or minor inefficiency?"}
    Q3 -->|Yes| Should["Should-fix<br/>Fix or justify deferral"]
    Q3 -->|No| Nit["Nit<br/>Optional suggestion"]
    style Start fill:#e1f5ff,stroke:#0066cc,color:#000
    style Q1 fill:#fff4e1,stroke:#cc8800,color:#000
    style Q2 fill:#fff4e1,stroke:#cc8800,color:#000
    style Q3 fill:#fff4e1,stroke:#cc8800,color:#000
    style Blocker fill:#ffe1e1,stroke:#cc0000,color:#000
    style Must fill:#ffe1e1,stroke:#cc0000,color:#000
    style Should fill:#fff4e1,stroke:#cc8800,color:#000
    style Nit fill:#e1f5ff,stroke:#0066cc,color:#000

Comment format

Every review comment MUST follow this format:

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`

Code review checklist (llm4s specific)

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

What’s happening here?

  • This snippet shows the minimal shape for: Error handling.
  • In review, confirm the types make the happy path obvious and the error path explicit.
General principle

Errors are values. They have types. They compose. They don’t surprise you at 2 AM.

When this doesn’t apply: At the absolute edge of your system where you interface with truly external code that throws, you need to catch. But do it once, at the boundary, and convert to your error type.

Configuration

  • No sys.env, System.getenv, 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)
}

What’s happening here?

  • This snippet shows the minimal shape for: Configuration.
  • In review, confirm the types make the happy path obvious and the error path explicit.
General principle
Core code never knows where config comes from. It receives typed, validated configuration objects.

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)}"
}

What’s happening here?

  • This snippet shows the minimal shape for: Type safety.
  • In review, confirm the types make the happy path obvious and the error path explicit.
Why this matters
If ApiKey.toString printed the actual key, your logs would leak secrets. Every security breach in our industry can be traced to logs, error messages, or stack traces containing credentials.

Pattern matching

  • Exhaustive on sealed traits (no unguarded case _)
  • Uses pattern matching for ADT dispatch, not isInstanceOf
  • Guards are explicit when partial matching is intentional
  • Wildcard case _ has logging or explicit justification

llm4s pattern (modules/core/src/main/scala/org/llm4s/llmconnect/provider/LLMProvider.scala:15-24):

 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"
  }
}

What’s happening here?

  • This snippet shows the minimal shape for: Pattern matching.
  • In review, confirm the types make the happy path obvious and the error path explicit.
Review aid
If control flow spans >5 cases or has unclear decision paths, consider requesting a Mermaid flowchart showing all branches (see Mermaid diagrams in reviews ).

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)

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
 9
10
11
12
13
def run(
         query: String,
         tools: ToolRegistry,
         inputGuardrails: Seq[InputGuardrail] = Seq.empty,
         outputGuardrails: Seq[OutputGuardrail] = Seq.empty,
...
): 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

What’s happening here?

  • This snippet shows the minimal shape for: for-Comprehensions.
  • In review, confirm the types make the happy path obvious and the error path explicit.

API and design review checklist

Public API changes

  • Backward compatible or explicitly breaking (with migration path)
  • Default parameters don’t change existing behavior
  • New required parameters fail at compile time, not runtime
  • Deprecations include @deprecated with since version and migration hint
  • Return types are explicit (no inferred public return types)

Type design

  • ADTs for closed sets (sealed trait + case classes/objects)
  • Plain traits for extension points (open for implementation)
  • Smart constructors for validated types (private constructor + factory returning Result)
  • Companion objects for default instances and factory methods

llm4s pattern - Smart constructor (modules/core/src/main/scala/org/llm4s/types/package.scala:115-121):

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"))
    }
}

What’s happening here?

  • This snippet shows the minimal shape for: Type design.
  • In review, confirm the types make the happy path obvious and the error path explicit.

Trait design

  • Small, focused traits (Interface Segregation)
  • No god traits with 10+ methods
  • Composition over inheritance
  • Self-types avoided unless strictly necessary

llm4s pattern (modules/core/src/main/scala/org/llm4s/llmconnect/LLMClient.scala):

 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 = ()
}

What’s happening here?

  • This snippet shows the minimal shape for: Trait design.
  • In review, confirm the types make the happy path obvious and the error path explicit.

Six methods, all essential, all related to the single responsibility of “interact with an LLM”.


Architecture review checklist

Dependency direction

  • Core depends on abstractions, not concrete implementations
  • No circular dependencies between packages
  • Providers implement core interfaces, not the reverse
  • Config injected at edges, not pulled from environment
graph TD
    subgraph "High-Level (Core)"
        Agent["Agent"]
        LLMClient["LLMClient (trait)"]
        ToolRegistry["ToolRegistry"]
    end

    subgraph "Low-Level (Implementations)"
        OpenAI["OpenAIClient"]
        Anthropic["AnthropicClient"]
        Gemini["GeminiClient"]
    end

    Agent --> LLMClient
    OpenAI --> LLMClient
    Anthropic --> LLMClient
    Gemini --> LLMClient
    style Agent fill:#e1f5ff,stroke:#0066cc,color:#000
    style LLMClient fill:#fff4e1,stroke:#cc8800,color:#000
    style ToolRegistry fill:#e1f5ff,stroke:#0066cc,color:#000
    style OpenAI fill:#e1ffe1,stroke:#2d7a2d,color:#000
    style Anthropic fill:#e1ffe1,stroke:#2d7a2d,color:#000
    style Gemini fill:#e1ffe1,stroke:#2d7a2d,color:#000

Effect boundaries

  • IO (HTTP, file, time, random) at application edges
  • Pure transformations in core
  • No hidden side effects in map/flatMap chains
  • Logging is the only acceptable side effect in core (use sparingly)

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

What’s happening here?

  • This snippet shows the minimal shape for: Resource management.
  • In review, confirm the types make the happy path obvious and the error path explicit.

Cross-version compatibility

  • Code compiles on both Scala 2.13 and 3.x
  • No Scala 3-only features in shared source (opaque types, inline, derives)
  • Version-specific code in src/main/scala-2.13 or src/main/scala-3
  • ScalaCompat used only for tiny shims (not dumping ground)
  • modules/crossTest tests pass on both versions

Functional programming patterns in llm4s

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)

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

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

What’s happening here?

  • The first version nests Future and Result, so you end up manually unpacking Right/Left at multiple levels.
  • EitherT lets you write one for-comprehension where failures short-circuit automatically; .value gives you back Future[Result[User]].

Advanced FP patterns you’ll actually use

These patterns show up in serious codebases. They’re not theoretical - they solve real problems when basic patterns (Option, Result, for-comprehensions) aren’t enough.

Reader monad: dependency injection without the ceremony

The problem: You have multiple functions that need the same configuration, and you’re tired of threading it through every signature.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// Bad: Config passed everywhere
def fetchUser(id: String, config: Config): Result[User] = ???
def enrichUser(user: User, config: Config): Result[EnrichedUser] = ???
def saveUser(user: EnrichedUser, config: Config): Result[Unit] = ???

// Composition is ugly
def process(id: String, config: Config): Result[Unit] = for {
  user <- fetchUser(id, config)
  enriched <- enrichUser(user, config)
  _ <- saveUser(enriched, config)
} yield ()

Good: Reader monad:

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

case class Config(dbUrl: String, apiKey: String)

// Functions return Reader[Config, A]
def fetchUser(id: String): Reader[Config, Result[User]] =
  Reader { config =>
    database.query(config.dbUrl, s"SELECT * FROM users WHERE id = '$id'")
  }

def enrichUser(user: User): Reader[Config, Result[EnrichedUser]] =
  Reader { config =>
    apiClient.enrich(user, config.apiKey)
  }

// Composition - config provided once at the end
val program: Reader[Config, Result[EnrichedUser]] = for {
  user <- fetchUser("user-123")
  enriched <- enrichUser(user)
} yield enriched

// Run with actual config
val result = program.run(prodConfig)

What’s happening here?

  • Reader[Config, A] is a function wrapper that delays config injection.
  • You compose operations without mentioning config in signatures.
  • Test by running with testConfig, prod with prodConfig.

llm4s application:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// Agent orchestration with implicit config
type ConfigReader[A] = Reader[Llm4sConfig, A]

def buildClient: ConfigReader[Result[LLMClient]] =
  Reader(config => LLMConnect.getClient(config.provider))

def createAgent: ConfigReader[Result[Agent]] = for {
  client <- buildClient
  tools <- Reader(_.toolRegistry)
} yield Agent(client.getOrElse(???), tools)

// Run with different configs for different environments
val devAgent = createAgent.run(devConfig)
val prodAgent = createAgent.run(prodConfig)

When to use:

  • Multiple functions need same dependencies (DB, API clients, config)
  • You want to test with different configs without changing signatures
  • Overkill for single function or two-function chains
sequenceDiagram
    participant Client
    participant Program as Reader[Config, A]
    participant FetchUser as fetchUser
    participant EnrichUser as enrichUser
    participant Config

    Client->>Program: program.run(prodConfig)
    activate Program
    Program->>FetchUser: apply(config)
    activate FetchUser
    FetchUser->>Config: config.dbUrl
    Config-->>FetchUser: dbUrl
    FetchUser-->>Program: Result[User]
    deactivate FetchUser

    Program->>EnrichUser: apply(config)
    activate EnrichUser
    EnrichUser->>Config: config.apiKey
    Config-->>EnrichUser: apiKey
    EnrichUser-->>Program: Result[EnrichedUser]
    deactivate EnrichUser

    Program-->>Client: Result[EnrichedUser]
    deactivate Program

    note over Program: Config injected once<br/>Threaded through all steps
    note over FetchUser,EnrichUser: Functions never mention<br/>config in signatures

Writer monad: logging without side effects

The problem: You want to accumulate logs alongside computations, but println is a side effect that doesn’t compose.

Good: Writer monad

 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.data.Writer

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

def validateEmail(email: String): Logged[Result[Email]] =
  if (email.contains("@"))
    Writer(Vector(s"Valid email: $email"), Right(Email(email)))
  else
    Writer(Vector(s"Invalid email: $email"), Left(ValidationError("Missing @")))

def validateAge(age: Int): Logged[Result[Int]] =
  if (age >= 0)
    Writer(Vector(s"Valid age: $age"), Right(age))
  else
    Writer(Vector(s"Negative age: $age"), Left(ValidationError("age", "Negative")))

// Logs accumulate automatically
val program = for {
  email <- validateEmail("[email protected]")
  age <- validateAge(30)
} yield (email, age)

val (logs, result) = program.run
// logs: Vector("Valid email: [email protected]", "Valid age: 30")

What’s happening here?

  • Writer[Vector[String], A] carries both a value (A) and accumulated logs (Vector[String]).
  • Each operation adds its log entry; composition merges them.
  • Extract final logs with .run - no side effects during composition.

When to use:

  • You need structured audit trails for compliance
  • Debugging complex pipelines (trace intermediate steps)
  • Don’t use for real-time logging (buffering overhead) - use regular logging for that
flowchart TD
    Start[Start: Empty logs] --> ValidateEmail[validateEmail]

    ValidateEmail --> EmailCheck{Email valid?}
    EmailCheck -->|Yes| EmailLog["Log: Valid email<br/>Value: Right(Email)"]
    EmailCheck -->|No| EmailFail["Log: Invalid email<br/>Value: Left(Error)"]

    EmailLog --> ValidateAge[validateAge]

    ValidateAge --> AgeCheck{Age >= 0?}
    AgeCheck -->|Yes| AgeLog["Logs: [email log, Valid age]<br/>Value: Right(30)"]
    AgeCheck -->|No| AgeFail["Logs: [email log, Negative age]<br/>Value: Left(Error)"]

    AgeLog --> Result["Final: (Vector of logs, result)"]
    EmailFail --> ShortCircuit[Short-circuit with logs]
    AgeFail --> ShortCircuit

    style EmailLog fill:#e1ffe1,stroke:#2d7a2d,color:#000
    style AgeLog fill:#e1ffe1,stroke:#2d7a2d,color:#000
    style EmailFail fill:#ffe1e1,stroke:#cc0000,color:#000
    style AgeFail fill:#ffe1e1,stroke:#cc0000,color:#000
    style Result fill:#e1f5ff,stroke:#0066cc,color:#000

Kleisli: composing functions that return monads

The problem: You have functions A => F[B] and B => F[C], and you want to compose them like A => F[C].

Good: Kleisli

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

type ReaderIO[A] = Kleisli[IO, Config, A]

def fetchUser(id: String): ReaderIO[User] =
  Kleisli { config =>
    IO(database.query(config.dbUrl, id))
  }

def fetchPermissions(user: User): ReaderIO[Permissions] =
  Kleisli { config =>
    IO(permissions.get(user.id, config.permissionsEndpoint))
  }

// Kleisli composes Config => IO[A] functions
val program: ReaderIO[(User, Permissions)] = for {
  user <- fetchUser("user-123")
  perms <- fetchPermissions(user)
} yield (user, perms)

// Run with config
val io: IO[(User, Permissions)] = program.run(prodConfig)

What’s happening here?

  • Kleisli[IO, Config, A] is Config => IO[A] with composition built-in.
  • It’s Reader + IO combined - dependency injection + async effects.
  • andThen chains Kleisli arrows: (A => F[B]) andThen (B => F[C]) = (A => F[C]).

llm4s application:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// Agent pipeline with Kleisli
type AgentPipeline[A] = Kleisli[Result, Llm4sConfig, A]

val validateInput: AgentPipeline[String] =
  Kleisli(config => Safety.safely(config.inputGuardrails.validate(input)))

val callLLM: AgentPipeline[Completion] =
  Kleisli(config => config.client.complete(conversation))

// Chain pipelines
val pipeline = validateInput andThen callLLM

When to use:

  • Functions return monads (IO, Result, Future) and need shared dependencies
  • Building composable pipelines (ETL, agent orchestration)
  • Overkill if you just need Reader (no monad) or just IO (no dependencies)
flowchart LR
    subgraph Input["Input"]
        UserId["userId: String"]
    end

    subgraph K1["fetchUser<br/>Kleisli[IO, Config, User]"]
        K1_Config[Read Config] --> K1_IO[IO effect]
        K1_IO --> K1_User[User]
    end

    subgraph K2["fetchPermissions<br/>Kleisli[IO, Config, Permissions]"]
        K2_Config[Read Config] --> K2_IO[IO effect]
        K2_IO --> K2_Perms[Permissions]
    end

    subgraph Composed["Combined Pipeline<br/>Kleisli[IO, Config, (User, Perms)]"]
        Comp_Config[Config shared] --> Comp_Result["(User, Permissions)"]
    end

    UserId --> K1
    K1_User --> K2
    K1 -.andThen.-> K2
    K2 --> Composed

    Config[Config] -.injected once.-> Composed

    style K1 fill:#e1f5ff,stroke:#0066cc,color:#000
    style K2 fill:#e1f5ff,stroke:#0066cc,color:#000
    style Composed fill:#e1ffe1,stroke:#2d7a2d,color:#000
    style Config fill:#fff4e1,stroke:#cc8800,color:#000

State monad: pure stateful computations

The problem: You need to thread state through a sequence of operations without mutable vars.

Good: State monad

 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.data.State

case class GameState(score: Int, lives: Int, level: Int)

type GameAction[A] = State[GameState, A]

def increaseScore(points: Int): GameAction[Int] =
  State { state =>
    val newState = state.copy(score = state.score + points)
    (newState, newState.score)
  }

def loseLife: GameAction[Int] =
  State { state =>
    val newState = state.copy(lives = state.lives - 1)
    (newState, newState.lives)
  }

// Composition
val game: GameAction[String] = for {
  _ <- increaseScore(100)
  _ <- increaseScore(50)
  livesLeft <- loseLife
  result <- State.pure(if (livesLeft > 0) "Continue" else "Game Over")
} yield result

val (finalState, message) = game.run(GameState(0, 3, 1)).value
// finalState: GameState(150, 2, 1), message: "Continue"

What’s happening here?

  • State[S, A] is a function S => (S, A) - takes old state, returns new state + value.
  • Composition threads state automatically - no manual passing.
  • Extract final state and value with .run(initialState).value.

llm4s equivalent:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
case class AgentState(conversation: Conversation, step: Int, toolCalls: Seq[ToolCallRequest])

type AgentAction[A] = State[AgentState, A]

def addMessage(msg: Message): AgentAction[Unit] =
  State.modify(state => state.copy(conversation = state.conversation.addMessage(msg)))

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

// Multi-step agent run
val agentRun: AgentAction[AgentState] = for {
  _ <- addMessage(userMessage)
  _ <- incrementStep
  _ <- addMessage(assistantMessage)
  _ <- incrementStep
  finalState <- State.get
} yield finalState

When to use:

  • Complex stateful logic (game loops, parsers, interpreters)
  • You want referential transparency (pure functions, testable)
  • Simple state - just use case class immutable updates
  • Concurrent state - use Ref[F, A] instead
stateDiagram-v2
    [*] --> S0: Start
    S0: GameState with score 0, lives 3, level 1

    S0 --> S1: increaseScore(100)
    S1: GameState with score 100, lives 3, level 1
    note right of S1: Pure function maps S to new S and value A

    S1 --> S2: increaseScore(50)
    S2: GameState with score 150, lives 3, level 1

    S2 --> S3: loseLife
    S3: GameState with score 150, lives 2, level 1
    note right of S3: Returns new state and livesLeft equals 2

    S3 --> [*]: game.run(initialState)
    note left of S3: State threaded automatically without manual passing

Traverse: sequence effects across collections

The problem: You have List[F[A]] and need F[List[A]] - fail-fast on first error, or collect all results.

Bad: manual recursion

1
2
3
4
5
6
7
def fetchAllManual(ids: List[String]): Result[List[User]] =
  ids.foldRight(Right(List.empty[User]): Result[List[User]]) { (id, acc) =>
    for {
      list <- acc
      user <- fetchUser(id)
    } yield user :: list
  }

Good: Traverse

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import cats.syntax.all._

val userIds = List("1", "2", "3")

// Traverse - fail-fast on first error
def fetchAllTraverse(ids: List[String]): Result[List[User]] =
  ids.traverse(fetchUser)

// Parallel traverse (with cats.Parallel)
import cats.Parallel
import cats.effect.IO

def fetchAllParallel(ids: List[String])(using P: Parallel[IO]): IO[List[User]] =
  ids.parTraverse(id => IO(fetchUser(id)))

What’s happening here?

  • traverse(f: A => F[B]): F[List[B]] sequences effects and collects results.
  • Fail-fast: first Left stops execution and returns that error.
  • parTraverse runs effects in parallel (requires Parallel instance).

llm4s application:

1
2
3
4
5
6
7
8
// Execute multiple tool calls, fail-fast on error
def executeTools(calls: List[ToolCallRequest]): Result[List[ToolCallResponse]] =
  calls.traverse { call =>
    toolRegistry.find(call.function.name) match {
      case Some(tool) => tool.execute(call)
      case None => Left(ToolNotFoundError(call.function.name))
    }
  }

When to use:

  • Apply effectful function to collection and collect results
  • Fail-fast error handling (first error stops everything)
  • Parallel execution with parTraverse
  • Don’t need sequencing - use map
  • Need to collect all errors - use Validated + traverse
flowchart TD
    Input["List[String]<br/>['1', '2', '3']"] --> Traverse[".traverse(fetchUser)"]

    Traverse --> Fetch1["fetchUser('1')<br/>Result[User]"]
    Traverse --> Fetch2["fetchUser('2')<br/>Result[User]"]
    Traverse --> Fetch3["fetchUser('3')<br/>Result[User]"]

    Fetch1 --> Check1{Success?}
    Check1 -->|Right| Acc1["Accumulate User1"]
    Check1 -->|Left| Fail[Short-circuit with error]

    Fetch2 --> Check2{Success?}
    Check2 -->|Right| Acc2["Accumulate User2"]
    Check2 -->|Left| Fail

    Fetch3 --> Check3{Success?}
    Check3 -->|Right| Acc3["Accumulate User3"]
    Check3 -->|Left| Fail

    Acc1 --> Acc2
    Acc2 --> Acc3
    Acc3 --> Result["Result[List[User]]<br/>Right([User1, User2, User3])"]

    Fail --> ErrorResult["Result[List[User]]<br/>Left(error)"]

    style Fetch1 fill:#e1f5ff,stroke:#0066cc,color:#000
    style Fetch2 fill:#e1f5ff,stroke:#0066cc,color:#000
    style Fetch3 fill:#e1f5ff,stroke:#0066cc,color:#000
    style Result fill:#e1ffe1,stroke:#2d7a2d,color:#000
    style ErrorResult fill:#ffe1e1,stroke:#cc0000,color:#000
    style Fail fill:#ffe1e1,stroke:#cc0000,color:#000

Higher-Kinded Types (HKT): abstract over type constructors

The problem: You want generic functions that work with any container (List, Option, Result, IO) without duplicating code.

Good: HKT

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Generic Functor for any F[_]
trait Functor[F[_]] {
  def map[A, B](fa: F[A])(f: A => B): F[B]
}

// Instances
given Functor[Option] with {
  def map[A, B](fa: Option[A])(f: A => B): Option[B] = fa.map(f)
}

given Functor[List] with {
  def map[A, B](fa: List[A])(f: A => B): List[B] = fa.map(f)
}

// Generic function works with any Functor
def increment[F[_]: Functor](container: F[Int]): F[Int] = {
  val F = summon[Functor[F]]
  F.map(container)(_ + 1)
}

increment(Some(5))       // Some(6)
increment(List(1, 2, 3)) // List(2, 3, 4)

What’s happening here?

  • F[_] is a type constructor - it takes a type and returns a type (List[], Option[]).
  • Functor[F[_]] abstracts over the container shape.
  • One implementation works for List, Option, Result, IO, etc.

llm4s application:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// Abstract over Result and IO
trait AsyncAlgebra[F[_]: Monad] {
  def complete(conv: Conversation): F[Completion]
  def execute(tool: ToolFunction[_, _]): F[String]
}

class LiveAgent[F[_]: Monad](client: LLMClient) extends AsyncAlgebra[F] {
  def complete(conv: Conversation): F[Completion] =
    summon[Monad[F]].pure(client.complete(conv).getOrElse(???))
}

// Use with different effect types
val resultAgent = new LiveAgent[Result](client)   // Synchronous
val ioAgent = new LiveAgent[IO](client)           // Asynchronous

When to use:

  • Writing libraries that work with multiple effect types
  • Tagless Final architecture (abstract over F[_])
  • Reusing algorithms across containers (Functor, Monad, Traverse)
  • Application code - usually just pick one effect type (IO or Result)
  • Beginner teams - start with concrete types, refactor to HKT later
classDiagram
    class Functor~F[_]~ {
        <<trait>>
        +map(fa: F[A])(f: A => B): F[B]
    }

    class FunctorOption {
        +map(fa: Option[A])(f: A => B): Option[B]
    }

    class FunctorList {
        +map(fa: List[A])(f: A => B): List[B]
    }

    class FunctorResult {
        +map(fa: Result[A])(f: A => B): Result[B]
    }

    class IncrementFunction {
        +increment~F[_]: Functor~(F[Int]): F[Int]
    }

    Functor <|.. FunctorOption : implements
    Functor <|.. FunctorList : implements
    Functor <|.. FunctorResult : implements

    IncrementFunction ..> Functor : uses F[_]

    note for Functor "F[_] is a type constructor<br/>Takes type, returns type"
    note for IncrementFunction "One function works for<br/>Option, List, Result, IO..."

    style Functor fill:#e1f5ff,stroke:#0066cc,color:#000
    style FunctorOption fill:#e1ffe1,stroke:#2d7a2d,color:#000
    style FunctorList fill:#e1ffe1,stroke:#2d7a2d,color:#000
    style FunctorResult fill:#e1ffe1,stroke:#2d7a2d,color:#000
    style IncrementFunction fill:#fff4e1,stroke:#cc8800,color:#000

Scala 3 type-level patterns: compile-time guarantees

These are Scala 3-specific features that catch bugs at compile time with zero runtime overhead. If you’re still on Scala 2, see the cross-compilation notes in scala-2-advanced patterns .

Opaque types: zero-cost domain modeling

The problem: You want type-safe wrappers (UserId vs String) without the runtime cost of case class wrappers.

Bad: case class wrapper (allocates objects):

1
2
3
4
5
6
7
8
case class UserId(value: String)
case class Email(value: String)

val userId = UserId("user-123")  // Heap allocation
val email = Email("[email protected]")  // Heap allocation

// Runtime overhead - wrapping/unwrapping everywhere
database.query(userId.value)

Good: opaque type:

 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
object Domain {
  opaque type UserId = String
  opaque type Email = String

  object UserId {
    def apply(value: String): UserId = value  // No allocation!
    extension (id: UserId)
      def value: String = id
  }

  object Email {
    def apply(value: String): Email = value
    extension (email: Email)
      def domain: String = email.split("@")(1)
  }
}

import Domain._

val userId: UserId = UserId("user-123")  // Zero cost
val email: Email = Email("[email protected]")

// Compile error: can't mix up types
// val badUserId: UserId = email  // Type mismatch!

// Zero runtime overhead - erases to String
database.query(userId)  // Passes String directly

What’s happening here?

  • opaque type creates a new type that’s only distinct at compile time.
  • At runtime, it’s literally the underlying type (String) - zero allocation.
  • Outside the defining scope (object Domain), you can’t mix up UserId and Email.
  • Inside the scope, they’re both String (for implementation convenience).

llm4s application:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
opaque type ModelName = String
opaque type ApiKey = String
opaque type ConversationId = String

object ModelName {
  def apply(value: String): ModelName = value
  val GPT4 = ModelName("gpt-4")
  val Claude = ModelName("claude-3-sonnet")
}

// Type-safe, no runtime cost
def getClient(model: ModelName, key: ApiKey): Result[LLMClient] = ???

// Can't accidentally swap arguments
getClient(apiKey, modelName)  // Compile error

When to use:

  • Domain primitives (UserId, OrderId, Money, Email)
  • Performance-critical code (hot paths, tight loops)
  • Preventing primitive obsession without overhead
  • Scala 2.13 - use AnyVal value classes instead
flowchart LR
    subgraph CompileTime["Compile Time"]
        direction TB
        UserId["UserId"]
        Email["Email"]
        String1["String"]

        UserId -.distinct from.-> Email
        UserId -.opaque to.-> String1
        Email -.opaque to.-> String1

        style UserId fill:#e1f5ff,stroke:#0066cc,color:#000
        style Email fill:#e1ffe1,stroke:#2d7a2d,color:#000
    end

    subgraph Runtime["Runtime"]
        direction TB
        String2["String"]
        String3["String (UserId erased)"]
        String4["String (Email erased)"]

        String3 --> String2
        String4 --> String2

        style String2 fill:#fff4e1,stroke:#cc8800,color:#000
        style String3 fill:#fff4e1,stroke:#cc8800,color:#000
        style String4 fill:#fff4e1,stroke:#cc8800,color:#000
    end

    CompileTime ==>|Erasure| Runtime

    note1["Inside Domain scope:<br/>UserId = String<br/>Email = String"]
    note2["Outside Domain scope:<br/>UserId ≠ Email<br/>(Compile error)"]
    note3["Zero allocation<br/>Zero overhead"]

    style note1 fill:#f0e1ff,stroke:#8800cc,color:#000
    style note2 fill:#f0e1ff,stroke:#8800cc,color:#000
    style note3 fill:#e1ffe1,stroke:#2d7a2d,color:#000

Union types: type-safe APIs without inheritance

The problem: You want to accept multiple unrelated types without creating artificial inheritance hierarchies.

Bad: sealed trait hierarchy (forces inheritance):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
sealed trait InputType
case class StringInput(value: String) extends InputType
case class IntInput(value: Int) extends InputType
case class BoolInput(value: Boolean) extends InputType

// Have to wrap primitives in case classes
def process(input: InputType): String = input match {
  case StringInput(s) => s"String: $s"
  case IntInput(i) => s"Int: $i"
  case BoolInput(b) => s"Boolean: $b"
}

process(StringInput("hello"))  // Wrapping overhead

Good: union type:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def process(value: String | Int | Boolean): String = value match {
  case s: String => s"String: $s"
  case i: Int => s"Int: $i"
  case b: Boolean => s"Boolean: $b"
}  // Compiler verifies exhaustiveness!

process("hello")  // No wrapping
process(42)       //
process(true)     //
// process(3.14)  // Compile error: Double not in union

What’s happening here?

  • String | Int | Boolean means “one of these types” - no inheritance required.
  • Pattern matching is exhaustive - compiler warns if you miss a case.
  • Zero runtime overhead - no wrapper classes.

Error handling with union types:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
sealed trait NotFound
sealed trait Unauthorized
sealed trait ServerError

type HttpError = NotFound | Unauthorized | ServerError

def handleError(error: HttpError): String = error match {
  case _: NotFound => "404: Not found"
  case _: Unauthorized => "401: Unauthorized"
  case _: ServerError => "500: Server error"
}

llm4s application:

1
2
3
4
5
6
7
type ToolResult = String | ujson.Value | Array[Byte]

def processToolOutput(result: ToolResult): String = result match {
  case s: String => s
  case json: ujson.Value => json.render()
  case bytes: Array[Byte] => new String(bytes)
}

When to use:

  • Accepting multiple primitive types without wrapping
  • Error modeling (union of error types)
  • API responses that can be different shapes
  • Complex domain modeling - use ADTs (sealed trait + case classes)
flowchart TD
    Input["value: String | Int | Boolean"] --> Match{Pattern Match}

    Match -->|case s: String| String["String branch<br/>s'String: $s'"]
    Match -->|case i: Int| Int["Int branch<br/>s'Int: $i'"]
    Match -->|case b: Boolean| Boolean["Boolean branch<br/>s'Boolean: $b'"]

    String --> Output[Output: String]
    Int --> Output
    Boolean --> Output

    Invalid["Double"] -.->|Compile Error| Match

    subgraph UnionType["Union Type = String | Int | Boolean"]
        T1[String]
        T2[Int]
        T3[Boolean]
    end

    note1["No inheritance hierarchy<br/>No wrapper classes<br/>Zero runtime overhead"]
    note2["Exhaustiveness checking:<br/>Compiler ensures all cases handled"]

    style String fill:#e1f5ff,stroke:#0066cc,color:#000
    style Int fill:#e1ffe1,stroke:#2d7a2d,color:#000
    style Boolean fill:#fff4e1,stroke:#cc8800,color:#000
    style Invalid fill:#ffe1e1,stroke:#cc0000,color:#000
    style UnionType fill:#f0e1ff,stroke:#8800cc,color:#000
    style note1 fill:#e1ffe1,stroke:#2d7a2d,color:#000
    style note2 fill:#e1f5ff,stroke:#0066cc,color:#000

Intersection types: compound capabilities

The problem: You need an object that implements multiple unrelated traits, but you don’t want to create a new trait just for the combination.

Good: intersection type

 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
trait Serializable {
  def serialize: String
}

trait Loggable {
  def log(): Unit
}

// Function requires BOTH capabilities
def processAndLog(obj: Serializable & Loggable): Unit = {
  obj.log()  // Both APIs available
  val data = obj.serialize
  println(data)
}

case class User(name: String) extends Serializable with Loggable {
  def serialize: String = s"""{"name":"$name"}"""
  def log(): Unit = println(s"User: $name")
}

processAndLog(User("Alice"))  // Has both traits

case class Order(id: Int) extends Serializable {
  def serialize: String = s"""{"id":$id}"""
}

// processAndLog(Order(1))  // Compile error: missing Loggable

What’s happening here?

  • Serializable & Loggable means “both traits required” - intersection of capabilities.
  • Compiler ensures the argument has all required methods.
  • No need to create trait SerializableAndLoggable extends Serializable with Loggable.

When to use:

  • Function parameters that need multiple capabilities
  • Avoiding trait proliferation (DRY)
  • Type-safe mixin composition
  • Domain modeling - use regular trait extension (with)
flowchart TB
    subgraph Capabilities["Available Capabilities"]
        Serializable["Serializable<br/>serialize()"]
        Loggable["Loggable<br/>log()"]
    end

    Serializable --> Intersection
    Loggable --> Intersection

    Intersection["Serializable & Loggable<br/>(Both required)"]

    Intersection --> ProcessAndLog["processAndLog(obj)"]

    ProcessAndLog --> CallLog["obj.log()"]
    ProcessAndLog --> CallSerialize["obj.serialize"]

    User["User<br/>extends Serializable with Loggable"] -->|Accepted| Intersection
    Order["Order<br/>extends Serializable only"] -.->|Compile Error| Intersection

    note1["Intersection Type:<br/>All capabilities must be present"]
    note2["No need to create<br/>SerializableAndLoggable trait"]

    style Serializable fill:#e1f5ff,stroke:#0066cc,color:#000
    style Loggable fill:#e1ffe1,stroke:#2d7a2d,color:#000
    style Intersection fill:#fff4e1,stroke:#cc8800,color:#000
    style User fill:#e1ffe1,stroke:#2d7a2d,color:#000
    style Order fill:#ffe1e1,stroke:#cc0000,color:#000
    style note1 fill:#f0e1ff,stroke:#8800cc,color:#000
    style note2 fill:#e1f5ff,stroke:#0066cc,color:#000

Literal types: compile-time constants

The problem: You want to restrict a parameter to specific values, not just a type (e.g., only 3, not any Int).

Good: literal type:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
def exactThree(x: 3): String = "You passed exactly 3"

exactThree(3)  //
// exactThree(4)  // Compile error: 4 is not subtype of 3

// String literals
def handleStatus(status: "pending" | "complete" | "failed"): Unit = status match {
  case "pending" => println("In progress")
  case "complete" => println("Done")
  case "failed" => println("Error")
}

handleStatus("pending")  //
// handleStatus("unknown")  // Compile error

What’s happening here?

  • Literal types (3, "pending") are exact values, not type ranges.
  • Compiler rejects anything that doesn’t match the literal.
  • Combine with union types for enums without boilerplate.

Real-world example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE"
type StatusCode = 200 | 404 | 500

def handleRequest(method: HttpMethod, expectedStatus: StatusCode): Unit = {
  // Type-safe HTTP constants
  method match {
    case "GET" => println("Fetching")
    case "POST" => println("Creating")
    case "PUT" => println("Updating")
    case "DELETE" => println("Deleting")
  }
}

handleRequest("GET", 200)  //
// handleRequest("PATCH", 200)  // Compile error

llm4s application:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
type LLMProvider = "openai" | "anthropic" | "cohere"

def getClient(provider: LLMProvider): Result[LLMClient] = provider match {
  case "openai" => OpenAIClient.create
  case "anthropic" => AnthropicClient.create
  case "cohere" => CohereClient.create
}

// Typos caught at compile time
// getClient("opeanai")  // Compile error

When to use:

  • Enums with small, fixed sets of values
  • Configuration constants (environments, feature flags)
  • Preventing typos in string constants
  • Large enums - use enum keyword instead
  • Values known only at runtime - use regular types

Compile-time validation: catch drift before production

These patterns use macros to validate schemas, enforce state machines, and eliminate entire classes of runtime errors.

Schema conformance with macros

The problem: Producer schema changes break consumers at runtime. You only discover schema drift when data pipelines fail in production.

Good: compile-time schema validation:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
enum SchemaPolicy:
  case Exact      // Exact match required
  case Backward   // Producer can add optional fields
  case Forward    // Consumer can ignore extra fields

// Define contract
case class UserV1(id: Long, email: String)
case class UserV2(id: Long, email: String, age: Option[Int] = None)

// Compiles: V2 adds optional field (backward compatible)
summon[SchemaConforms[UserV2, UserV1, SchemaPolicy.Backward]]

// Compile error: V1 missing 'age' field that V2 expects
// summon[SchemaConforms[UserV1, UserV2, SchemaPolicy.Exact]]

What’s happening here?

  • Macro compares case class structures at compile time.
  • SchemaConforms[Producer, Contract, Policy] proves compatibility.
  • If schemas don’t match policy, code won’t compile.

Prevented at compile time:

  • Schema drift in Spark/Kafka pipelines
  • Breaking changes between microservices
  • Runtime schema mismatch errors

When to use:

  • Data pipelines with producer/consumer contracts
  • Spark schema evolution (prevent AnalysisException)
  • gRPC/Protobuf compatibility checks
  • Overkill for internal-only schemas with no external consumers

Phantom type state machines

The problem: You can call builder methods in the wrong order, creating invalid pipelines (e.g., .build() before .addSource()).

Good: phantom type builder:

 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
sealed trait BuilderState
sealed trait Empty extends BuilderState
sealed trait WithSource extends BuilderState
sealed trait WithTransform extends BuilderState
sealed trait Complete extends BuilderState

case class PipelineBuilder[S <: BuilderState, Contract] private (
  steps: List[PipelineStep]
) {
  def addSource[C](src: TypedSource[C]): PipelineBuilder[WithSource, C] = ???

  def transformAs[Next](f: DataFrame => DataFrame)(using
    ev: S <:< WithSource  // Can only call if state is WithSource
  ): PipelineBuilder[WithTransform, Next] = ???

  def addSink[R, P <: SchemaPolicy](sink: TypedSink[R])(using
    ev0: S <:< WithTransform,  // Must have transform
    ev1: SchemaConforms[Contract, R, P]  // Schema must conform
  ): PipelineBuilder[Complete, Contract] = ???

  def build(using ev: S =:= Complete): Pipeline = ???  // Only if Complete
}

// Usage
val pipeline =
  PipelineBuilder[UserContract]()
    .addSource(csvSource)       // Empty -> WithSource
    .transformAs[Enriched](...)  // WithSource -> WithTransform
    .addSink[Contract, Backward](sink) // WithTransform -> Complete
    .build                       // Only works when Complete

// Compile errors
// PipelineBuilder().build  // Can't build without source
// builder.addSink(...)     // Can't add sink before transform

What’s happening here?

  • S <: BuilderState is a phantom type - exists only at compile time, zero runtime overhead.
  • Each method returns a builder with a different state type.
  • Type constraints (S <:< WithSource) prevent calling methods in wrong order.

Impossible states made unrepresentable:

  • Can’t build pipeline without source
  • Can’t add sink before transform
  • Can’t skip required configuration steps

When to use:

  • Complex builders with required sequences (Spark jobs, HTTP clients, DSLs)
  • Preventing misconfiguration at compile time
  • Simple builders (2-3 optional methods) - phantom types are overkill
stateDiagram-v2
    [*] --> Empty: new PipelineBuilder()

    Empty --> WithSource: addSource(csvSource)
    note right of Empty: State is Empty - Can only call addSource

    WithSource --> WithTransform: transformAs(fn)
    note right of WithSource: State is WithSource - Can call transformAs with constraint ev

    WithTransform --> Complete: addSink(sink)
    note right of WithTransform: State is WithTransform - Can call addSink with constraint ev

    Complete --> [*]: build()
    note right of Complete: State is Complete - Can call build with equality constraint

    Empty --> CompileError1: build()
    Empty --> CompileError2: addSink()
    WithSource --> CompileError3: build()
    WithSource --> CompileError4: addSink()

    state CompileError1 {
        [*] --> Error: Type constraint failed
    }
    state CompileError2 {
        [*] --> Error: Type constraint failed
    }
    state CompileError3 {
        [*] --> Error: Type constraint failed
    }
    state CompileError4 {
        [*] --> Error: Type constraint failed
    }

    note left of CompileError1: Phantom types prevent<br/>invalid state transitions<br/>at compile time

Inline for zero-cost abstractions

The problem: Helper functions add overhead (call stack, allocation). You want abstraction without performance cost.

Good: inline:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
inline def square(inline x: Int): Int = x * x

val result = square(5)
// Compiles to: val result = 25 (no function call!)

// Conditional compilation
inline def debug(inline msg: String): Unit =
  inline if (BuildConfig.debugEnabled) then
    println(s"[DEBUG] $msg")

// In production (debugEnabled = false):
debug("Expensive computation: ${heavyOperation()}")
// Compiles to: () - nothing! heavyOperation() never called

What’s happening here?

  • inline tells compiler to replace function call with function body at every call site.
  • inline if evaluated at compile time - dead branches eliminated.
  • Zero runtime overhead - inlined code is exactly as if you copy-pasted it.

llm4s application:

1
2
3
4
5
6
inline def safely[A](inline operation: => A): Result[A] =
  inline if (BuildConfig.productionMode) then
    try { Right(operation) }
    catch { case e: Exception => Left(UnexpectedError(e.getMessage)) }
  else
    Right(operation)  // Skip try-catch in dev for better stack traces

When to use:

  • Performance-critical hot paths
  • Compile-time feature flags
  • Eliminating abstraction overhead
  • Large functions - increases bytecode size
  • Functions used in hundreds of places - code bloat

Design patterns: how FP, OOP, and SOLID work together

Scala supports both OOP and FP paradigms. This section shows how to blend design patterns with functional techniques, when to use which approach, and how they relate to SOLID principles.

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.

The pattern spectrum

flowchart LR
    subgraph PureFP["Pure FP"]
        FP1["ADTs, Monads"]
        FP2["Type Classes"]
        FP3["Immutable Data"]
        FP4["SOLID: SRP, ISP"]
    end

    subgraph Hybrid["Hybrid"]
        H1["Smart Constructors"]
        H2["Builder + Phantom Types"]
        H3["Resource Management"]
        H4["SOLID: All 5"]
    end

    subgraph PureOOP["Pure OOP"]
        OOP1["Mutable State"]
        OOP2["Inheritance Trees"]
        OOP3["Imperative Logic"]
        OOP4["SOLID: LSP, DIP"]
    end

    PureFP <-->|" Data transforms,<br/>validation, parsing "| Hybrid
    Hybrid <-->|" External APIs,<br/>stateful components "| PureOOP
    style PureFP fill:#e1ffe1,stroke:#2d7a2d,color:#000
    style Hybrid fill:#fff4e1,stroke:#cc8800,color:#000
    style PureOOP fill:#e1f5ff,stroke:#0066cc,color:#000
    style FP1 fill:#c8e6c9,stroke:#2d7a2d,color:#000
    style FP2 fill:#c8e6c9,stroke:#2d7a2d,color:#000
    style FP3 fill:#c8e6c9,stroke:#2d7a2d,color:#000
    style FP4 fill:#c8e6c9,stroke:#2d7a2d,color:#000
    style H1 fill:#ffe0b2,stroke:#cc8800,color:#000
    style H2 fill:#ffe0b2,stroke:#cc8800,color:#000
    style H3 fill:#ffe0b2,stroke:#cc8800,color:#000
    style H4 fill:#ffe0b2,stroke:#cc8800,color:#000
    style OOP1 fill:#bbdefb,stroke:#0066cc,color:#000
    style OOP2 fill:#bbdefb,stroke:#0066cc,color:#000
    style OOP3 fill:#bbdefb,stroke:#0066cc,color:#000
    style OOP4 fill:#bbdefb,stroke:#0066cc,color:#000

Guidelines:

  • Pure FP (Left): Data transformations, validation, business logic, parsing
  • Hybrid (Center): Object construction, resource management, external integrations
  • Pure OOP (Right): Rare in llm4s - only when external APIs demand it

Pattern synergies: combinations that strengthen each other

Pattern combinationSOLIDWhy they work togetherBenefit
Factory + smart constructors + Result + DIPDIPFactory returns trait (abstraction), smart constructors validate, Result for errorsType-safe creation, testable
Builder + phantom types + SRPSRPEach builder method has one job; phantom types track stateImpossible states unrepresentable
Strategy + ADTs + pattern matching + OCPOCPStrategy as sealed trait; add new strategy = add new caseExhaustive checks, open for extension
Adapter + type classes + ISPISPType classes adapt types without inheritance; focused interfacesNon-intrusive adaptation
Decorator + HOFs + Kleisli + SRPSRPEach decorator does one thing; HOFs composeComposable, no class explosion
State + state monad + for-comprehensions + SRPSRPEach state transformation is pure function with single jobPure, testable, no mutation

Pattern anti-synergies: combinations to avoid

Pattern combinationSOLID violationWhy they conflictWhat to do instead
Mutable state + pure functionsSRPBreaks referential transparencyUse State monad or Ref[F, A]
Deep inheritance + ADTsLSP, SRPADTs can’t extend multiple sealed traitsUse composition
Exceptions + ResultSRPMixing throw with Result breaks monad lawsConvert Try to Result at boundaries
Singleton + dependency injectionDIPGlobal state makes testing hardPass dependencies explicitly
God object + many responsibilitiesSRP, ISPOne class does everythingSplit into focused classes

Pattern review decision tree

flowchart TD
    Start([Reviewing Code]) --> CheckSOLID{Check SOLID<br/>violations first}
    CheckSOLID -->|God Object| SplitSRP[Violates SRP<br/>Split responsibilities]
    CheckSOLID -->|Concrete Deps| ApplyDIP[Violates DIP<br/>Depend on abstractions]
    CheckSOLID -->|No Violations| Question1{Is it data<br/>transformation?}
    Question1 -->|Yes| FP[Use Pure FP:<br/>ADTs, Pattern Matching,<br/>Monads]
    Question1 -->|No| Question2{Is it object<br/>construction?}
    Question2 -->|Yes| Question3{Complex validation<br/>or many params?}
    Question3 -->|Yes| Builder[Use Builder +<br/>Phantom Types]
    Question3 -->|No| Factory[Use Factory +<br/>Smart Constructors]
    Question2 -->|No| Question4{Is it external<br/>integration?}
    Question4 -->|Yes| TypeClass[Use Type Classes<br/>or Adapter Pattern]
    Question4 -->|No| Question5{Is it polymorphic<br/>behavior?}
    Question5 -->|Yes| ADT[Use ADTs +<br/>Pattern Matching]
    Question5 -->|No| ReviewSOLID[Apply SOLID<br/>Principles]
    style Start fill:#e1f5ff,stroke:#0066cc,color:#000
    style CheckSOLID fill:#f5e1ff,stroke:#8800cc,color:#000
    style SplitSRP fill:#ffe1e1,stroke:#cc0000,color:#000
    style ApplyDIP fill:#ffe1e1,stroke:#cc0000,color:#000
    style FP fill:#e1ffe1,stroke:#2d7a2d,color:#000
    style Builder fill:#fff4e1,stroke:#cc8800,color:#000
    style Factory fill:#fff4e1,stroke:#cc8800,color:#000
    style TypeClass fill:#f0e1ff,stroke:#8800cc,color:#000
    style ADT fill:#e1ffe1,stroke:#2d7a2d,color:#000

Factory pattern: smart constructors + ADTs

classDiagram
    class LLMClient {
        <<trait>>
        +complete(Conversation, CompletionOptions) Result~Completion~
        +streamComplete(Conversation, CompletionOptions, onChunk) Result~Completion~
    }

    class ProviderConfig {
        <<sealedtrait>>
    }

    class OpenAIConfig {
        +model: ModelName
        +apiKey: ApiKey
    }

    class AnthropicConfig {
        +model: ModelName
        +apiKey: ApiKey
    }

    class LLMConnect {
        <<object>>
        +getClient(ProviderConfig) Result~LLMClient~
    }

    class OpenAIClient {
        +complete() Result~Completion~
    }

    class AnthropicClient {
        +complete() Result~Completion~
    }

    ProviderConfig <|-- OpenAIConfig
    ProviderConfig <|-- AnthropicConfig
    LLMClient <|.. OpenAIClient
    LLMClient <|.. AnthropicClient
    LLMConnect ..> ProviderConfig: uses
    LLMConnect ..> LLMClient: creates

OOP approach (classic Factory):

1
2
3
4
5
6
7
8
abstract class LLMClientFactory {
  def createClient(): LLMClient
}

final class OpenAIFactory extends LLMClientFactory {
  override def createClient(): LLMClient =
    new OpenAIClient(/* provider config */)
}

What’s happening here?

  • This snippet shows the minimal shape for: Factory pattern: smart constructors + ADTs.
  • In review, confirm the types make the happy path obvious and the error path explicit.

FP approach (Smart constructors + ADTs):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 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

// 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)
  }

What’s happening here?

  • This snippet shows the minimal shape for: Factory pattern: smart constructors + ADTs.
  • In review, confirm the types make the happy path obvious and the error path explicit.

llm4s hybrid approach (LLMConnect.scala:10-46):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
object LLMConnect {
  def getClient(config: ProviderConfig): Result[LLMClient] =
    Safety.safely(buildClient(config))

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

What’s happening here?

  • This snippet shows the minimal shape for: Factory pattern: smart constructors + ADTs.
  • In review, confirm the types make the happy path obvious and the error path explicit.

Why this works:

  • DIP: High-level code depends on 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

Builder pattern: phantom types vs currying

classDiagram
    class BuilderState {
        <<sealedtrait>>
    }

    class Empty {
        <<phantomtype>>
    }

    class HasModel {
        <<phantomtype>>
    }

    class Complete {
        <<phantomtype>>
    }

    class LLMClientBuilder~S~ {
        -model: Option~ModelName~
        -apiKey: Option~ApiKey~
        +withModel(ModelName) LLMClientBuilder~HasModel~
        +withApiKey(ApiKey) LLMClientBuilder~Complete~
        +build() Result~LLMClient~
    }

    BuilderState <|-- Empty
    BuilderState <|-- HasModel
    BuilderState <|-- Complete
    LLMClientBuilder ..> BuilderState: S < BuilderState
    note for LLMClientBuilder "Phantom type S tracks builder state at compile time"

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

 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
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()  // COMPILE ERROR

What’s happening here?

  • S <: BuilderState is a phantom type: it exists only at compile time to track which steps you’ve done.
  • The implicit ev: S =:= ... constraints make illegal calls (like .build() too early) fail at compile time.
  • build() still returns Result[LLMClient] because “builder complete” is not the same thing as “config valid”.

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)

Singleton pattern: object in Scala

Singletons, in one diagram: object is fine for stateless shared wiring, but domain code should still depend on abstractions (DI) instead of reaching for globals.

flowchart LR
    subgraph Domain["Domain (core)"]
        Service["Service (depends on trait)"]
        Client["LLMClient (trait)"]
    end

    subgraph Wiring["Wiring (edge)"]
        Live["OpenAIClient (impl)"]
        Registry["ToolRegistry (object)"]
    end

    Service -->|depends on| Client
    Live -->|implements| Client
    Registry --> Service
    style Service fill:#e1f5ff,stroke:#0066cc,color:#000
    style Client fill:#fff4e1,stroke:#cc8800,color:#000
    style Live fill:#e1ffe1,stroke:#2d7a2d,color:#000
    style Registry fill:#f0e1ff,stroke:#8800cc,color:#000

OOP approach (Java singleton, avoid in Scala):

 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
}

What’s happening here?

  • You’re manually recreating something Scala already gives you with object.
  • It also nudges you towards hidden global state, which makes testing painful.

Scala approach (object is already a singleton):

 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

What’s happening here?

  • object is a single instance by definition, so callers just refer to it directly.
  • Keep objects either stateless or holding immutable state; if you need mutation, isolate and synchronize it.

Review checklist

  • Use 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

Strategy pattern: ADTs + pattern matching

classDiagram
    class ToolExecutionStrategy {
        <<sealedtrait>>
    }

    class Sequential {
        <<caseobject>>
    }

    class Parallel {
        <<caseobject>>
    }

    class ParallelWithLimit {
        +maxConcurrent: Int
    }

    class ToolRegistry {
        +executeAll(requests, strategy) AsyncResult
    }

    ToolExecutionStrategy <|-- Sequential
    ToolExecutionStrategy <|-- Parallel
    ToolExecutionStrategy <|-- ParallelWithLimit
    ToolRegistry ..> ToolExecutionStrategy: "pattern matches on"
    note for ToolExecutionStrategy "Adding new strategy = adding new case class"
 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)
  }
}

What’s happening here?

  • The “strategy” is data (a sealed ADT), not a subtype hierarchy with overridden methods.
  • Dispatch stays explicit in one place (match), and Scala enforces exhaustiveness when you add a new strategy.

Why ADTs are better for Strategy:

  • OCP: Add new strategy by adding case to sealed trait
  • Exhaustiveness: Compiler ensures all strategies handled
  • No runtime polymorphism: Direct dispatch, better performance
  • Serializable: ADTs can be serialized (strategies as data)

Review checklist

  • Strategy is sealed trait (exhaustive matching)
  • Strategies are immutable case objects/classes
  • Pattern matching covers all cases (no wildcard unless justified)
  • New strategy doesn’t require modifying existing strategies

State pattern: ADT + immutable state with copy

stateDiagram-v2
    [*] --> InProgress: Agent started
    InProgress --> WaitingForTools: Tool call requested
    WaitingForTools --> InProgress: Tools executed
    InProgress --> Complete: Task finished
    InProgress --> Failed: Error occurred
    WaitingForTools --> Failed: Tool error
    Complete --> [*]
    Failed --> [*]
    note right of InProgress: Each transition returns new AgentState
    note right of WaitingForTools: Pure functions, no mutation
 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
// ADT for status
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)
}

What’s happening here?

  • You model state as data (AgentState) plus a closed set of statuses (AgentStatus).
  • Every transition returns a new value via copy, so you can test transitions as pure functions and avoid “who mutated this?” bugs.

Why ADTs + immutable state are better:

  • Immutability: State transitions return new state, don’t mutate
  • Pattern matching: Explicit handling of all states
  • Serializable: Can save/restore agent state
  • Testable: Pure functions, easy to test transitions

Review checklist

  • State is represented as sealed ADT
  • State transitions are pure functions (return new state)
  • No mutable state fields (use copy for updates)
  • Illegal transitions return error or are no-ops

Adapter pattern: type classes

classDiagram
    class LLMClient {
        <<trait>>
        +complete(Conversation) Result~Completion~
    }

    class ThirdPartyLLM {
        <<externalAPI>>
        +generate(String) String
    }

    class LLMClientAdapter {
        -thirdParty: ThirdPartyLLM
        +complete(Conversation) Result~Completion~
    }

    class JsonWriter~A~ {
        <<typeclass>>
        +write(A) ujson.Value
    }

    LLMClient <|.. LLMClientAdapter
    LLMClientAdapter o-- ThirdPartyLLM: wraps

note for JsonWriter~A~ "Type class: adapts without inheritance"

OOP approach (classic Adapter):

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

What’s happening here?

  • This snippet shows the minimal shape for: Adapter pattern: type classes.
  • In review, confirm the types make the happy path obvious and the error path explicit.

FP approach (Type classes):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// 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
  )
}

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

What’s happening here?

  • JsonWriter[A] is a capability: “I know how to turn an A into JSON”.
  • You add behaviour by providing instances (JsonWriter[Completion]) without modifying Completion itself.
  • Call sites use toJson[A: JsonWriter] and let implicit resolution pick the right writer.

Why type classes are better for cross-cutting concerns:

  • ISP: Clients only import the type class instances they need
  • OCP: Add new type -> add new instance, don’t modify existing code
  • Composition: Type class instances compose

Review checklist

  • Adapter wraps external API, not internal types
  • Adapter translates data formats bidirectionally
  • For serialization/validation, prefer type classes over adapters
  • Adapter handles errors gracefully (returns Result)

Decorator pattern: higher-order functions

flowchart LR
    subgraph OOP["OOP: Class Decorators"]
        direction TB
        Base1["OpenAIClient"] --> Log1["LoggingDecorator"]
        Log1 --> Cache1["CachingDecorator"]
    end

    subgraph FP["FP: Higher-Order Functions"]
        direction TB
        Base2["baseCall"] --> Log2["withLogging(f)"]
        Log2 --> Cache2["withCaching(cache)(f)"]
    end

    OOP ---|" Same behavior<br/>Different approach "| FP
    style Base1 fill:#e1f5ff,stroke:#0066cc,color:#000
    style Log1 fill:#fff4e1,stroke:#cc8800,color:#000
    style Cache1 fill:#e1ffe1,stroke:#2d7a2d,color:#000
    style Base2 fill:#e1f5ff,stroke:#0066cc,color:#000
    style Log2 fill:#fff4e1,stroke:#cc8800,color:#000
    style Cache2 fill:#e1ffe1,stroke:#2d7a2d,color:#000

OOP approach (class decorators):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class LoggingDecorator(client: LLMClient) extends LLMClient {
  def complete(conversation: Conversation, options: CompletionOptions): Result[Completion] = {
    println(s"Calling LLM with ${conversation.messages.size} messages")
    val result = client.complete(conversation, options)
    println(s"LLM returned: ${result.isRight}")
    result
  }
}

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

What’s happening here?

  • This snippet shows the minimal shape for: Decorator pattern: higher-order functions.
  • In review, confirm the types make the happy path obvious and the error path explicit.

FP approach (Higher-order functions):

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

What’s happening here?

  • You represent “call the LLM” as a plain function type (Conversation => Result[Completion]).
  • Cross-cutting concerns become higher-order functions that wrap that call and return a new call.
  • Composition becomes wiring (pipe) instead of class stacking.

When to use which:

  • Traits with abstract override: Stateful decorators (logging, tracing, metrics)
  • HOFs: Stateless decorators (retry, timeout, caching)
  • Kleisli: When composing functions that return monadic types

Review checklist

  • Decorator doesn’t change core behavior, only adds cross-cutting concerns
  • Decorator is composable (can stack multiple)
  • For pure functions, prefer HOFs over trait decorators
  • Decorator handles errors from wrapped component

Composite pattern: recursive ADTs + Semigroup

classDiagram
    class Guardrail {
        <<sealedtrait>>
    }

    class Single {
        +check: String => Result~Unit~
    }

    class All {
        +guardrails: List~Guardrail~
    }

    class Any {
        +guardrails: List~Guardrail~
    }

    Guardrail <|-- Single
    Guardrail <|-- All
    Guardrail <|-- Any
    All o-- Guardrail: contains
    Any o-- Guardrail: contains
    note for Guardrail "Recursive ADT enables tree structures"
 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

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

What’s happening here?

  • The guardrail set is a recursive tree (Single, All, Any) so composition is just building bigger trees.
  • The interpreter (evaluate) is where behaviour lives: it walks the tree and returns Result[Unit].
  • The Semigroup[Guardrail] instance gives you a standard “combine” operator (|+|) for free.

Why ADTs are better for composites:

  • Pattern matching: Explicit handling of leaf vs composite
  • Immutability: ADTs are immutable by default
  • Semigroup: Algebraic composition via |+| operator

Review checklist

  • Composite and leaf share same interface
  • Composite delegates to children recursively
  • Empty composite has sensible behavior
  • Consider using Semigroup for algebraic composition

Observer pattern: callbacks with ADT events

sequenceDiagram
    participant C as Caller
    participant A as Agent
    participant CB as Callback (onEvent)
    C ->> A: run(query, onEvent)
    A ->> CB: StepStarted(0)
    A ->> A: Process step
    A ->> CB: ToolCallStarted("get_weather")
    A ->> A: Execute tool
    A ->> CB: AgentCompleted(state)
    A ->> C: Result[AgentState]
    note right of CB: ADT events ensure<br/>exhaustive handling
 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 java.time.Instant

// Event ADT
sealed trait AgentEvent

case class StepStarted(step: Int, at: Instant) extends AgentEvent

case class ToolCallStarted(name: String, at: Instant) extends AgentEvent

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

// Agent takes event callback
class Agent(client: LLMClient) {
  private def safeEmit(onEvent: AgentEvent => Unit, e: AgentEvent): Unit =
    try onEvent(e)
    catch {
      case _: Throwable => ()
    } // callback shouldn't break the agent

  def run(query: String, onEvent: AgentEvent => Unit = _ => ()): Result[AgentState] = {
    safeEmit(onEvent, StepStarted(0, Instant.now()))
    // ... execution logic
    safeEmit(onEvent, ToolCallStarted("get_weather", Instant.now()))
    // ... more logic
    safeEmit(onEvent, AgentCompleted(finalState, Instant.now()))
    Right(finalState)
  }
}

What’s happening here?

  • You model events as an ADT so callers can handle them exhaustively (no stringly-typed event names).
  • The “observer” is just a callback; you avoid registering/unregistering listeners and shared mutable lists.

Why callbacks over full Observer pattern:

  • Simplicity: No registration/unregistration boilerplate
  • Type safety: Events are ADT, exhaustive matching enforced
  • No mutable state: No observable/observer list to manage

Review checklist

  • Events are sealed ADT (exhaustive matching)
  • Callback parameter has default value for optional usage
  • Events include timestamp for debugging
  • Callback doesn’t throw (agent shouldn’t fail if callback fails)

Visitor pattern: zero-allocation JSON traversal

The problem: You need to transform JSON structures without allocating intermediate objects (critical for high-throughput systems).

Real-world example from toon4s (originally designed by Li Haoyi ):

 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
// Visitor trait - zero allocations
trait JsonVisitor[-T, +J] extends (T => J):
  def visitNull: J
  def visitBoolean(value: Boolean): J
  def visitNumber(value: Double): J
  def visitString(value: String): J
  def visitArray(items: Iterable[J]): J
  def visitObject(fields: Iterable[(String, J)]): J

// Concrete visitor: validate JSON depth
class DepthValidator(maxDepth: Int) extends JsonVisitor[Int, Int]:
  def visitNull: Int = 1
  def visitBoolean(value: Boolean): Int = 1
  def visitNumber(value: Double): Int = 1
  def visitString(value: String): Int = 1
  def visitArray(items: Iterable[Int]): Int =
    val depth = if items.isEmpty then 1 else items.max + 1
    if depth > maxDepth then throw new Exception(s"Max depth $maxDepth exceeded")
    depth
  def visitObject(fields: Iterable[(String, Int)]): Int =
    val depth = if fields.isEmpty then 1 else fields.map(_._2).max + 1
    if depth > maxDepth then throw new Exception(s"Max depth $maxDepth exceeded")
    depth

// Usage
val validator = new DepthValidator(maxDepth = 10)
val json = """{"user": {"profile": {"settings": {"theme": "dark"}}}}"""
val depth = ujson.read(json).transform(validator)  // 4

What’s happening here?

  • JsonVisitor defines operations (visit methods) without tying them to specific data structures.
  • Each visit method processes one JSON node type.
  • No intermediate allocations - results accumulate directly.

Why Visitor over pattern matching:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// Pattern matching - allocates case class wrappers
sealed trait JsonValue
case class JObject(fields: Map[String, JsonValue]) extends JsonValue
case class JArray(items: List[JsonValue]) extends JsonValue
case class JString(value: String) extends JsonValue
// ... more allocations

def depth(json: JsonValue): Int = json match {
  case JObject(fields) => 1 + fields.values.map(depth).max
  case JArray(items) => 1 + items.map(depth).max
  case _ => 1
}  // Every node creates wrapper objects!

// Visitor - zero allocations
// Processes raw JSON directly via visitor callbacks

Performance comparison (toon4s benchmarks):

  • Pattern matching: ~2000 allocations/sec, 50μs GC pauses
  • Visitor: 0 allocations, 0 GC overhead

When to use:

  • High-throughput systems (JSON parsing, serialization)
  • Performance-critical hot paths
  • Large nested structures (deep JSON, ASTs)
  • Simple one-off transformations - pattern matching is clearer
sequenceDiagram
    participant Client
    participant JSON as JSON Structure
    participant Visitor as DepthValidator

    Client->>JSON: ujson.read(json)
    activate JSON

    JSON->>Visitor: visitObject(fields)
    activate Visitor

    loop For each field
        JSON->>Visitor: visitString("user")
        Visitor-->>JSON: depth = 1

        JSON->>Visitor: visitObject(profile)
        activate Visitor

        JSON->>Visitor: visitString("profile")
        Visitor-->>JSON: depth = 1

        JSON->>Visitor: visitObject(settings)
        activate Visitor

        JSON->>Visitor: visitString("theme")
        Visitor-->>JSON: depth = 1

        JSON->>Visitor: visitString("dark")
        Visitor-->>JSON: depth = 1

        deactivate Visitor
        Visitor-->>JSON: depth = 3
        deactivate Visitor
        Visitor-->>JSON: depth = 4
    end

    deactivate Visitor
    JSON-->>Client: Final depth = 4

    deactivate JSON

    note over Visitor: Zero allocations<br/>No intermediate objects<br/>Direct traversal
    note over JSON,Visitor: Each visit method processes<br/>one node type without<br/>creating wrapper classes

Review checklist:

  • Visitor methods return consistent type (don’t mix Unit and values)
  • No mutable state in visitor (keep it pure)
  • Handle all node types (exhaustiveness)
  • Benchmark vs pattern matching if claiming “zero allocation”

Command pattern: ADTs as operations

classDiagram
    class Operation {
        <<sealedtrait>>
    }

    class CreateFile {
        +path: String
        +content: String
    }

    class DeleteFile {
        +path: String
    }

    class UpdateFile {
        +path: String
        +newContent: String
    }

    class Interpreter {
        +execute(Operation) Result~String~
        +undo(Operation) Operation
    }

    Operation <|-- CreateFile
    Operation <|-- DeleteFile
    Operation <|-- UpdateFile
    Interpreter ..> Operation: interprets
    note for Operation "Commands are data:<br/>serializable, composable, inspectable"
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 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) => Right(s"Created $path")
  case DeleteFile(path) => Right(s"Deleted $path")
  case UpdateFile(path, content) => 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, "")
  case UpdateFile(path, _) => UpdateFile(path, "")
}

What’s happening here?

  • Commands are data (Operation), so you can validate/log/queue them before you execute them.
  • “Doing the thing” is an interpreter (execute), which is easy to test because it’s just a pure match.

Why ADTs are better for Command:

  • Serializable: Commands can be saved, queued, sent over network
  • Composable: Commands can be combined (sequence, parallel)
  • Inspectable: Can analyze commands before executing
  • Testable: Can test interpreter without executing side effects

Review checklist

  • Commands are immutable ADTs
  • Command execution is separate from command definition (interpreter pattern)
  • Commands include all data needed for execution
  • Command results are wrapped in Result for error handling

Pattern selection guide

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

Pattern maturity model

Level 1: Beginner (Focus: Type safety, immutability)

Master these first:

  • ADTs, Pattern Matching, Smart Constructors
  • Result type, Option, Either
  • Immutable collections, copy() for updates
  • SOLID: SRP and DIP - single responsibility functions, depend on traits
 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) {
  def process(input: String): Result[Unit] =
    for {
      json <- parseJson(input)
      config <- validateConfig(json)
      _ <- storage.save(config.toString)
    } yield ()
}

What’s happening here?

  • SRP: parsing and validation are separate functions, so each one is easy to test and change.
  • DIP: Service depends on Storage (an abstraction), so production vs test storage is just a different implementation.

Level 2: Intermediate (Focus: Composition, abstraction)

Master these next:

  • Type Classes, Implicits, Higher-Order Functions
  • for-comprehensions, Monads
  • Builder pattern + Phantom Types
  • SOLID: ISP and OCP - focused interfaces, extend without modification

Level 3: Advanced (Focus: Type-level programming)

Master these when ready:

  • EitherT/OptionT, Semigroup/Monoid
  • F-Bounded Polymorphism, Self Types
  • Lazy Evaluation, Currying
  • All 5 SOLID principles - apply reflexively

Level 4: Expert (Focus: Architecture, domain modeling)

Master these for architecture:

  • Type Members, Opaque Types (Scala 3)
  • Reader/Writer Monads, Traverse
  • Combining all patterns cohesively
  • SOLID violations detection - spot and refactor instantly

SOLID principles in practice

Single responsibility principle (SRP)

Check: Class name describes a single, clear purpose. Can you explain it in one sentence without “and”?

SRP, in one diagram: one god object becomes a set of small, boring components.

classDiagram
    class ToolManager {
        +register()
        +validate()
        +execute()
        +log()
        +formatOutput()
        +cacheResult()
    }

    class ToolRegistry
    class ToolValidator
    class ToolExecutor
    class ToolLogger
    class ToolFormatter
    class ToolCache

    ToolExecutor --> ToolRegistry
    ToolExecutor --> ToolValidator
    ToolExecutor --> ToolCache
    ToolExecutor --> ToolLogger
    ToolExecutor --> ToolFormatter

    style ToolManager fill:#ffe1e1,stroke:#cc0000,color:#000
    style ToolExecutor fill:#e1f5ff,stroke:#0066cc,color:#000
    style ToolRegistry fill:#e1ffe1,stroke:#2d7a2d,color:#000
    style ToolValidator fill:#e1ffe1,stroke:#2d7a2d,color:#000
    style ToolCache fill:#e1ffe1,stroke:#2d7a2d,color:#000
    style ToolLogger fill:#e1ffe1,stroke:#2d7a2d,color:#000
    style ToolFormatter fill:#e1ffe1,stroke:#2d7a2d,color:#000

Bad:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
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
}

What’s happening here?

  • This snippet shows the minimal shape for: Single responsibility principle.
  • In review, confirm the types make the happy path obvious and the error path explicit.

Good:

1
2
3
4
5
6
7
class ToolRegistry(tools: Seq[ToolFunction]) // manages collection

class ToolExecutor(registry: ToolRegistry) // executes tools

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

class ToolCache(store: Cache) // caching

What’s happening here?

  • This snippet shows the minimal shape for: Single responsibility principle.
  • In review, confirm the types make the happy path obvious and the error path explicit.

How to detect violations:

  • Class has >300 lines (smell, not rule)
  • Class name contains “Manager”, “Handler”, “Service”, “Util” (vague names)
  • Constructor takes 5+ dependencies
  • Methods operate on unrelated data

Review checklist

  • Class name describes a single, clear purpose
  • Methods all relate to that single purpose
  • Can explain the class in one sentence without “and”
  • Changes to different concerns don’t affect this class

Open/Closed principle (OCP)

Check: Can you add a new provider/tool/strategy without editing existing classes?

OCP, in one diagram: core depends on an abstraction; new behaviour comes in as a new implementation.

flowchart LR
    Core["Core code"] -->|depends on| Factory["ProviderFactory (trait)"]
    OpenAI["OpenAIProviderFactory"] -->|implements| Factory
    Anthropic["AnthropicProviderFactory"] -->|implements| Factory
    New["NewProviderFactory"] -->|implements| Factory
    style Core fill:#e1f5ff,stroke:#0066cc,color:#000
    style Factory fill:#fff4e1,stroke:#cc8800,color:#000
    style OpenAI fill:#e1ffe1,stroke:#2d7a2d,color:#000
    style Anthropic fill:#e1ffe1,stroke:#2d7a2d,color:#000
    style New fill:#f0e1ff,stroke:#8800cc,color:#000
1
2
3
4
5
6
7
8
// 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)
    // Adding GeminiConfig? Just add a case. No modification needed.
  }

What’s happening here?

  • This snippet shows the minimal shape for: Open/Closed principle.
  • In review, confirm the types make the happy path obvious and the error path explicit.

Review checklist

  • New features added via new implementations, not editing existing classes
  • Core abstractions (traits) are stable
  • New providers/tools/strategies don’t require modifying registry code
  • Uses polymorphism, not if/else chains on type codes

Liskov substitution principle (LSP)

Check: Can you swap implementations without changing behavior?

LSP, in one diagram: every implementation is swappable because they all obey the same contract.

classDiagram
    class LLMClient {
        <<trait>>
        +complete()
    }

    class OpenAIClient
    class AnthropicClient
    class BrokenClient

    LLMClient <|.. OpenAIClient
    LLMClient <|.. AnthropicClient
    LLMClient <|.. BrokenClient
    note for LLMClient "Contract: never throws; does not mutate input"
    note for BrokenClient "Violates contract: throws; mutates input"

    style LLMClient fill:#fff4e1,stroke:#cc8800,color:#000
    style OpenAIClient fill:#e1ffe1,stroke:#2d7a2d,color:#000
    style AnthropicClient fill:#e1ffe1,stroke:#2d7a2d,color:#000
    style BrokenClient fill:#ffe1e1,stroke:#cc0000,color:#000

All LLMClient implementations must honor the contract:

Review checklist

  • Subtype preserves preconditions (doesn’t require more)
  • Subtype preserves postconditions (doesn’t promise less)
  • Subtype preserves invariants (e.g., error semantics)
  • Subtype doesn’t throw new exceptions not in base contract
  • Subtype doesn’t weaken base type guarantees
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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 */
    }
}

// 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() // VIOLATES
    conversation.messages.clear() // VIOLATES: mutates input
    Right(Completion(""))
  }
}

What’s happening here?

  • The “contract” is not just the method signature. It’s behavioural: never throw, don’t mutate inputs, and respect options.
  • BrokenClient technically “implements the trait” but breaks those behavioural promises, so callers can’t substitute it safely.

Interface segregation principle (ISP)

Check: Are there methods that throw NotImplementedError? That’s a violation.

ISP, in one diagram: narrow interfaces let implementations pick only what they can actually support.

classDiagram
    class LLMService {
        +complete()
        +embed()
        +createFineTuneJob()
        +moderate()
    }

    class OllamaService
    LLMService <|.. OllamaService

    class CompletionClient
    class EmbeddingClient
    class FineTuneClient
    class ModerationClient

    class OpenAIClient
    class OllamaClient
    class AnthropicClient

    CompletionClient <|.. OpenAIClient
    EmbeddingClient <|.. OpenAIClient
    FineTuneClient <|.. OpenAIClient
    ModerationClient <|.. OpenAIClient
    CompletionClient <|.. OllamaClient
    EmbeddingClient <|.. OllamaClient
    CompletionClient <|.. AnthropicClient
    note for OllamaService "Fat interface forces stubs/NotImplementedError"

    style LLMService fill:#ffe1e1,stroke:#cc0000,color:#000
    style OllamaService fill:#ffe1e1,stroke:#cc0000,color:#000
    style CompletionClient fill:#e1ffe1,stroke:#2d7a2d,color:#000
    style EmbeddingClient fill:#e1ffe1,stroke:#2d7a2d,color:#000
    style FineTuneClient fill:#e1ffe1,stroke:#2d7a2d,color:#000
    style ModerationClient fill:#e1ffe1,stroke:#2d7a2d,color:#000
    style OpenAIClient fill:#e1f5ff,stroke:#0066cc,color:#000
    style OllamaClient fill:#e1f5ff,stroke:#0066cc,color:#000
    style AnthropicClient fill:#e1f5ff,stroke:#0066cc,color:#000

Bad:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
trait LLMService {
  def complete(conversation: Conversation): Result[Completion]

  def embed(texts: Seq[String]): Result[Seq[Embedding]]

  def createFineTuneJob(config: FineTuneConfig): Result[Job]

  def moderate(text: String): Result[ModerationResult]
  // Ollama doesn't provide fine-tuning or moderation!
}

What’s happening here?

  • This snippet shows the minimal shape for: Interface segregation principle.
  • In review, confirm the types make the happy path obvious and the error path explicit.

Good:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
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

What’s happening here?

  • This snippet shows the minimal shape for: Interface segregation principle.
  • In review, confirm the types make the happy path obvious and the error path explicit.

Review checklist

  • Traits have ≤7 abstract methods (guideline, not rule)
  • All implementations use all methods
  • Traits are cohesive (methods relate to single purpose)
  • Clients import only the traits they need

Dependency inversion principle (DIP)

Check: Does core depend on infrastructure, or vice versa?

DIP, in one diagram: high-level code depends on abstractions; config and providers live at the edge.

flowchart TD
    subgraph HighLevel["High-level policy (core)"]
        Agent["Agent"]
    end

    subgraph Abstractions["Abstractions (core)"]
        LLMClient["LLMClient (trait)"]
    end

    subgraph LowLevel["Low-level details (edge)"]
        Config["Config loader"]
        OpenAI["OpenAIClient"]
    end

    Agent --> LLMClient
    OpenAI --> LLMClient
    Config --> OpenAI
    style Agent fill:#e1f5ff,stroke:#0066cc,color:#000
    style LLMClient fill:#fff4e1,stroke:#cc8800,color:#000
    style Config fill:#f0e1ff,stroke:#8800cc,color:#000
    style OpenAI fill:#e1ffe1,stroke:#2d7a2d,color:#000

Bad:

1
2
3
4
5
6
class BadAgent {
  val client = new OpenAIClient(OpenAIConfig(
    ModelName("gpt-4"),
    ApiKey(sys.env("OPENAI_API_KEY")) // reads env directly!
  ))
}

What’s happening here?

  • This snippet shows the minimal shape for: Dependency inversion principle.
  • In review, confirm the types make the happy path obvious and the error path explicit.

Good:

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

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

What’s happening here?

  • This snippet shows the minimal shape for: Dependency inversion principle.
  • In review, confirm the types make the happy path obvious and the error path explicit.

Review checklist

  • Core modules depend on traits, not concrete classes
  • Concrete implementations provided at application edges
  • Dependencies injected via constructors, not created internally
  • No new ConcreteClass in core business logic

Testing strategy review

Test strategy, in one diagram: fast gates run always; slow/provider tests run explicitly.

flowchart TD
    PR["PR opened"] --> Gates["CI gates"]
    Gates --> Compile["+compile (Scala 2.13 + 3)"]
    Gates --> Format["scalafmtCheckAll"]
    Gates --> Unit["Unit tests (fast)"]
    Unit --> Coverage["scoverage gate (baseline + critical paths)"]
    Coverage --> Cross["Cross-version tests (modules/crossTest)"]
    Cross --> Integr["Integration tests (no credentials)"]
    Integr --> Provider{"Provider tests?<br/>(needs credentials)"}
    Provider -->|CI without creds| Skip["Skip in CI<br/>(tagged, explicit)"]
    Provider -->|Local/cred - enabled CI| Run["Run provider tests<br/>(@ProviderTest)"]
    Run --> Merge["Ready to merge"]
    Skip --> Merge
    style PR fill:#e1f5ff,stroke:#0066cc,color:#000
    style Gates fill:#f0e1ff,stroke:#8800cc,color:#000
    style Compile fill:#e1f5ff,stroke:#0066cc,color:#000
    style Format fill:#e1f5ff,stroke:#0066cc,color:#000
    style Unit fill:#e1ffe1,stroke:#2d7a2d,color:#000
    style Coverage fill:#fff4e1,stroke:#cc8800,color:#000
    style Cross fill:#fff4e1,stroke:#cc8800,color:#000
    style Integr fill:#fff4e1,stroke:#cc8800,color:#000
    style Provider fill:#fff4e1,stroke:#cc8800,color:#000
    style Skip fill:#e1f5ff,stroke:#0066cc,color:#000
    style Run fill:#e1ffe1,stroke:#2d7a2d,color:#000
    style Merge fill:#e1ffe1,stroke:#2d7a2d,color:#000

Unit test requirements

  • Tests mirror package structure
  • Each public method has at least one test
  • Both success and failure paths tested
  • Edge cases covered (empty, null/None, boundary values)

Test patterns

 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"))
  }
}

What’s happening here?

  • This snippet shows the minimal shape for: Test patterns.
  • In review, confirm the types make the happy path obvious and the error path explicit.

Coverage requirements

  • Minimum 50% statement coverage (enforced by scoverage)
  • Critical paths (error handling, security) should have higher coverage
  • Coverage excludes: samples, workspace, runner

Property-based testing

For complex transformations, use ScalaCheck:

 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 org.scalacheck.Prop.forAll
import org.scalacheck.{Arbitrary, Gen}

class JsonRoundtripSpec extends AnyFlatSpec with ScalaCheckPropertyChecks {
  // Generator for valid ModelName
  implicit val arbModelName: Arbitrary[ModelName] = Arbitrary(
    Gen.alphaNumStr.suchThat(_.nonEmpty).map(ModelName(_))
  )

  "ModelName" should "roundtrip through JSON" in {
    forAll { (name: ModelName) =>
      val json = write(name)
      val parsed = read[ModelName](json)
      parsed shouldBe name
    }
  }

  "Result" should "preserve errors through transformations" in {
    forAll { (error: LLMError) =>
      val result: Result[Int] = Left(error)
      result.map(_ + 1) shouldBe Left(error) // Error preserved
    }
  }
}

What’s happening here?

  • You generate lots of inputs (Arbitrary[ModelName]) and check an invariant holds for all of them (roundtrip, error preservation).
  • This is how you catch “works for my one test input” bugs in parsing/serialization/transforms.

When to use property-based tests:

  • Serialization roundtrips (JSON, protobuf)
  • Invariant preservation (sorted lists stay sorted)
  • Algebraic laws (Semigroup associativity)
  • Parsers (parse-print roundtrip)

Integration tests

  • Provider tests marked with @ProviderTest tag (skipped in CI without credentials)
  • Tests use test fixtures, not production data
  • Cleanup happens in afterAll, not relying on garbage collection
  • Timeouts set for network operations

Cross-version tests

  • modules/crossTest validates behavior matches on Scala 2.13 and 3.x
  • No version-specific behavior leaks (especially in implicits/givens)
  • ScalaCompat shims tested on both versions

Documentation review

Code documentation

  • Public APIs have ScalaDoc
  • Complex algorithms have inline comments explaining “why”
  • No comments explaining obvious “what”
  • Examples in ScalaDoc actually compile

ADR requirements

Write an ADR when a change:

  • Introduces new architectural dependency
  • Changes public API or error model
  • Changes storage formats or tool schemas
  • Changes concurrency model
  • Adds security-relevant behavior

README updates

  • User-facing features documented in README
  • Breaking changes noted in CHANGELOG
  • New samples added for new features
  • Environment variables documented if added

README and user-facing docs

  • README updated if API changes
  • Quickstart examples still work after changes
  • Migration guide added for breaking changes
  • CHANGELOG updated with user-visible changes
  • Version numbers consistent across docs and code

Documentation review checklist

Change typeRequired doc updates
New public APIScalaDoc + README example
API behavior changeScalaDoc update + migration note
New config optionREADME config section
New error typeError handling guide
Deprecation@deprecated annotation + migration path

Build, release, CI, DevOps review

Build requirements

  • sbt +compile succeeds (both Scala versions)
  • sbt +test succeeds
  • sbt scalafmtCheckAll passes
  • No new compiler warnings introduced

Dependency changes

  • New dependencies justified (not just “nice to have”)
  • License compatible with MIT
  • No transitive dependency conflicts
  • Cross-published for both Scala versions

Version compatibility

  • MiMa checks pass or exception justified
  • Deprecations have migration path
  • Breaking changes require version bump

Security and dependency hygiene

Security trust boundary, in one diagram: treat LLM output like user input until it’s validated.

flowchart LR
    User["User input"] --> Prompt["Prompt builder<br/>(string templating)"]
    Prompt --> LLM["LLM call"]
    LLM --> Out["LLM output<br/>(UNTRUSTED)"]
    Out --> Parse["Parse candidate tool call"]
    Parse --> Validate["Validate args against schema<br/>(strict types + allowlists)"]
    Validate -->|Valid| Exec["Execute tool"]
    Validate -->|Invalid| Reject["Reject + return typed error"]
    Exec --> Logs["Logs/trace<br/>(redaction + no secrets)"]
    Reject --> Logs
    Out -.-> Attack["Prompt injection attempt<br/>(tool call with untrusted args)"]
    Attack -.-> Validate
    style User fill:#e1f5ff,stroke:#0066cc,color:#000
    style Prompt fill:#f0e1ff,stroke:#8800cc,color:#000
    style LLM fill:#f0e1ff,stroke:#8800cc,color:#000
    style Out fill:#ffe1e1,stroke:#cc0000,color:#000
    style Parse fill:#fff4e1,stroke:#cc8800,color:#000
    style Validate fill:#fff4e1,stroke:#cc8800,color:#000
    style Exec fill:#e1ffe1,stroke:#2d7a2d,color:#000
    style Reject fill:#ffe1e1,stroke:#cc0000,color:#000
    style Logs fill:#e1f5ff,stroke:#0066cc,color:#000
    style Attack fill:#ffe1e1,stroke:#cc0000,color:#000

Secret handling

  • API keys never logged
  • Secrets redacted in error messages
  • ApiKey, JwtToken, OAuthToken types have safe toString
  • No secrets in stack traces

LLM safety

  • LLM output treated as untrusted user input
  • Tool arguments validated against schema
  • No LLM output executed as code/SQL/shell
  • Prompt injection mitigations in place for user input

Resource limits

  • Token budgets enforced
  • Max tool calls per request
  • Timeouts on all external calls
  • Max payload sizes
  • Retry limits with backoff

Tool calling security

  • Strict schemas for tool inputs
  • Allowlists for outbound network calls
  • Audit logging of tool invocations
  • Idempotent where possible

Performance and resource safety

Practical performance patterns

These are battle-tested patterns from handsonscala that solve real performance problems.

Memoization: cache expensive computations

The problem: You’re recomputing expensive results for the same inputs (Fibonacci, recursive algorithms).

Bad:

1
2
3
4
5
6
7
def fib(n: Int): Int = n match {
  case 0 => 0
  case 1 => 1
  case n => fib(n - 1) + fib(n - 2)  // Exponential time!
}

fib(40)  // Takes seconds - recomputes fib(20) millions of times

Good: memoization:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
def memoize[A, B](f: A => B): A => B = {
  val cache = scala.collection.mutable.Map.empty[A, B]
  (a: A) => cache.getOrElseUpdate(a, f(a))
}

lazy val fib: Int => Int = memoize {
  case 0 => 0
  case 1 => 1
  case n => fib(n - 1) + fib(n - 2)
}

fib(40)  // Instant - each fib(n) computed only once

Review checklist:

  • Cache keyed on function arguments (not mutable state)
  • Thread-safety considered if concurrent access
  • Cache eviction strategy for unbounded inputs

Parallel collections: CPU-bound parallelism

The problem: You’re doing CPU-intensive work on large collections sequentially.

Bad:

1
val results = (1 to 1000000).map(heavyComputation)  // Single-threaded

Good:

1
2
3
4
5
6
7
val results = (1 to 1000000).par.map(heavyComputation)  // Parallel

// Control parallelism
import scala.collection.parallel.ForkJoinTaskSupport
val pc = (1 to 1000000).par
pc.tasksupport = new ForkJoinTaskSupport(new java.util.concurrent.ForkJoinPool(8))
val results = pc.map(heavyComputation)

When to use:

  • CPU-bound work (math, parsing, transformations)
  • Large collections (>10k elements typically)
  • Stateless computations (no side effects)
  • IO-bound work - use Future.traverse or parTraverse instead
  • Small collections - overhead > benefit

Review checklist:

  • Actually CPU-bound (profile first!)
  • No side effects in parallel operations
  • Thread pool size configured for workload

StringBuilder: string concatenation in loops

The problem: String concatenation in loops creates intermediate strings (O(n²) allocations).

Bad:

1
2
var result = ""
(1 to 1000).foreach(i => result += i.toString)  // Creates 1000 intermediate strings!

Good:

1
2
3
val builder = new StringBuilder
(1 to 1000).foreach(i => builder.append(i))
val result = builder.toString  // Single allocation

Real-world benchmark (handsonscala):

  • String concatenation: 1000 iterations = 50ms, 500KB garbage
  • StringBuilder: 1000 iterations = 2ms, 4KB garbage

Review checklist:

  • Use StringBuilder for loops with >10 concatenations
  • Don’t forget .toString at the end
  • Consider mkString for simple joins: list.mkString(",")

Performance review checklist

  • Hot paths identified before optimization
  • Benchmarks provided for performance claims (JMH preferred)
  • Tradeoffs explicitly documented (readability vs speed)
  • No premature optimization

Allocation awareness

  • Avoid unnecessary allocations in hot paths
  • Hoist constants out of loops
  • Prefer StringBuilder over string concatenation in loops
  • Avoid zip tuple churn when indices suffice

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

Resource leaks

  • Connections properly closed
  • File handles released
  • Thread pools shut down
  • Cleanup in error paths

Production resilience patterns

Retry with exponential backoff (llm4s):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@tailrec
def retryWithBackoff[A](
  operation: => Result[A],
  maxRetries: Int = 3,
  delay: FiniteDuration = 1.second
): Result[A] = operation match {
  case Right(value) => Right(value)
  case Left(error) if maxRetries > 0 && isRetryable(error) =>
    Thread.sleep(delay.toMillis)
    retryWithBackoff(operation, maxRetries - 1, delay * 2)
  case Left(error) => Left(error)
}
flowchart TD
    Start[Execute operation] --> Check{Result?}

    Check -->|Right value| Success[Return Right value]

    Check -->|Left error| Retryable{isRetryable?<br/>maxRetries > 0?}

    Retryable -->|Yes| Sleep["Sleep delay<br/>delay = 1s"]
    Retryable -->|No| Fail[Return Left error]

    Sleep --> Retry1["Retry attempt 1<br/>Sleep 2s<br/>maxRetries = 2"]
    Retry1 --> Check2{Result?}

    Check2 -->|Right| Success
    Check2 -->|Left| Retry2["Retry attempt 2<br/>Sleep 4s<br/>maxRetries = 1"]

    Retry2 --> Check3{Result?}
    Check3 -->|Right| Success
    Check3 -->|Left| Retry3["Retry attempt 3<br/>Sleep 8s<br/>maxRetries = 0"]

    Retry3 --> Check4{Result?}
    Check4 -->|Right| Success
    Check4 -->|Left| Fail

    note1["Exponential backoff:<br/>delay × 2 each retry<br/>1s to 2s to 4s to 8s"]

    style Success fill:#e1ffe1,stroke:#2d7a2d,color:#000
    style Fail fill:#ffe1e1,stroke:#cc0000,color:#000
    style Sleep fill:#e1f5ff,stroke:#0066cc,color:#000
    style Retry1 fill:#fff4e1,stroke:#cc8800,color:#000
    style Retry2 fill:#fff4e1,stroke:#cc8800,color:#000
    style Retry3 fill:#fff4e1,stroke:#cc8800,color:#000
    style note1 fill:#f0e1ff,stroke:#8800cc,color:#000

Circuit breaker essentials:

  • Open (failing) → reject immediately
  • Half-open (testing) → try one request
  • Closed (healthy) → normal operation
stateDiagram-v2
    [*] --> Closed

    Closed --> Open: Failure threshold exceeded - e.g. 5 failures in 10s
    note right of Closed: Normal operation<br/>All requests pass through

    Open --> HalfOpen: Timeout elapsed - e.g. after 30 seconds
    note right of Open: Failing state<br/>Reject immediately<br/>Fail fast

    HalfOpen --> Closed: Success
    HalfOpen --> Open: Failure
    note right of HalfOpen: Testing state<br/>Try one request<br/>to check health

    Closed --> Closed: Success
    Open --> Open: Request rejected

WebSocket streaming (llm4s):

1
2
3
4
5
6
7
def runWithStreaming(query: String, onChunk: StreamedChunk => Unit): Result[AgentState] =
  client.streamComplete(conversation, options, onChunk)

// WebSocket handler
ws.onMessage { message =>
  agent.runWithStreaming(parse(message), chunk => ws.send(chunk.toJson))
}
sequenceDiagram
    participant Client as WebSocket Client
    participant Server as WebSocket Server
    participant Agent
    participant LLM as LLM Provider

    Client->>Server: message (user query)
    activate Server

    Server->>Agent: runWithStreaming(query, callback)
    activate Agent

    Agent->>LLM: streamComplete(conversation)
    activate LLM

    loop Streaming chunks
        LLM-->>Agent: StreamedChunk(delta)
        Agent-->>Server: onChunk callback
        Server-->>Client: ws.send(chunk.toJson)
        note over Client: Display token immediately<br/>(progressive rendering)
    end

    LLM-->>Agent: Final completion
    deactivate LLM

    Agent-->>Server: Result[AgentState]
    deactivate Agent

    Server-->>Client: Final state
    deactivate Server

    note over Client,LLM: Streaming reduces latency<br/>User sees tokens as they arrive

Pattern maturity progression

Use this 4-level model to assess codebase maturity and recommend next steps:

Level 1: Basic (Entry bar for production)

  • Tests exist and pass
  • Error handling present (even if basic)
  • CI pipeline configured

Level 2: Intermediate (Professional standard)

  • ADTs for domain modeling
  • Validated error flows (Result/Either)
  • Resource safety (bracket/Resource)

Level 3: Advanced (Staff-level engineering)

  • Tagless final / effect abstraction
  • Typestate / phantom types
  • Property-based testing

Level 4: Expert (Principal-level architecture)

  • Compile-time contract validation
  • Effect systems (Cats Effect 3, ZIO)
  • Complex type-level programming

Review guidance: Identify current level, recommend one step up (not three). If codebase is Level 1, suggest Level 2 patterns. Don’t jump to Level 4.


Common PR smells and how to comment

Smell: null checks

Bad:

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

What’s happening here?

  • This snippet shows the minimal shape for: Smell: null checks.
  • In review, confirm the types make the happy path obvious and the error path explicit.

Comment:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
**[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`

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

What’s happening here?

  • This snippet shows the minimal shape for: Smell: exception in domain logic.
  • In review, confirm the types make the happy path obvious and the error path explicit.

Comment:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
**[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`

Smell: direct config read in core

Bad:

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

What’s happening here?

  • This snippet shows the minimal shape for: Smell: direct config read in core.
  • In review, confirm the types make the happy path obvious and the error path explicit.

Comment:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
**[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`

Smell: non-exhaustive match

Bad:

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

What’s happening here?

  • This snippet shows the minimal shape for: Smell: non-exhaustive match.
  • In review, confirm the types make the happy path obvious and the error path explicit.

Comment:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
**[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`

Smell: blocking on global EC

Bad:

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

What’s happening here?

  • This snippet shows the minimal shape for: Smell: blocking on global EC.
  • In review, confirm the types make the happy path obvious and the error path explicit.

Comment:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
**[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:

```scala
// 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`

Smell: missing error cause

Bad:

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

What’s happening here?

  • This snippet shows the minimal shape for: Smell: missing error cause.
  • In review, confirm the types make the happy path obvious and the error path explicit.

Comment:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
**[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:

```scala
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`

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

What’s happening here?

  • This snippet shows the minimal shape for: Smell: fail-fast when accumulation needed.
  • In review, confirm the types make the happy path obvious and the error path explicit.

Comment:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
**[Should-fix]** Use Validated for multi-field validation

**Guideline**: Advanced FP Patterns - Validated for Error Accumulation

**Issue**: For-comprehension short-circuits on first error. Users must fix errors one by one.

**Suggestion**: Use Validated to accumulate all errors:

```scala
import cats.data.Validated
import cats.syntax.apply._

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`

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!

What’s happening here?

  • This snippet shows the minimal shape for: Smell: unsafe partial function.
  • In review, confirm the types make the happy path obvious and the error path explicit.

Comment:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
**[Must-fix]** Unguarded partial function can throw MatchError

**Guideline**: Advanced FP Patterns - 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:

```scala
// 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`

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

What’s happening here?

  • This snippet shows the minimal shape for: Smell: comparing newtypes with ==.
  • In review, confirm the types make the happy path obvious and the error path explicit.

Comment:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
**[Should-Fix]** Use typed comparison for newtypes

**Guideline**: Type Safety - Newtypes

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

```scala
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`

llm4s examples index

Quick reference to find real examples of patterns in the llm4s codebase.

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

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

ADTs and pattern matching

PatternFileLines
LLMProvider sealed ADTmodules/core/.../llmconnect/provider/LLMProvider.scala12-107
ToolCallError ADTmodules/core/.../toolapi/ToolCallError.scalaentire file
AgentStatus ADTmodules/core/.../agent/AgentState.scalaAgentStatus section

Factory pattern

PatternFileLines
LLMConnect factorymodules/core/.../llmconnect/LLMConnect.scala10-46
ToolRegistry.emptymodules/core/.../toolapi/ToolRegistry.scala138-143

Builder pattern

PatternFileLines
ToolBuildermodules/core/.../toolapi/ToolFunction.scala101-119

Resource management

PatternFileLines
ManagedResourcemodules/core/.../resource/ManagedResource.scala11-45
fileInputStreammodules/core/.../resource/ManagedResource.scala60-64

Strategy pattern

PatternFileLines
ToolExecutionStrategymodules/core/.../toolapi/ToolExecutionStrategy.scalaentire file
executeAll dispatchmodules/core/.../toolapi/ToolRegistry.scala64-77

Guardrails

PatternFileLines
Guardrail traitmodules/core/.../agent/guardrails/Guardrail.scala14-47
InputGuardrailmodules/core/.../agent/guardrails/Guardrail.scala57-67
CompositeGuardrailmodules/core/.../agent/guardrails/CompositeGuardrail.scalaentire file

Mermaid diagrams in reviews

GitHub renders Mermaid diagrams in PRs. Use them strategically to clarify complex logic.

When to use Mermaid

  • Control flow complexity: Nested pattern matches, multiple error paths
  • Architecture boundaries: Module dependencies, trait hierarchies
  • Sequence of events: Async operations, tool call flows
  • State machines: AgentStatus transitions, connection states
  • Error propagation: How errors flow through Result chains

When NOT to use Mermaid

  • Trivial logic that fits in 1-2 sentences
  • Restating code: Don’t just translate code to diagram form
  • Large diagrams: >10 nodes becomes unreadable

Size and readability rules

  • Maximum 10 nodes per diagram (preferably 5-7)
  • Maximum 12 edges/connections
  • Keep node labels under 50 characters
  • One diagram per comment
  • Always include plain-text summary above diagram

Flowchart template

 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
**[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 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`

Semantic colors

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

How to sell this internally

If you’re introducing this system to a team, here’s how to frame it.

Frame it as risk management, not nitpicking

“We’re not slowing down delivery - we’re preventing the 15-hour incidents that actually slow us down.”

Metrics to track

MetricWhat it showsTarget
PR sizeReview quality correlates with size<400 lines of diff
Review latencyTime from open to first review<4 hours
Defect escape rateBugs found in prod vs in reviewDecreasing over time
Incident rateProduction incidents per sprintDecreasing over time
Rollback rateDeployments that need rollback<5%

Phased adoption

Week 1-2: Awareness

  • Share this document
  • Discuss in team meeting
  • No enforcement, just education

Week 3-4: Soft enforcement

  • Reviewers start using severity labels
  • Must-fix items must be fixed
  • Should-fix can be deferred with comment

Week 5-8: Full enforcement

  • CI bot checks formatting and basic patterns
  • All Must-fix items block merge
  • Metrics dashboard visible

Ongoing: Refinement

  • Monthly review of what’s working
  • Adjust rules based on actual defect data
  • Remove rules that don’t catch real bugs

What reviewers enforce vs encourage

Hard rules (blockers)

These MUST be fixed before merge:

  1. Exceptions in domain logic - Use Result types
  2. Direct config reads in core - Use injection
  3. Secrets in logs - Use redacted types
  4. Non-exhaustive ADT matches - Handle all cases
  5. Breaking API changes without versioning - Explicit migration
  6. Missing error handling - All paths must handle failure
  7. Blocking on global EC - Use dedicated pools

Strong recommendations

These SHOULD be fixed, but can be deferred with justification:

  1. Missing tests for new code - Can defer for prototype
  2. Suboptimal performance - Document and ticket
  3. Unclear naming - Context-dependent
  4. Missing documentation - Better late than never

Optional improvements (non-blocking)

These are suggestions that improve but don’t block:

  1. More idiomatic patterns - Style preference
  2. Additional test cases - Diminishing returns
  3. Code organization - Personal preference
  4. Comment style - Consistency helps but isn’t critical

Advanced FP patterns reference

Pattern: unified error handling across effect types using Cats MonadError[F, E].

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

What’s happening here?

  • This snippet is a minimal reference shape: unified error handling across effect types using Cats MonadError[F, E].
  • In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.

Pattern: use Validated[E, A] with Semigroup[E] to accumulate multiple errors instead of fail-fast.

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

What’s happening here?

  • This snippet is a minimal reference shape: use Validated[E, A] with Semigroup[E] to accumulate multiple errors instead of fail-fast.
  • In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.

Scala 2.13 recipe:

 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
// 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
  }
}

// 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"

What’s happening here?

  • This snippet is a minimal reference shape: use Validated[E, A] with Semigroup[E] to accumulate multiple errors instead of fail-fast.
  • In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.

Scala 3 equivalent:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// 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""""

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

What’s happening here?

  • This snippet is a minimal reference shape: use Validated[E, A] with Semigroup[E] to accumulate multiple errors instead of fail-fast.
  • In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.

Pattern: compose functions that return effects (A => F[B]) using Kleisli.

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

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)

What’s happening here?

  • This snippet is a minimal reference shape: compose functions that return effects (A => F[B]) using Kleisli.
  • In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import cats.data.State

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

What’s happening here?

  • This snippet is a minimal reference shape: compose functions that return effects (A => F[B]) using Kleisli.
  • In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.

Pattern: covariance (+T) for producers/collections, contravariance (-T) for consumers/actions.

 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

What’s happening here?

  • This snippet is a minimal reference shape: covariance (+T) for producers/collections, contravariance (-T) for consumers/actions.
  • In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.

Review tip: When reviewing generic types (especially public APIs), verify variance annotations match usage patterns.

Pattern: use .lift to safely handle partial functions.

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

What’s happening here?

  • This snippet is a minimal reference shape: use .lift to safely handle partial functions.
  • In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.
 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.Semigroup
import cats.syntax.semigroup._ // for |+|

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

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

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

What’s happening here?

  • This snippet is a minimal reference shape: use .lift to safely handle partial functions.
  • In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
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

What’s happening here?

  • This snippet is a minimal reference shape: use .lift to safely handle partial functions.
  • In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.
 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)
}

What’s happening here?

  • This snippet is a minimal reference shape: use .lift to safely handle partial functions.
  • In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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()
}

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

What’s happening here?

  • This snippet is a minimal reference shape: use .lift to safely handle partial functions.
  • In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.
 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

What’s happening here?

  • This snippet is a minimal reference shape: use .lift to safely handle partial functions.
  • In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
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 = {
  val bytes = os.read.bytes(path)
  hashBytes(bytes) // CPU-intensive
}

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

What’s happening here?

  • This snippet is a minimal reference shape: use .lift to safely handle partial functions.
  • In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.

Review note: CPU-bound operations should use a dedicated ExecutionContext rather than scala.concurrent.ExecutionContext.global, which is designed for non-blocking I/O.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
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 {
      val (batch, rest) = remaining.splitAt(maxConcurrency - inFlight.size)
      val newFutures = batch.map(url => fetchAsync(url))

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

What’s happening here?

  • This snippet is a minimal reference shape: use .lift to safely handle partial functions.
  • In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.

Review checklist:

  • Is there a maximum concurrency limit?
  • Does the code respect external rate limits (API quotas)?
  • Is backpressure handled (don’t spawn unlimited futures)?
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
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 broadcast(msg: String): Unit = synchronized {
    for (conn <- openConnections) conn.send(msg)
  }
}

What’s happening here?

  • This snippet is a minimal reference shape: use .lift to safely handle partial functions.
  • In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.

Review questions:

  • Is all access to mutable state synchronized?
  • Are read and write operations atomic when needed?
  • Would an immutable approach (Actor, STM, or State monad) be cleaner?
 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!
}

What’s happening here?

  • This snippet is a minimal reference shape: use .lift to safely handle partial functions.
  • In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.

Review checklist:

  • Is the trait sealed for exhaustive matching?
  • Do all cases have serialization instances?
  • Are new message types added by extending the ADT (not modifying existing cases)?
 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
        ()
    }
  }
}

What’s happening here?

  • This snippet is a minimal reference shape: use .lift to safely handle partial functions.
  • In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.

Review questions:

  • Are all state combinations handled?
  • Is the case _ fallback intentional and documented?
  • Would an explicit enum of transitions be clearer?
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// 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!"

// llm4s context example
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)

What’s happening here?

  • This snippet is a minimal reference shape: use .lift to safely handle partial functions.
  • In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.
 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
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

// Cake pattern for dependency injection
trait LoggingComponent {
  def log(msg: String): Unit
}

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

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

What’s happening here?

  • This snippet is a minimal reference shape: use .lift to safely handle partial functions.
  • In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.
 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
// Phantom type markers (no instances, just types)
sealed trait BuilderState

sealed trait Empty extends BuilderState

sealed trait HasModel 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
  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

What’s happening here?

  • This snippet is a minimal reference shape: use .lift to safely handle partial functions.
  • In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.
 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
// Problem: losing 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

// Solution: F-bounded polymorphism + self-type
trait SafeCompletionOptions[A <: SafeCompletionOptions[A]] {
  self: A =>
  def withMaxTokens(n: Int): A
}

class SafeOpenAIOptions extends SafeCompletionOptions[SafeOpenAIOptions] {
  def withTemperature(t: Double): SafeOpenAIOptions = ???

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

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

What’s happening here?

  • This snippet is a minimal reference shape: use .lift to safely handle partial functions.
  • In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.
 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
// Type members (alternative to type parameters)
trait LLMProvider {
  type Config // abstract 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

// 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

What’s happening here?

  • This snippet is a minimal reference shape: use .lift to safely handle partial functions.
  • In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.

When to use:

  • Type parameters: When type is known at call site (more common)
  • Type members: When type is defined by implementation, not caller
 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
object SocialNetwork {
  // Opaque type: Name and String are separate types OUTSIDE this scope
  opaque
  type Name = String

  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
}

// 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

What’s happening here?

  • This snippet is a minimal reference shape: use .lift to safely handle partial functions.
  • In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.

Migration path from Scala 2.13 AnyVal:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Current Scala 2.13
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(***)"
}

What’s happening here?

  • This snippet is a minimal reference shape: Migration path from Scala 2.13 AnyVal.
  • In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.

Pattern: thread configuration through computations without explicit parameter passing.

 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)

// Functions that depend on config return Reader
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)

What’s happening here?

  • This snippet is a minimal reference shape: thread configuration through computations without explicit parameter passing.
  • In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.

When to use Reader in llm4s context:

  • Many functions need the same config subset
  • Config is read multiple times in a for-comprehension
  • You need to test different configs without reconstructing objects

Pattern: abstract over effect types using higher-kinded type parameters, enabling polymorphic code that works with

any effect (Future, IO, Task, etc.).

 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
// Define algebra (capabilities) abstractly
trait Console[F[_]] {
  def putStrLn(line: String): F[Unit]

  def getStrLn: F[String]
}

trait UserRepository[F[_]] {
  def findUser(id: Long): F[Option[User]]

  def saveUser(user: User): F[Unit]
}

// Program is polymorphic in F[_]
def program[F[_] : Monad](
                           console: Console[F],
                           repo: UserRepository[F]
                         ): F[Unit] = for {
  _ <- console.putStrLn("Enter user ID:")
  id <- console.getStrLn.map(_.toLong)
  user <- repo.findUser(id)
  _ <- console.putStrLn(user.fold("Not found")(_.name))
} yield ()

// Production interpreter (F = IO)
object ConsoleIO extends Console[IO] {
  def putStrLn(line: String): IO[Unit] = IO(println(line))

  def getStrLn: IO[String] = IO(scala.io.StdIn.readLine())
}

// Test interpreter (F = State for deterministic tests)
type TestState[A] = State[List[String], A]

object ConsoleTest extends Console[TestState] {
  def putStrLn(line: String): TestState[Unit] = State.modify(_ :+ line)

  def getStrLn: TestState[String] = State(s => (s.tail, s.head))
}

What’s happening here?

  • This snippet is a minimal reference shape: abstract over effect types using higher-kinded type parameters, enabling polymorphic code that works with.
  • In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.

Why tagless final over Free monads:

  • Simpler: No need for complex interpreters or natural transformations
  • Better inference: Compiler can often infer types automatically
  • Performance: No intermediate representation overhead
  • Flexibility: Easy to swap effect types for testing

When NOT to use: llm4s avoids tagless final for most code because:

  • Adds complexity for teams not familiar with HKT
  • Result[A] is sufficient for synchronous code
  • Constructor injection is simpler for dependency management

Review note: If reviewing tagless final code, ensure algebras are minimal (ISP), interpreters are lawful, and there’s clear justification for the added abstraction.

Pattern: accumulate logs alongside computations without side effects.

 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

What’s happening here?

  • This snippet is a minimal reference shape: accumulate logs alongside computations without side effects.
  • In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.

Review note: Consider Writer for tracing or audit logging code where isolation from shared mutable state matters. Useful in concurrent tool execution where logs from different tools shouldn’t interleave.

Pattern: add pattern matching support to any type via unapply methods.

 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
// 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(", ")}"

What’s happening here?

  • This snippet is a minimal reference shape: add pattern matching support to any type via unapply methods.
  • In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.

Review note: Consider custom extractors to simplify complex guards or enable matching on wrapped types (like matching directly on JSON structures).

Pattern: abstract over type constructors to write generic code.

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

What’s happening here?

  • This snippet is a minimal reference shape: abstract over type constructors to write generic code.
  • In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.

Review note: When reviewing generic utilities, check if higher-kinded types would reduce code duplication. Relevant for operations that should work uniformly across Result, Option, and Future.

Pattern: use Eq[A] type class to prevent comparing incompatible types.

 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

What’s happening here?

  • This snippet is a minimal reference shape: use Eq[A] type class to prevent comparing incompatible types.
  • In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.

Review note: When reviewing equality checks on newtypes or domain objects, suggest using Cats Eq to catch type mismatches at compile time. Valuable for ID types like ConversationId, ModelName.

Pattern: structure implicit instances for discoverability and override control.

 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

What’s happening here?

  • This snippet is a minimal reference shape: structure implicit instances for discoverability and override control.
  • In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.

Implicit resolution priority (lowest to highest):

  1. Companion objects of involved types
  2. Imported scope
  3. Local scope (same block)

Review checklist:

  • Default instances go in companion objects
  • Alternative instances are in clearly named objects
  • Tests can easily swap instances by importing

Pattern: combine independent effects in parallel using Semigroupal and mapN.

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

What’s happening here?

  • This snippet is a minimal reference shape: combine independent effects in parallel using Semigroupal and mapN.
  • In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.

Review note: Use mapN when validations are independent. Use for-comprehension when later validations depend on earlier results.

Pattern: typeclass instances that derive automatically for composite types.

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

What’s happening here?

  • This snippet is a minimal reference shape: typeclass instances that derive automatically for composite types.
  • In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.

Review note: Check if recursive typeclass instances could reduce boilerplate when reviewing JSON serialization, tool schema generation, or configuration parsing. llm4s uses uPickle which provides this pattern for JSON.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 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
}

// Anti-pattern to watch for
// BAD: Hides initialization errors
lazy val apiKey = sys.env("API_KEY") // Exception thrown on first use

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

What’s happening here?

  • This snippet is a minimal reference shape: typeclass instances that derive automatically for composite types.
  • In review, focus on the types + combinators: they should reduce branching and keep effects/errors explicit.

TL;DR

  • Errors are values, not exceptions - Use Result[A] or equivalent
  • Types prevent confusion - ApiKey and ModelName are different types
  • Config lives at the edge - Core code receives typed config, never reads env vars
  • Immutability by default - State transitions return new states
  • Exhaustive matching - No wildcard catches on sealed types
  • Small, focused interfaces - No god traits with 20 methods
  • Test both paths - Success and failure get equal attention
  • Review severity matters - Blocker/Must-fix/Should-fix/Nit
  • Rigor speeds delivery - 30-minute review prevents 15-hour incident

The goal isn’t perfect code. The goal is code that doesn’t wake you up at 2 AM.

References

llm4s code

Official documentation

Design patterns

Online tutorials


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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
**[Must-Fix]** {brief title}

**Guideline**: {Section Name}

**Issue**: {What is wrong}

**Suggestion**:

```scala
{
  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}`

Document maintenance

This document should be updated when:

  • New patterns are established in the codebase
  • Scalafix rules are added or changed
  • New modules are added
  • Security requirements change
  • Cross-version strategy changes

Last reviewed: 2026-01-29


Appendix D: changelog

VersionDateChanges
0.92026-01-30
0.82026-01-30
0.72026-01-30
0.62026-01-30
0.52026-01-30
0.42026-01-29
0.32026-01-29
0.22026-01-29
0.12026-01-29
Vitthal Mirji profile photo

Vitthal Mirji

Staff Data Engineer @ Walmart

Mumbai, India

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

Expertise
  • Data Engineering
  • Scala
  • Apache Spark
  • Functional Programming
  • Cloud Architecture
  • GCP
  • Big Data
Next time, we'll talk about "10 Reasons why gcc SHOULD be re-written in JavaScript - You won't believe #8!"