Module 6: Code Quality Patterns for AI

1. Introduction: Code Quality for AI

Description

You have functional code with tests (Modules 2-3), guardrails (Module 4), and logging (Module 5). But "functional" is not the same as "maintainable." This module tackles the difference: how to organize that code with clean architecture specifically adapted to LLM apps, so a team can extend it, debug it, and change it without rewriting everything.


The state of the code after the previous modules

# Functional but monolithic code — what you probably have now:

from openai import OpenAI
import json

client = OpenAI(api_key="sk-...")  # Coupled to the provider

SENTIMENT_PROMPT = "Analyze sentiment of: {text}"  # Prompt hardcoded in the module

def analyze(text: str) -> dict:
    # God function: does everything in one place
    
    # Guardrail (mixed with logic)
    if len(text) > 5000:
        text = text[:5000]
    
    # Prompt construction (mixed with the call)
    prompt = SENTIMENT_PROMPT.format(text=text)
    
    # LLM call (business coupled to infrastructure)
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
        temperature=0.7  # Magic number
    )
    
    # Parsing (mixed with everything)
    raw = response.choices[0].message.content
    try:
        result = json.loads(raw)
    except:
        result = {"sentiment": "unknown", "score": 0.0}
    
    # Logging (mixed with logic)
    print(f"Result: {result}")
    
    return result

# What's wrong with this?
# 1. If you switch from OpenAI to Anthropic: rewrite the module
# 2. If you want to A/B test the prompt: edit the code
# 3. If you want to mock in tests: patch("openai.chat.completions.create") — fragile
# 4. If you want to change temperature per environment: scattered constant or env var
# 5. If you want to reuse the parser elsewhere: it's mixed in

The 4 specific problems of AI apps

Problem 1: Hardcoded prompts

# ❌ The prompt as a string in the code:
PROMPT = "Analyze the sentiment of the following text and return a JSON..."

# Problems:
# - To A/B test the prompt, you need to change the code and redeploy
# - To translate the prompt to another language, same thing
# - You can't version the prompt separately from the code
# - You can't know which prompt version produced which result

# ✅ The prompt as configuration:
# prompts/sentiment/v1.yaml
# template: "Analyze the sentiment of..."
# version: "v1"
# author: "mike"
# last_modified: "2024-01-15"

Problem 2: Tight coupling to the provider

# ❌ Coupled directly to OpenAI:
from openai import OpenAI
client = OpenAI()

def analyze(text: str) -> dict:
    response = client.chat.completions.create(...)  # OpenAI-specific API

# If tomorrow Anthropic has better price/quality:
# → Rewrite ALL the business logic

# ✅ Decoupled with Protocol:
class LLMProvider(Protocol):
    def complete(self, messages: list, **kwargs) -> str: ...

def analyze(text: str, provider: LLMProvider) -> dict:
    response = provider.complete([{"role": "user", "content": text}])

# Switch to Anthropic: new LLMProvider implementation, one line in config

Problem 3: Scattered configuration

# ❌ Config scattered across multiple places:
MODEL = "gpt-4o-mini"              # In models.py
TEMPERATURE = 0.7                   # In utils.py
MAX_TOKENS = int(os.getenv("MT"))   # In main.py
RETRY_ATTEMPTS = 3                  # In llm_client.py

# Result: to change the staging config, search in 4 files

# ✅ Config centralized with pydantic-settings:
class Settings(BaseSettings):
    model: str = "gpt-4o-mini"
    temperature: float = 0.7
    max_tokens: int = 500
    retry_attempts: int = 3
    
    class Config:
        env_file = ".env"

Problem 4: God functions

# ❌ One function that does everything:
def process_request(text: str) -> dict:
    # 1. Sanitize (should be guardrails)
    # 2. Build prompt (should be the prompts layer)
    # 3. Call LLM (should be infrastructure)
    # 4. Parse response (should be output processing)
    # 5. Validate result (should be domain)
    # 6. Log (should be an infrastructure wrapper)
    # 7. Return (ok)
    pass

