Split View: 소프트웨어 아키텍처 & 디자인 패턴 완전 정복: SOLID, Clean Architecture, AI 시스템 설계까지
소프트웨어 아키텍처 & 디자인 패턴 완전 정복: SOLID, Clean Architecture, AI 시스템 설계까지
들어가며
소프트웨어 아키텍처는 시스템의 뼈대입니다. 잘 설계된 아키텍처는 변경에 유연하고, 테스트하기 쉬우며, 팀 전체가 이해할 수 있는 코드를 만들어 냅니다. AI 시대에는 LLM 서비스, RAG 파이프라인, Agent 시스템 등 새로운 설계 과제가 등장했습니다. 이 가이드에서는 고전적인 설계 원칙부터 현대 AI 시스템 아키텍처까지 체계적으로 살펴봅니다.
1. SOLID 원칙
SOLID는 Robert C. Martin이 정리한 5가지 객체지향 설계 원칙으로, 유지보수 가능하고 확장 가능한 소프트웨어를 만드는 기반입니다.
1.1 단일 책임 원칙 (SRP)
클래스는 하나의 책임만 가져야 하며, 변경 이유도 하나여야 합니다.
# BAD: 여러 책임이 하나의 클래스에
class UserManager:
def create_user(self, data): ...
def send_welcome_email(self, user): ...
def save_to_database(self, user): ...
# GOOD: 각 책임을 별도 클래스로 분리
class UserRepository:
def save(self, user): ...
class EmailService:
def send_welcome(self, user): ...
class UserFactory:
def create(self, data): ...
1.2 개방-폐쇄 원칙 (OCP)
소프트웨어 엔티티는 확장에는 열려 있고, 수정에는 닫혀 있어야 합니다.
from abc import ABC, abstractmethod
class Discount(ABC):
@abstractmethod
def apply(self, price: float) -> float: ...
class NoDiscount(Discount):
def apply(self, price: float) -> float:
return price
class PercentDiscount(Discount):
def __init__(self, percent: float):
self.percent = percent
def apply(self, price: float) -> float:
return price * (1 - self.percent / 100)
class VIPDiscount(Discount):
def apply(self, price: float) -> float:
return price * 0.7
# 새로운 할인 정책 추가 시 기존 코드 수정 없이 확장 가능
class Order:
def __init__(self, discount: Discount):
self.discount = discount
def final_price(self, base: float) -> float:
return self.discount.apply(base)
1.3 리스코프 치환 원칙 (LSP)
서브타입은 언제나 기반 타입으로 교체할 수 있어야 합니다.
class Bird:
def fly(self) -> str:
return "Flying"
# LSP 위반: Penguin은 Bird를 상속하지만 날 수 없음
class Penguin(Bird):
def fly(self):
raise NotImplementedError("펭귄은 날 수 없습니다")
# GOOD: 능력 기반으로 인터페이스 분리
class FlyingBird(ABC):
@abstractmethod
def fly(self) -> str: ...
class SwimmingBird(ABC):
@abstractmethod
def swim(self) -> str: ...
class Eagle(FlyingBird):
def fly(self) -> str:
return "독수리가 날아오릅니다"
class Penguin(SwimmingBird):
def swim(self) -> str:
return "펭귄이 수영합니다"
1.4 인터페이스 분리 원칙 (ISP)
클라이언트는 자신이 사용하지 않는 인터페이스에 의존하도록 강요받아서는 안 됩니다.
# BAD: 하나의 거대한 인터페이스
class Machine(ABC):
@abstractmethod
def print(self): ...
@abstractmethod
def scan(self): ...
@abstractmethod
def fax(self): ...
# GOOD: 작은 단위로 분리
class Printable(ABC):
@abstractmethod
def print(self): ...
class Scannable(ABC):
@abstractmethod
def scan(self): ...
class MultiFunctionPrinter(Printable, Scannable):
def print(self): print("인쇄 중...")
def scan(self): print("스캔 중...")
class SimplePrinter(Printable):
def print(self): print("간단 인쇄...")
1.5 의존성 역전 원칙 (DIP)
고수준 모듈은 저수준 모듈에 의존해서는 안 됩니다. 둘 다 추상화에 의존해야 합니다.
# BAD: 고수준이 저수준에 직접 의존
class MySQLDatabase:
def query(self, sql: str): ...
class UserService:
def __init__(self):
self.db = MySQLDatabase() # 구체 클래스에 의존
# GOOD: 추상화에 의존 (의존성 주입)
class DatabasePort(ABC):
@abstractmethod
def find_user(self, user_id: str) -> dict: ...
class MySQLAdapter(DatabasePort):
def find_user(self, user_id: str) -> dict:
# MySQL 구현
return {}
class MongoAdapter(DatabasePort):
def find_user(self, user_id: str) -> dict:
# MongoDB 구현
return {}
class UserService:
def __init__(self, db: DatabasePort):
self.db = db # 추상화에 의존
# 사용 예
service = UserService(db=MySQLAdapter())
2. GoF 디자인 패턴
2.1 Factory 패턴
객체 생성 로직을 캡슐화합니다.
class LLMProvider(ABC):
@abstractmethod
def complete(self, prompt: str) -> str: ...
class OpenAIProvider(LLMProvider):
def complete(self, prompt: str) -> str:
return f"OpenAI 응답: {prompt}"
class AnthropicProvider(LLMProvider):
def complete(self, prompt: str) -> str:
return f"Anthropic 응답: {prompt}"
class LLMFactory:
_registry = {
"openai": OpenAIProvider,
"anthropic": AnthropicProvider,
}
@classmethod
def create(cls, provider: str) -> LLMProvider:
klass = cls._registry.get(provider)
if not klass:
raise ValueError(f"알 수 없는 provider: {provider}")
return klass()
2.2 Singleton 패턴
인스턴스가 오직 하나만 생성되도록 보장합니다.
import threading
class ConfigManager:
_instance = None
_lock = threading.Lock()
def __new__(cls):
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._config = {}
return cls._instance
def set(self, key: str, value):
self._config[key] = value
def get(self, key: str):
return self._config.get(key)
2.3 Observer 패턴
객체 상태 변화를 여러 구독자에게 자동으로 알립니다.
class EventBus:
def __init__(self):
self._subscribers: dict[str, list] = {}
def subscribe(self, event: str, handler):
self._subscribers.setdefault(event, []).append(handler)
def publish(self, event: str, data=None):
for handler in self._subscribers.get(event, []):
handler(data)
# 사용 예
bus = EventBus()
bus.subscribe("user.created", lambda d: print(f"환영 이메일 발송: {d}"))
bus.subscribe("user.created", lambda d: print(f"분석 이벤트 기록: {d}"))
bus.publish("user.created", {"id": "u1", "email": "user@example.com"})
2.4 Strategy 패턴
알고리즘을 캡슐화하고 런타임에 교체 가능하게 합니다.
class SortStrategy(ABC):
@abstractmethod
def sort(self, data: list) -> list: ...
class QuickSort(SortStrategy):
def sort(self, data: list) -> list:
return sorted(data) # 간략화
class MergeSort(SortStrategy):
def sort(self, data: list) -> list:
return sorted(data, key=lambda x: x)
class DataProcessor:
def __init__(self, strategy: SortStrategy):
self._strategy = strategy
def set_strategy(self, strategy: SortStrategy):
self._strategy = strategy
def process(self, data: list) -> list:
return self._strategy.sort(data)
2.5 Decorator 패턴
객체에 동적으로 새로운 책임을 추가합니다.
import time
import functools
def retry(max_attempts: int = 3):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(max_attempts):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == max_attempts - 1:
raise
time.sleep(2 ** attempt)
return wrapper
return decorator
def timed(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
print(f"{func.__name__} 실행 시간: {time.time() - start:.2f}s")
return result
return wrapper
@retry(max_attempts=3)
@timed
def call_llm_api(prompt: str) -> str:
# LLM API 호출
return "응답"
2.6 Command 패턴
요청을 객체로 캡슐화하여 실행 취소 및 큐잉을 가능하게 합니다.
class Command(ABC):
@abstractmethod
def execute(self): ...
@abstractmethod
def undo(self): ...
class CreatePostCommand(Command):
def __init__(self, repo, post_data: dict):
self.repo = repo
self.post_data = post_data
self.created_id = None
def execute(self):
self.created_id = self.repo.create(self.post_data)
def undo(self):
if self.created_id:
self.repo.delete(self.created_id)
class CommandHistory:
def __init__(self):
self._history: list[Command] = []
def execute(self, cmd: Command):
cmd.execute()
self._history.append(cmd)
def undo_last(self):
if self._history:
self._history.pop().undo()
3. 아키텍처 패턴
3.1 Clean Architecture
의존성은 항상 안쪽(도메인)을 향해야 합니다.
외부 계층 → 인터페이스 어댑터 → 유스케이스 → 도메인 엔티티
# Domain Entity (가장 내부)
from dataclasses import dataclass, field
from datetime import datetime
@dataclass
class Article:
id: str
title: str
content: str
author_id: str
created_at: datetime = field(default_factory=datetime.utcnow)
def publish(self):
if not self.title or not self.content:
raise ValueError("제목과 내용은 필수입니다")
# Use Case (도메인 로직 조율)
class CreateArticleUseCase:
def __init__(self, repo: "ArticleRepository", event_bus: EventBus):
self.repo = repo
self.event_bus = event_bus
def execute(self, title: str, content: str, author_id: str) -> Article:
import uuid
article = Article(
id=str(uuid.uuid4()),
title=title,
content=content,
author_id=author_id,
)
article.publish()
self.repo.save(article)
self.event_bus.publish("article.created", {"id": article.id})
return article
# Interface Adapter (외부 계층)
class ArticleController:
def __init__(self, use_case: CreateArticleUseCase):
self.use_case = use_case
def handle_create(self, request: dict) -> dict:
article = self.use_case.execute(
title=request["title"],
content=request["content"],
author_id=request["author_id"],
)
return {"id": article.id, "title": article.title}
3.2 Hexagonal Architecture (Ports & Adapters)
# Port (인터페이스 정의)
class ArticleRepository(ABC):
@abstractmethod
def save(self, article: Article): ...
@abstractmethod
def find_by_id(self, id: str) -> Article: ...
class NotificationPort(ABC):
@abstractmethod
def notify(self, message: str): ...
# Adapter (외부 시스템 연결)
class SQLiteArticleRepository(ArticleRepository):
def save(self, article: Article):
# SQLite 저장 로직
pass
def find_by_id(self, id: str) -> Article:
# SQLite 조회 로직
pass
class SlackNotificationAdapter(NotificationPort):
def notify(self, message: str):
# Slack API 호출
pass
3.3 CQRS + 이벤트 소싱
# Command Side
@dataclass
class CreateOrderCommand:
order_id: str
user_id: str
items: list[dict]
# Query Side (별도 읽기 모델)
@dataclass
class OrderSummaryView:
order_id: str
total_price: float
item_count: int
# Event Sourcing
@dataclass
class OrderCreatedEvent:
order_id: str
user_id: str
items: list[dict]
timestamp: datetime = field(default_factory=datetime.utcnow)
class OrderAggregate:
def __init__(self):
self.events: list = []
self.state = {}
def create(self, cmd: CreateOrderCommand):
event = OrderCreatedEvent(
order_id=cmd.order_id,
user_id=cmd.user_id,
items=cmd.items,
)
self._apply(event)
self.events.append(event)
def _apply(self, event: OrderCreatedEvent):
self.state["id"] = event.order_id
self.state["items"] = event.items
self.state["total"] = sum(
i.get("price", 0) * i.get("qty", 1) for i in event.items
)
4. 마이크로서비스 패턴
4.1 API Gateway 패턴
from fastapi import FastAPI, HTTPException
import httpx
app = FastAPI(title="API Gateway")
SERVICE_MAP = {
"users": "http://user-service:8001",
"orders": "http://order-service:8002",
"products": "http://product-service:8003",
}
@app.get("/api/users/{user_id}")
async def proxy_user(user_id: str):
async with httpx.AsyncClient() as client:
resp = await client.get(f"{SERVICE_MAP['users']}/users/{user_id}")
if resp.status_code == 404:
raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다")
return resp.json()
4.2 Saga 패턴 (분산 트랜잭션)
마이크로서비스 간 데이터 일관성을 보장하기 위해 일련의 로컬 트랜잭션과 보상 트랜잭션으로 처리합니다.
class OrderSaga:
def __init__(self, order_service, payment_service, inventory_service):
self.order_svc = order_service
self.payment_svc = payment_service
self.inventory_svc = inventory_service
async def execute(self, order_data: dict):
order_id = None
payment_id = None
try:
# Step 1: 주문 생성
order_id = await self.order_svc.create(order_data)
# Step 2: 결제 처리
payment_id = await self.payment_svc.charge(order_data["amount"])
# Step 3: 재고 차감
await self.inventory_svc.reserve(order_data["items"])
return {"status": "success", "order_id": order_id}
except Exception as e:
# 보상 트랜잭션 (역순 롤백)
if payment_id:
await self.payment_svc.refund(payment_id)
if order_id:
await self.order_svc.cancel(order_id)
raise
5. 클린 코드 원칙
5.1 의미 있는 네이밍
# BAD
def calc(d, r):
return d * (1 - r / 100)
# GOOD
def calculate_discounted_price(original_price: float, discount_rate_percent: float) -> float:
return original_price * (1 - discount_rate_percent / 100)
5.2 함수 설계 — 작고 한 가지만
# BAD: 너무 많은 책임
def process_user_registration(email, password, name, send_email=True):
# 유효성 검사, DB 저장, 이메일 발송을 모두 처리
if not "@" in email:
raise ValueError("이메일 형식 오류")
hashed = hash(password)
user = {"email": email, "password": hashed, "name": name}
db.save(user)
if send_email:
mailer.send(email, "환영합니다!")
return user
# GOOD: 분리된 함수들
def validate_email(email: str) -> None:
if "@" not in email:
raise ValueError("이메일 형식 오류")
def hash_password(raw: str) -> str:
import hashlib
return hashlib.sha256(raw.encode()).hexdigest()
def register_user(email: str, password: str, name: str) -> dict:
validate_email(email)
return {"email": email, "password": hash_password(password), "name": name}
5.3 코드 냄새와 리팩토링
대표적인 코드 냄새(code smell)와 해결책입니다.
- Long Method: 함수를 작은 단위로 추출 (Extract Method)
- Large Class: 책임에 따라 클래스 분리 (Extract Class)
- Feature Envy: 다른 클래스의 데이터를 너무 많이 사용한다면 메서드를 이동 (Move Method)
- Magic Numbers: 상수로 추출 (Replace Magic Number with Symbolic Constant)
- Duplicate Code: 공통 함수로 추출 (Extract Function)
6. AI 시스템 아키텍처
6.1 RAG (Retrieval-Augmented Generation) 아키텍처
from dataclasses import dataclass
@dataclass
class RAGConfig:
embedding_model: str = "text-embedding-3-small"
llm_model: str = "gpt-4o"
top_k: int = 5
chunk_size: int = 512
class RAGPipeline:
def __init__(self, config: RAGConfig, vector_store, llm_client):
self.config = config
self.vector_store = vector_store
self.llm = llm_client
def retrieve(self, query: str) -> list[str]:
# 1. 쿼리 임베딩
query_vector = self.llm.embed(query)
# 2. 유사 문서 검색
docs = self.vector_store.search(query_vector, top_k=self.config.top_k)
return [d["content"] for d in docs]
def generate(self, query: str, context: list[str]) -> str:
context_text = "\n\n".join(context)
prompt = f"""다음 컨텍스트를 참고하여 질문에 답하세요.
컨텍스트:
{context_text}
질문: {query}
답변:"""
return self.llm.complete(prompt)
def query(self, user_question: str) -> str:
context = self.retrieve(user_question)
return self.generate(user_question, context)
6.2 Agent 시스템 설계
@dataclass
class Tool:
name: str
description: str
func: callable
class ReActAgent:
"""Reasoning + Acting 패턴의 AI Agent"""
def __init__(self, llm, tools: list[Tool]):
self.llm = llm
self.tools = {t.name: t for t in tools}
def _build_system_prompt(self) -> str:
tool_desc = "\n".join(
f"- {t.name}: {t.description}" for t in self.tools.values()
)
return f"""당신은 도구를 사용하여 문제를 해결하는 AI Agent입니다.
사용 가능한 도구:
{tool_desc}
형식:
Thought: [현재 상황 분석]
Action: [사용할 도구 이름]
Action Input: [도구에 전달할 입력]
Observation: [도구 실행 결과]
... (반복)
Final Answer: [최종 답변]"""
def run(self, task: str, max_steps: int = 10) -> str:
messages = [{"role": "user", "content": task}]
for _ in range(max_steps):
response = self.llm.chat(messages)
if "Final Answer:" in response:
return response.split("Final Answer:")[-1].strip()
# Action 파싱 및 실행
if "Action:" in response:
action_line = [l for l in response.split("\n") if l.startswith("Action:")]
if action_line:
tool_name = action_line[0].replace("Action:", "").strip()
tool = self.tools.get(tool_name)
if tool:
observation = tool.func(response)
messages.append({"role": "assistant", "content": response})
messages.append({"role": "user", "content": f"Observation: {observation}"})
return "최대 단계 초과"
7. 테스트 전략
7.1 테스트 피라미드
[E2E 테스트] ← 느리고 비용 높음, 소수 유지
[통합 테스트] ← 서비스 간 계약 검증
[단위 테스트] ← 빠르고 저비용, 다수 유지
7.2 TDD 예시 (Red-Green-Refactor)
import pytest
# 1. RED: 실패하는 테스트 먼저 작성
def test_calculate_discounted_price_basic():
assert calculate_discounted_price(100.0, 20.0) == 80.0
def test_calculate_discounted_price_zero_discount():
assert calculate_discounted_price(100.0, 0.0) == 100.0
def test_calculate_discounted_price_full_discount():
assert calculate_discounted_price(100.0, 100.0) == 0.0
def test_calculate_discounted_price_invalid_rate():
with pytest.raises(ValueError):
calculate_discounted_price(100.0, -10.0)
# 2. GREEN: 테스트를 통과하는 최소 구현
def calculate_discounted_price(price: float, discount_rate: float) -> float:
if discount_rate < 0 or discount_rate > 100:
raise ValueError("할인율은 0~100 사이여야 합니다")
return price * (1 - discount_rate / 100)
# 3. REFACTOR: 품질 개선 (테스트는 그대로 통과)
7.3 모킹 전략
from unittest.mock import MagicMock, patch
class TestUserService:
def test_create_user_sends_email(self):
mock_repo = MagicMock()
mock_email = MagicMock()
mock_repo.save.return_value = {"id": "u1"}
service = UserService(repo=mock_repo, email_svc=mock_email)
service.register("test@example.com", "홍길동")
mock_repo.save.assert_called_once()
mock_email.send_welcome.assert_called_once_with("test@example.com")
def test_create_user_handles_db_error(self):
mock_repo = MagicMock()
mock_repo.save.side_effect = Exception("DB 연결 실패")
mock_email = MagicMock()
service = UserService(repo=mock_repo, email_svc=mock_email)
with pytest.raises(Exception):
service.register("test@example.com", "홍길동")
mock_email.send_welcome.assert_not_called()
퀴즈
Q1. Dependency Inversion 원칙에서 고수준 모듈이 저수준 모듈에 직접 의존하면 안 되는 이유는 무엇인가요?
정답: 저수준 모듈의 변경이 고수준 모듈까지 전파되어 시스템 전반의 변경 비용이 커지기 때문입니다.
설명: 고수준 모듈(비즈니스 로직)이 저수준 모듈(DB, 외부 API)에 직접 의존하면, DB를 MySQL에서 PostgreSQL로 교체하는 것만으로 비즈니스 로직 코드가 수정되어야 합니다. 추상화(인터페이스)에 의존하면 저수준 구현체만 교체하면 되고 고수준 코드는 그대로 유지됩니다. 이는 테스트 시 Mock 주입도 용이하게 만들어 줍니다.
Q2. Observer 패턴과 Pub/Sub 패턴의 차이는 무엇인가요?
정답: Observer는 Subject와 Observer가 직접 참조 관계를 가지지만, Pub/Sub은 Publisher와 Subscriber 사이에 메시지 브로커(이벤트 버스)가 있어 완전히 분리됩니다.
설명: Observer 패턴에서는 Subject가 Observer 목록을 직접 관리하므로 둘이 같은 프로세스에 있어야 합니다. Pub/Sub에서는 Kafka, RabbitMQ 같은 브로커를 통해 Publisher와 Subscriber가 서로를 모르는 상태에서 통신합니다. 마이크로서비스 비동기 통신에는 Pub/Sub이 더 적합합니다.
Q3. CQRS에서 Command와 Query를 분리하는 이점과 복잡도 트레이드오프는?
정답: 읽기/쓰기 부하를 독립적으로 확장할 수 있고 읽기 모델을 최적화할 수 있지만, 데이터 일관성 지연(Eventually Consistent)과 코드 복잡도 증가가 단점입니다.
설명: Command(쓰기)는 강한 일관성이 필요하고, Query(읽기)는 성능 최적화가 중요합니다. 분리하면 읽기 전용 복제본을 여러 개 두거나 비정규화된 읽기 모델을 만들 수 있습니다. 단, 이벤트 소싱과 결합하면 읽기/쓰기 모델 간 동기화 지연이 발생하고, 시스템 전체 복잡도가 크게 올라갑니다.
Q4. 마이크로서비스에서 Saga 패턴이 필요한 상황은 언제인가요?
정답: 여러 마이크로서비스에 걸친 비즈니스 트랜잭션을 처리할 때, 분산 환경에서 2PC(Two-Phase Commit) 없이 데이터 일관성을 보장해야 할 때 필요합니다.
설명: 주문-결제-배송이 각각 다른 서비스인 경우 하나의 DB 트랜잭션으로 처리할 수 없습니다. Saga는 각 단계를 로컬 트랜잭션으로 처리하고, 중간에 실패하면 이미 완료된 단계를 역순으로 보상 트랜잭션(compensating transaction)으로 롤백합니다. Choreography 방식(이벤트 기반)과 Orchestration 방식(중앙 조율자)으로 구현할 수 있습니다.
Q5. TDD의 Red-Green-Refactor 사이클에서 Green 단계에서 최소한의 코드만 작성하는 이유는?
정답: 현재 실패하는 테스트만 통과시키는 코드를 작성함으로써 테스트가 실제로 행동을 검증하는지 확인하고, 과잉 설계(YAGNI 위반)를 방지하기 위해서입니다.
설명: 최소 구현을 강제하면 테스트가 명세(specification)로서 역할을 제대로 하는지 검증됩니다. 만약 처음부터 완전한 구현을 한다면 테스트가 올바른 이유로 통과하는지 알 수 없습니다. Refactor 단계에서 코드 품질을 높이되 테스트가 보호망 역할을 하므로, 리팩토링이 안전하게 이루어집니다.
마무리
소프트웨어 아키텍처는 한 번 배우고 끝나는 지식이 아닙니다. SOLID 원칙은 작은 함수 하나를 설계할 때부터 적용되고, Clean Architecture는 팀이 수년간 유지보수할 시스템의 기반이 됩니다. AI 시스템 설계에서도 이 원칙들은 그대로 적용됩니다. RAG 파이프라인도 포트-어댑터로 설계하면 벡터 DB를 교체하기 쉽고, Agent 시스템도 Command 패턴으로 도구 실행을 관리하면 확장성이 높아집니다.
좋은 아키텍처는 변경을 두렵지 않게 만듭니다. 오늘 배운 패턴들을 실제 프로젝트에 하나씩 적용해 보세요.
Mastering Software Architecture & Design Patterns: From SOLID to Clean Architecture and AI System Design
- Introduction
- 1. SOLID Principles
- 2. GoF Design Patterns
- 3. Architecture Patterns
- 4. Microservices Patterns
- 5. Clean Code Principles
- 6. AI System Architecture
- 7. Testing Strategy
- Quiz
- Conclusion
Introduction
Software architecture is the skeleton of a system. Well-designed architecture is flexible to change, easy to test, and produces code that the entire team can understand. In the AI era, new design challenges have emerged: LLM services, RAG pipelines, and Agent systems. This guide walks through everything from classical design principles to modern AI system architecture.
1. SOLID Principles
SOLID is a set of five object-oriented design principles compiled by Robert C. Martin. They form the foundation for building maintainable and extensible software.
1.1 Single Responsibility Principle (SRP)
A class should have one, and only one, reason to change.
# BAD: multiple responsibilities in one class
class UserManager:
def create_user(self, data): ...
def send_welcome_email(self, user): ...
def save_to_database(self, user): ...
# GOOD: each responsibility in its own class
class UserRepository:
def save(self, user): ...
class EmailService:
def send_welcome(self, user): ...
class UserFactory:
def create(self, data): ...
1.2 Open/Closed Principle (OCP)
Software entities should be open for extension but closed for modification.
from abc import ABC, abstractmethod
class Discount(ABC):
@abstractmethod
def apply(self, price: float) -> float: ...
class NoDiscount(Discount):
def apply(self, price: float) -> float:
return price
class PercentDiscount(Discount):
def __init__(self, percent: float):
self.percent = percent
def apply(self, price: float) -> float:
return price * (1 - self.percent / 100)
class VIPDiscount(Discount):
def apply(self, price: float) -> float:
return price * 0.7
# New discount policies can be added without modifying existing code
class Order:
def __init__(self, discount: Discount):
self.discount = discount
def final_price(self, base: float) -> float:
return self.discount.apply(base)
1.3 Liskov Substitution Principle (LSP)
Subtypes must be substitutable for their base types without altering the correctness of the program.
class Bird:
def fly(self) -> str:
return "Flying"
# LSP violation: Penguin inherits Bird but cannot fly
class Penguin(Bird):
def fly(self):
raise NotImplementedError("Penguins cannot fly")
# GOOD: separate interfaces based on capabilities
class FlyingBird(ABC):
@abstractmethod
def fly(self) -> str: ...
class SwimmingBird(ABC):
@abstractmethod
def swim(self) -> str: ...
class Eagle(FlyingBird):
def fly(self) -> str:
return "Eagle soars through the sky"
class Penguin(SwimmingBird):
def swim(self) -> str:
return "Penguin swims gracefully"
1.4 Interface Segregation Principle (ISP)
Clients should not be forced to depend on interfaces they do not use.
# BAD: one bloated interface
class Machine(ABC):
@abstractmethod
def print(self): ...
@abstractmethod
def scan(self): ...
@abstractmethod
def fax(self): ...
# GOOD: split into smaller, focused interfaces
class Printable(ABC):
@abstractmethod
def print(self): ...
class Scannable(ABC):
@abstractmethod
def scan(self): ...
class MultiFunctionPrinter(Printable, Scannable):
def print(self): print("Printing...")
def scan(self): print("Scanning...")
class SimplePrinter(Printable):
def print(self): print("Simple print...")
1.5 Dependency Inversion Principle (DIP)
High-level modules should not depend on low-level modules. Both should depend on abstractions.
# BAD: high-level depends directly on low-level
class MySQLDatabase:
def query(self, sql: str): ...
class UserService:
def __init__(self):
self.db = MySQLDatabase() # depends on a concrete class
# GOOD: depend on abstractions (dependency injection)
class DatabasePort(ABC):
@abstractmethod
def find_user(self, user_id: str) -> dict: ...
class MySQLAdapter(DatabasePort):
def find_user(self, user_id: str) -> dict:
# MySQL implementation
return {}
class MongoAdapter(DatabasePort):
def find_user(self, user_id: str) -> dict:
# MongoDB implementation
return {}
class UserService:
def __init__(self, db: DatabasePort):
self.db = db # depends on the abstraction
# Usage
service = UserService(db=MySQLAdapter())
2. GoF Design Patterns
2.1 Factory Pattern
Encapsulates object creation logic.
class LLMProvider(ABC):
@abstractmethod
def complete(self, prompt: str) -> str: ...
class OpenAIProvider(LLMProvider):
def complete(self, prompt: str) -> str:
return f"OpenAI response: {prompt}"
class AnthropicProvider(LLMProvider):
def complete(self, prompt: str) -> str:
return f"Anthropic response: {prompt}"
class LLMFactory:
_registry = {
"openai": OpenAIProvider,
"anthropic": AnthropicProvider,
}
@classmethod
def create(cls, provider: str) -> LLMProvider:
klass = cls._registry.get(provider)
if not klass:
raise ValueError(f"Unknown provider: {provider}")
return klass()
2.2 Singleton Pattern
Ensures a class has only one instance and provides a global access point to it.
import threading
class ConfigManager:
_instance = None
_lock = threading.Lock()
def __new__(cls):
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._config = {}
return cls._instance
def set(self, key: str, value):
self._config[key] = value
def get(self, key: str):
return self._config.get(key)
2.3 Observer Pattern
Automatically notifies multiple subscribers when an object's state changes.
class EventBus:
def __init__(self):
self._subscribers: dict[str, list] = {}
def subscribe(self, event: str, handler):
self._subscribers.setdefault(event, []).append(handler)
def publish(self, event: str, data=None):
for handler in self._subscribers.get(event, []):
handler(data)
# Usage
bus = EventBus()
bus.subscribe("user.created", lambda d: print(f"Send welcome email: {d}"))
bus.subscribe("user.created", lambda d: print(f"Log analytics event: {d}"))
bus.publish("user.created", {"id": "u1", "email": "user@example.com"})
2.4 Strategy Pattern
Encapsulates algorithms and makes them interchangeable at runtime.
class SortStrategy(ABC):
@abstractmethod
def sort(self, data: list) -> list: ...
class QuickSort(SortStrategy):
def sort(self, data: list) -> list:
return sorted(data)
class MergeSort(SortStrategy):
def sort(self, data: list) -> list:
return sorted(data, key=lambda x: x)
class DataProcessor:
def __init__(self, strategy: SortStrategy):
self._strategy = strategy
def set_strategy(self, strategy: SortStrategy):
self._strategy = strategy
def process(self, data: list) -> list:
return self._strategy.sort(data)
2.5 Decorator Pattern
Adds new responsibilities to objects dynamically.
import time
import functools
def retry(max_attempts: int = 3):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(max_attempts):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == max_attempts - 1:
raise
time.sleep(2 ** attempt)
return wrapper
return decorator
def timed(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
print(f"{func.__name__} took {time.time() - start:.2f}s")
return result
return wrapper
@retry(max_attempts=3)
@timed
def call_llm_api(prompt: str) -> str:
return "response"
2.6 Command Pattern
Encapsulates requests as objects, enabling undo/redo and queuing.
class Command(ABC):
@abstractmethod
def execute(self): ...
@abstractmethod
def undo(self): ...
class CreatePostCommand(Command):
def __init__(self, repo, post_data: dict):
self.repo = repo
self.post_data = post_data
self.created_id = None
def execute(self):
self.created_id = self.repo.create(self.post_data)
def undo(self):
if self.created_id:
self.repo.delete(self.created_id)
class CommandHistory:
def __init__(self):
self._history: list[Command] = []
def execute(self, cmd: Command):
cmd.execute()
self._history.append(cmd)
def undo_last(self):
if self._history:
self._history.pop().undo()
3. Architecture Patterns
3.1 Clean Architecture
Dependencies always point inward (toward the domain).
Outer Layer → Interface Adapters → Use Cases → Domain Entities
# Domain Entity (innermost layer)
from dataclasses import dataclass, field
from datetime import datetime
@dataclass
class Article:
id: str
title: str
content: str
author_id: str
created_at: datetime = field(default_factory=datetime.utcnow)
def publish(self):
if not self.title or not self.content:
raise ValueError("Title and content are required")
# Use Case (orchestrates domain logic)
class CreateArticleUseCase:
def __init__(self, repo: "ArticleRepository", event_bus: EventBus):
self.repo = repo
self.event_bus = event_bus
def execute(self, title: str, content: str, author_id: str) -> Article:
import uuid
article = Article(
id=str(uuid.uuid4()),
title=title,
content=content,
author_id=author_id,
)
article.publish()
self.repo.save(article)
self.event_bus.publish("article.created", {"id": article.id})
return article
# Interface Adapter (outermost layer)
class ArticleController:
def __init__(self, use_case: CreateArticleUseCase):
self.use_case = use_case
def handle_create(self, request: dict) -> dict:
article = self.use_case.execute(
title=request["title"],
content=request["content"],
author_id=request["author_id"],
)
return {"id": article.id, "title": article.title}
3.2 Hexagonal Architecture (Ports and Adapters)
# Port (interface definition)
class ArticleRepository(ABC):
@abstractmethod
def save(self, article: Article): ...
@abstractmethod
def find_by_id(self, id: str) -> Article: ...
class NotificationPort(ABC):
@abstractmethod
def notify(self, message: str): ...
# Adapter (connects to external systems)
class SQLiteArticleRepository(ArticleRepository):
def save(self, article: Article):
pass # SQLite save logic
def find_by_id(self, id: str) -> Article:
pass # SQLite query logic
class SlackNotificationAdapter(NotificationPort):
def notify(self, message: str):
pass # Slack API call
3.3 CQRS and Event Sourcing
# Command Side
@dataclass
class CreateOrderCommand:
order_id: str
user_id: str
items: list[dict]
# Query Side (separate read model)
@dataclass
class OrderSummaryView:
order_id: str
total_price: float
item_count: int
# Event Sourcing
@dataclass
class OrderCreatedEvent:
order_id: str
user_id: str
items: list[dict]
timestamp: datetime = field(default_factory=datetime.utcnow)
class OrderAggregate:
def __init__(self):
self.events: list = []
self.state = {}
def create(self, cmd: CreateOrderCommand):
event = OrderCreatedEvent(
order_id=cmd.order_id,
user_id=cmd.user_id,
items=cmd.items,
)
self._apply(event)
self.events.append(event)
def _apply(self, event: OrderCreatedEvent):
self.state["id"] = event.order_id
self.state["items"] = event.items
self.state["total"] = sum(
i.get("price", 0) * i.get("qty", 1) for i in event.items
)
4. Microservices Patterns
4.1 API Gateway Pattern
from fastapi import FastAPI, HTTPException
import httpx
app = FastAPI(title="API Gateway")
SERVICE_MAP = {
"users": "http://user-service:8001",
"orders": "http://order-service:8002",
"products": "http://product-service:8003",
}
@app.get("/api/users/{user_id}")
async def proxy_user(user_id: str):
async with httpx.AsyncClient() as client:
resp = await client.get(f"{SERVICE_MAP['users']}/users/{user_id}")
if resp.status_code == 404:
raise HTTPException(status_code=404, detail="User not found")
return resp.json()
4.2 Saga Pattern (Distributed Transactions)
The Saga pattern ensures data consistency across microservices using a series of local transactions and compensating transactions.
class OrderSaga:
def __init__(self, order_service, payment_service, inventory_service):
self.order_svc = order_service
self.payment_svc = payment_service
self.inventory_svc = inventory_service
async def execute(self, order_data: dict):
order_id = None
payment_id = None
try:
# Step 1: Create order
order_id = await self.order_svc.create(order_data)
# Step 2: Process payment
payment_id = await self.payment_svc.charge(order_data["amount"])
# Step 3: Reserve inventory
await self.inventory_svc.reserve(order_data["items"])
return {"status": "success", "order_id": order_id}
except Exception as e:
# Compensating transactions (reverse rollback)
if payment_id:
await self.payment_svc.refund(payment_id)
if order_id:
await self.order_svc.cancel(order_id)
raise
5. Clean Code Principles
5.1 Meaningful Naming
# BAD
def calc(d, r):
return d * (1 - r / 100)
# GOOD
def calculate_discounted_price(original_price: float, discount_rate_percent: float) -> float:
return original_price * (1 - discount_rate_percent / 100)
5.2 Function Design — Small and Single Purpose
# BAD: too many responsibilities
def process_user_registration(email, password, name, send_email=True):
if "@" not in email:
raise ValueError("Invalid email format")
hashed = hash(password)
user = {"email": email, "password": hashed, "name": name}
db.save(user)
if send_email:
mailer.send(email, "Welcome!")
return user
# GOOD: separated functions
def validate_email(email: str) -> None:
if "@" not in email:
raise ValueError("Invalid email format")
def hash_password(raw: str) -> str:
import hashlib
return hashlib.sha256(raw.encode()).hexdigest()
def register_user(email: str, password: str, name: str) -> dict:
validate_email(email)
return {"email": email, "password": hash_password(password), "name": name}
5.3 Code Smells and Refactoring
Common code smells and their solutions:
- Long Method: Extract into smaller functions (Extract Method)
- Large Class: Split by responsibility (Extract Class)
- Feature Envy: Move method closer to the data it uses (Move Method)
- Magic Numbers: Replace with named constants (Replace Magic Number with Symbolic Constant)
- Duplicate Code: Extract to a shared function (Extract Function)
6. AI System Architecture
6.1 RAG (Retrieval-Augmented Generation) Architecture
from dataclasses import dataclass
@dataclass
class RAGConfig:
embedding_model: str = "text-embedding-3-small"
llm_model: str = "gpt-4o"
top_k: int = 5
chunk_size: int = 512
class RAGPipeline:
def __init__(self, config: RAGConfig, vector_store, llm_client):
self.config = config
self.vector_store = vector_store
self.llm = llm_client
def retrieve(self, query: str) -> list[str]:
# 1. Embed the query
query_vector = self.llm.embed(query)
# 2. Search for similar documents
docs = self.vector_store.search(query_vector, top_k=self.config.top_k)
return [d["content"] for d in docs]
def generate(self, query: str, context: list[str]) -> str:
context_text = "\n\n".join(context)
prompt = f"""Answer the question using the following context.
Context:
{context_text}
Question: {query}
Answer:"""
return self.llm.complete(prompt)
def query(self, user_question: str) -> str:
context = self.retrieve(user_question)
return self.generate(user_question, context)
6.2 Agent System Design
@dataclass
class Tool:
name: str
description: str
func: callable
class ReActAgent:
"""AI Agent using the Reasoning + Acting pattern"""
def __init__(self, llm, tools: list[Tool]):
self.llm = llm
self.tools = {t.name: t for t in tools}
def _build_system_prompt(self) -> str:
tool_desc = "\n".join(
f"- {t.name}: {t.description}" for t in self.tools.values()
)
return f"""You are an AI Agent that solves tasks using tools.
Available tools:
{tool_desc}
Format:
Thought: [analyze the current situation]
Action: [tool name to use]
Action Input: [input for the tool]
Observation: [result of the tool execution]
... (repeat)
Final Answer: [final answer]"""
def run(self, task: str, max_steps: int = 10) -> str:
messages = [{"role": "user", "content": task}]
for _ in range(max_steps):
response = self.llm.chat(messages)
if "Final Answer:" in response:
return response.split("Final Answer:")[-1].strip()
if "Action:" in response:
action_line = [l for l in response.split("\n") if l.startswith("Action:")]
if action_line:
tool_name = action_line[0].replace("Action:", "").strip()
tool = self.tools.get(tool_name)
if tool:
observation = tool.func(response)
messages.append({"role": "assistant", "content": response})
messages.append({"role": "user", "content": f"Observation: {observation}"})
return "Max steps exceeded"
7. Testing Strategy
7.1 The Test Pyramid
[E2E Tests] <- slow and costly, keep few
[Integration Tests] <- verify service contracts
[Unit Tests] <- fast and cheap, keep many
7.2 TDD Example (Red-Green-Refactor)
import pytest
# 1. RED: write a failing test first
def test_calculate_discounted_price_basic():
assert calculate_discounted_price(100.0, 20.0) == 80.0
def test_calculate_discounted_price_zero_discount():
assert calculate_discounted_price(100.0, 0.0) == 100.0
def test_calculate_discounted_price_full_discount():
assert calculate_discounted_price(100.0, 100.0) == 0.0
def test_calculate_discounted_price_invalid_rate():
with pytest.raises(ValueError):
calculate_discounted_price(100.0, -10.0)
# 2. GREEN: minimal implementation to pass the tests
def calculate_discounted_price(price: float, discount_rate: float) -> float:
if discount_rate < 0 or discount_rate > 100:
raise ValueError("Discount rate must be between 0 and 100")
return price * (1 - discount_rate / 100)
# 3. REFACTOR: improve quality while keeping tests green
7.3 Mocking Strategy
from unittest.mock import MagicMock, patch
class TestUserService:
def test_create_user_sends_email(self):
mock_repo = MagicMock()
mock_email = MagicMock()
mock_repo.save.return_value = {"id": "u1"}
service = UserService(repo=mock_repo, email_svc=mock_email)
service.register("test@example.com", "John Doe")
mock_repo.save.assert_called_once()
mock_email.send_welcome.assert_called_once_with("test@example.com")
def test_create_user_handles_db_error(self):
mock_repo = MagicMock()
mock_repo.save.side_effect = Exception("DB connection failed")
mock_email = MagicMock()
service = UserService(repo=mock_repo, email_svc=mock_email)
with pytest.raises(Exception):
service.register("test@example.com", "John Doe")
mock_email.send_welcome.assert_not_called()
Quiz
Q1. Why should high-level modules not directly depend on low-level modules in the Dependency Inversion Principle?
Answer: Because changes in low-level modules propagate up to high-level modules, increasing the cost of change across the entire system.
Explanation: When business logic (high-level) depends directly on infrastructure (low-level like a database or external API), swapping MySQL for PostgreSQL forces changes in the business logic code. Depending on an abstraction (interface) means only the low-level adapter needs to change while the high-level code stays untouched. This also makes it easy to inject mocks during testing.
Q2. What is the difference between the Observer pattern and the Pub/Sub pattern?
Answer: In Observer, Subject and Observer have a direct reference relationship. In Pub/Sub, a message broker (event bus) sits between Publisher and Subscriber, fully decoupling them.
Explanation: In the Observer pattern, the Subject maintains a list of Observers directly, so they must live in the same process. In Pub/Sub, a broker like Kafka or RabbitMQ enables Publisher and Subscriber to communicate without knowing each other. Pub/Sub is better suited for asynchronous communication in microservices.
Q3. What are the benefits and complexity trade-offs of separating Commands and Queries in CQRS?
Answer: Read and write workloads can scale independently and read models can be optimized, but the downsides are eventual consistency delays and increased code complexity.
Explanation: Commands (writes) require strong consistency while Queries (reads) need performance optimization. Separation allows multiple read replicas or denormalized read models. However, combining CQRS with event sourcing introduces synchronization lag between models and significantly increases overall system complexity.
Q4. When is the Saga pattern necessary in a microservices architecture?
Answer: When a business transaction spans multiple microservices and you need data consistency in a distributed environment without using Two-Phase Commit (2PC).
Explanation: When order, payment, and shipping are separate services, they cannot share a single database transaction. Saga handles each step as a local transaction and rolls back completed steps using compensating transactions if a failure occurs. It can be implemented as Choreography (event-driven) or Orchestration (central coordinator).
Q5. Why should you write only the minimum code necessary during the Green phase of TDD's Red-Green-Refactor cycle?
Answer: To verify that the test genuinely validates behavior and to prevent over-engineering (YAGNI violations), by writing only enough code to pass the currently failing test.
Explanation: Enforcing minimal implementation confirms that tests are working as specifications. If you implement everything upfront, you cannot be sure that tests pass for the right reasons. During the Refactor phase, tests serve as a safety net so refactoring can proceed safely without breaking behavior.
Conclusion
Software architecture is not a one-time lesson. SOLID principles apply from the smallest function design, and Clean Architecture forms the backbone of systems a team will maintain for years. These principles apply equally to AI systems: RAG pipelines designed with ports and adapters make swapping vector databases painless, and Agent systems that use the Command pattern for tool execution become highly extensible.
Good architecture makes change fearless. Apply the patterns you learned today to your real projects, one at a time.