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
| # | Capsule | Core feature | Result |
|---|---|---|---|
| 01 | Introduction | The problem of monolithic AI code | This capsule |
| 02 | Clean architecture | The 4 layers, directory structure | Architecture defined |
| 03 | Separation of concerns | Extract each responsibility | God functions eliminated |
| 04 | Config management | Type-safe pydantic-settings | Centralized config |
| 05 | Dependency injection | LLMProvider Protocol | Decoupled provider |
| 06 | Environment management | dev/staging/prod configs | Config per environment |
| 07 | Refactored AI App project | Complete refactoring | Organized app |
| 08 | Summary and troubleshooting | Wrap-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:
- "The prompt is hardcoded in
main.pyinside the function" → Solution: externalize to a YAML file +load_prompt() - "I import
from openai import OpenAIdirectly insentiment_service.py" → Solution: LLMProvider Protocol + dependency injection - "The configuration is mixed across
.env,config.py, andmain.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:
- Externalize prompts to YAML
- Create LLMProvider Protocol
- Centralize config with pydantic-settings
See guide
- Externalized prompts: A/B testing prompts without redeploying = hours saved per iteration × all iterations. If you optimize prompts once a week: 2h/week saved.
- 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.
- 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
- Clean Architecture (Robert C. Martin) — The conceptual framework
- pydantic-settings — Configuration management
- typing.Protocol — Interfaces in Python
- Dependency Injection in Python (Martin Fowler) — The fundamental pattern
- Refactoring (Martin Fowler) — The reference book for safe refactoring