# Consequence: you can't test each part separately
# You can't reuse the parser for another endpoint
# You can't change the LLM without risking breaking the parser

The solution: 4 layers adapted for AI

┌─────────────────────────────────────────────────────────┐
│           PROMPT TEMPLATES (configuration)              │
│  YAML/JSON files with templates, version, metadata      │
│  Versionable, editable without redeploy, A/B-testable   │
├─────────────────────────────────────────────────────────┤
│              BUSINESS LOGIC (domain)                    │
│  Orchestration, business rules, use cases               │
│  Only knows interfaces — doesn't know OpenAI or JSON    │
├─────────────────────────────────────────────────────────┤
│           LLM INFRASTRUCTURE (infrastructure)           │
│  Implementations: OpenAIProvider, AnthropicProvider,    │
│  MockProvider, FallbackProvider                         │
├─────────────────────────────────────────────────────────┤
│           OUTPUT PROCESSING (processing)                │
│  Parsers, validators, transformers                      │
│  Receive strings, return Python types                   │
└─────────────────────────────────────────────────────────┘

Rule: dependencies only point INWARD.
Processing doesn't know Infrastructure.
Domain doesn't know Infrastructure.
Infrastructure doesn't know Domain.

The complete transformation

# BEFORE: everything mixed in one function

# AFTER: each layer has its responsibility

# ─── prompts/sentiment/v1.yaml ───────────────────────────────────────────
# template: |
#   Analyze the sentiment of the following text.
#   Return JSON: {"sentiment": "positive|negative|neutral|mixed", "score": float}
#   Text: {text}
# version: "v1"

# ─── src/domain/sentiment_service.py ─────────────────────────────────────
from src.infrastructure.llm_provider import LLMProvider
from src.processing.sentiment_parser import parse_sentiment_output
from src.prompts.loader import load_prompt

def analyze_sentiment(text: str, provider: LLMProvider) -> dict:
    """Pure business logic: orchestrates without knowing infrastructure details."""
    prompt = load_prompt("sentiment/v1").render(text=text)
    raw_response = provider.complete([
        {"role": "system", "content": "You are a sentiment analysis expert."},
        {"role": "user", "content": prompt}
    ])
    return parse_sentiment_output(raw_response)

# ─── src/infrastructure/openai_provider.py ───────────────────────────────
class OpenAIProvider:
    def __init__(self, client, model: str, temperature: float, max_tokens: int):
        self._client = client
        self._model = model
        self._temperature = temperature
        self._max_tokens = max_tokens
    
    def complete(self, messages: list) -> str:
        response = self._client.chat.completions.create(
            model=self._model,
            messages=messages,
            temperature=self._temperature,
            max_tokens=self._max_tokens
        )
        return response.choices[0].message.content

# ─── src/processing/sentiment_parser.py ──────────────────────────────────
from pydantic import BaseModel
import json

class SentimentOutput(BaseModel):
    sentiment: str
    score: float

def parse_sentiment_output(raw: str) -> dict:
    """Independent parser: takes a string, returns a validated dict."""
    data = json.loads(raw)
    return SentimentOutput(**data).model_dump()

# ─── src/app/main.py ──────────────────────────────────────────────────────
from src.domain.sentiment_service import analyze_sentiment
from src.infrastructure.openai_provider import OpenAIProvider
from src.config import get_settings

settings = get_settings()

def get_provider():
    return OpenAIProvider(
        client=OpenAI(api_key=settings.openai_api_key.get_secret_value()),
        model=settings.model,
        temperature=settings.temperature,
        max_tokens=settings.max_tokens
    )

Why this module comes HERE in the guide

Guide timeline:

M1-3: Tests ────────────────────────────────────────────────────
      You wrote tests for the code. Now you have a safety
      net. The M6 refactoring is only safe because you
      have tests.

M4: Guardrails ──────────────────────────────────────────────────
      You added validation logic. Now you have substantial
      code that is worth organizing.

