~/abhipraya
PPL: Programming [Sprint 2, Week 2]
What I Worked On
Two major features this week, email integration (SIRA-160) and session management (SIRA-214), both followed the SOLID principles that have been established in the codebase. The email feature in particular offered a good opportunity to show SRP in action: sending an email and rendering an email template are genuinely different responsibilities, and keeping them separate made both easier to test.
Email Integration: Separating Transport from Rendering
SIRA-160 (MR !108) introduced three distinct services:
EmailService— handles only HTTP transport to the Resend APIEmailTemplateService— handles template rendering (Jinja2, variable substitution, preview generation)EmailDefaultsService— manages default templates and per-client overrides
Each has one reason to change. EmailService changes if the email provider changes. EmailTemplateService changes if the template format changes. Neither knows about the other.
EmailService as written:
class EmailService:
"""Sends emails via the Resend REST API."""
def __init__(self, api_key: str, from_address: str) -> None:
self._api_key = api_key
self._from_address = from_address
async def send_email(self, to: str, subject: str, html_body: str) -> EmailResult:
async with httpx.AsyncClient() as client:
response = await client.post(
RESEND_API_URL,
headers={"Authorization": f"Bearer {self._api_key}", ...},
json={"from": self._from_address, "to": [to], "subject": subject, "html": html_body},
timeout=30.0,
)
...
It receives an already-rendered html_body. It does not know how that HTML was produced. The router combines both:
@router.post("/emails/send")
async def send_email(payload: SendEmailRequest, db: Client = Depends(get_db)) -> EmailResult:
template_service = EmailTemplateService(db)
html_body = await template_service.render(payload.template_id, payload.variables)
email_service = get_email_service() # injected from config
return await email_service.send_email(payload.to, payload.subject, html_body)
The router orchestrates. The services stay focused.
Session Management: SRP in the Service Layer
SIRA-214 (MR !120) added SessionService, which handles the session lifecycle: create, list, revoke, enforce device limits. The router is thin:
@router.get("/sessions")
async def list_sessions(
current_user: UserResponse = Depends(get_current_user),
db: Client = Depends(get_db),
) -> list[SessionResponse]:
service = SessionService(db)
return await service.list_sessions(current_user.id, current_user.session_id)
All device-limit logic lives in SessionService, not in the router. The router handles HTTP concerns (auth dependency, response schema). The service handles business logic (MAX_SESSIONS cap, oldest-session eviction, ownership validation on revoke).
The DB layer (db/queries/user_sessions.py) handles only SQL: no business logic, no HTTP context. This three-layer separation means each layer can be tested in isolation, which was important for covering the session-limit edge cases.
Router-Service-DB Pattern Maintained in Cancel Invoice
SIRA-125 (MR !118) added invoice cancellation. The same layered pattern holds:
routers/invoices.py: validates the incoming request, calls service, returns 200services/invoice_service.py: enforces the cancellation rules (UNPAID/OVERDUE only), updates statusdb/queries/invoices.py: executes the DB mutation
No business logic leaked into the router. No DB calls from the service directly. Consistent with every other feature in the backend.
Results
- 3 new services introduced this week, each with a single responsibility
- All services are dependency-injectable (accept
db: Clientin__init__) - Router-Service-DB pattern maintained across email, sessions, and cancel invoice
- 100% test coverage on all new service code