M5: Logging ─────────────────────────────────────────────────────
      You added observability infrastructure. Now the code
      has three mixed layers: business + guardrails + logging.

M6: Code Quality ────────────────────────────────────────────────
      With tests (safety net) and enough code to organize,
      now refactoring is as valuable as it is safe to do.

M7: Reliability ─────────────────────────────────────────────────
      The M6 clean architecture makes adding retry, circuit
      breakers, and fallbacks plug-and-play.

Module prerequisites

# New dependencies for this module
pip install pydantic-settings   # Configuration management
pip install jinja2              # Prompt templating (optional)
pip install python-dotenv       # .env loading
pip install pyyaml              # YAML for prompt files

Module roadmap

#CapsuleCore featureResult
01IntroductionThe problem of monolithic AI codeThis capsule
02Clean architectureThe 4 layers, directory structureArchitecture defined
03Separation of concernsExtract each responsibilityGod functions eliminated
04Config managementType-safe pydantic-settingsCentralized config
05Dependency injectionLLMProvider ProtocolDecoupled provider
06Environment managementdev/staging/prod configsConfig per environment
07Refactored AI App projectComplete refactoringOrganized app
08Summary and troubleshootingWrap-up and anti-patterns

Exercises

Exercise 1: Diagnosis of your current code

List the 3 main code quality problems in the code you've built in previous modules. For each one, identify the pattern that solves it:

See guide

Common problems:

  1. "The prompt is hardcoded in main.py inside the function" → Solution: externalize to a YAML file + load_prompt()
  2. "I import from openai import OpenAI directly in sentiment_service.py" → Solution: LLMProvider Protocol + dependency injection
  3. "The configuration is mixed across .env, config.py, and main.py" → Solution: centralized pydantic-settings

Exercise 2: Classify code

For each fragment, indicate which architecture layer it should be in:

# A)
def complete(self, messages: list) -> str:
    return openai.chat.completions.create(...)

# B)
def analyze_sentiment(text: str, provider: LLMProvider) -> dict:
    prompt = load_prompt("sentiment").render(text=text)
    raw = provider.complete([{"role": "user", "content": prompt}])
    return parse_output(raw)

# C)
def parse_output(raw: str) -> dict:
    return SentimentOutput.model_validate_json(raw).model_dump()

# D)
# sentiment/v1.yaml
# template: "Analyze sentiment: {text}"
See solution

A) Infrastructure — It's the API call, specific to a provider. B) Business Logic / Domain — Orchestrates the steps, defines the use case. C) Output Processing — Transforms raw output into a Python type. D) Prompt Templates — Prompt configuration, not code.


Exercise 3: ROI of refactoring

For each code quality improvement, estimate how much time you save in the future:

  1. Externalize prompts to YAML
  2. Create LLMProvider Protocol
  3. Centralize config with pydantic-settings
See guide
  1. Externalized prompts: A/B testing prompts without redeploying = hours saved per iteration × all iterations. If you optimize prompts once a week: 2h/week saved.
  2. LLMProvider Protocol: Switching from OpenAI to another provider = 30 minutes (new implementation) vs 2-3 days (refactoring all the code). ROI: huge if you switch even once.
  3. pydantic-settings: Debugging config in production = 5 minutes (startup error) vs 2 hours (finding where the wrong config is). ROI: every time there's a config error.

Summary

  • Functional but monolithic code has 4 AI-specific problems: hardcoded prompts, tight coupling, scattered config, and god functions
  • The solution is clean architecture in 4 layers: prompt templates, business logic, infrastructure, output processing
  • The timing is right: the Phase 1 tests are the safety net for the refactoring
  • The goal is pragmatism, not purism — each abstraction must be justified by a real use case

Additional resources

  1. Clean Architecture (Robert C. Martin) — The conceptual framework
  2. pydantic-settings — Configuration management
  3. typing.Protocol — Interfaces in Python
  4. Dependency Injection in Python (Martin Fowler) — The fundamental pattern
  5. Refactoring (Martin Fowler) — The reference book for safe refactoring