Architecture Decision Records (ADR) - Writing Guide
This skill provides comprehensive guidance for writing high-quality Architecture Decision Records (ADRs) for the VedaGhosham project, based on the existing 13 ADRs in docs/architecture-decisions/.
What is an ADR?
An Architecture Decision Record documents a significant technical decision made during the project's development. ADRs capture:
- •What decision was made
- •Why it was made (problem context)
- •How it's implemented (with code examples)
- •Trade-offs (what was gained/lost)
- •Alternatives considered
When to Write an ADR
Write an ADR when making decisions that:
- •Impact the entire system (database choice, framework selection, deployment strategy)
- •Are difficult to reverse (DynamoDB single-table design, monorepo structure)
- •Involve significant trade-offs (tRPC vs REST, Remix vs Next.js)
- •Set architectural patterns (entity structure, error handling, authentication)
- •Require future context (why we chose this approach over alternatives)
Examples of ADR-Worthy Decisions
- •✅ Choosing DynamoDB single-table design (ADR-004)
- •✅ Selecting tRPC over REST (ADR-005)
- •✅ Email blacklist management strategy (ADR-015)
- •✅ Fresh user data from database vs JWT (ADR-001)
- •✅ Monorepo with pnpm workspaces (ADR-009)
Examples of Non-ADR Decisions
- •❌ Adding a new field to an entity (routine change)
- •❌ Refactoring a component (implementation detail)
- •❌ Fixing a bug (not an architectural decision)
- •❌ Updating dependencies (maintenance task)
ADR Format and Structure
File Naming Convention
docs/architecture-decisions/adr-NNN-kebab-case-title.md
- •NNN: Zero-padded 3-digit number (001, 002, 015, etc.)
- •kebab-case-title: Descriptive, lowercase, hyphen-separated
- •Find the next number by checking existing ADRs
Examples:
- •
adr-001-fresh-user-data.md - •
adr-005-trpc-over-rest.md - •
adr-015-email-blacklist-bounce-management.md
Document Structure
Every ADR follows this structure:
# ADR-NNN: Title (Clear, Concise Description) **Date:** YYYY-MM-DD **Status:** Accepted | Proposed | Deprecated | Superseded **Context:** One-line summary of when/why this decision was needed --- ## Problem [Detailed problem description with 3-5 paragraphs] ### Option 1: [First Alternative] [Code example or description] **Problems:** - List specific issues - Technical limitations - Business concerns ### Option 2: [Second Alternative] [Code example or description] **Problems:** - List specific issues ### Option 3: [Chosen Approach] [Code example or description] **Benefits:** - Why this is better - Advantages over alternatives --- ## Decision **Use [Chosen Approach] for [Purpose].** [1-2 paragraph explanation of the decision] **Why [Chosen Approach]:** 1. Reason 1 2. Reason 2 3. Reason 3 --- ## Implementation [Detailed implementation with code examples] ### 1. [First Implementation Aspect] [Code examples with comments] ### 2. [Second Implementation Aspect] [Code examples with comments] [Continue for all major implementation aspects] --- ## Benefits ### 1. **[Benefit Name]** [Explanation with code/metrics examples] ### 2. **[Benefit Name]** [Explanation with code/metrics examples] [Continue for all major benefits] --- ## Trade-offs ### What We Gained - ✅ Benefit 1 - ✅ Benefit 2 - ✅ Benefit 3 ### What We Lost - ❌ Limitation 1 - ❌ Limitation 2 - ❌ Limitation 3 ### Why Trade-offs Are Acceptable 1. **Limitation 1**: Explanation of why it's acceptable 2. **Limitation 2**: Explanation 3. **Limitation 3**: Explanation --- ## Comparison | Feature | Alternative 1 | Alternative 2 | Chosen Approach | |---------|--------------|---------------|-----------------| | **Metric 1** | Value | Value | Value | | **Metric 2** | Value | Value | Value | --- ## Real-World Example ### Scenario: [Concrete Use Case] **With [Alternative Approach]:**
[Step-by-step flow showing problems]
**With [Chosen Approach]:**
[Step-by-step flow showing benefits]
**Winner:** [Chosen Approach] ([quantified benefit]) --- ## Future Considerations ### [Future Topic 1] [Discussion of how this decision might evolve] ### [Future Topic 2] [Potential extensions or modifications] **Current Status:** [Current state and future plans] --- ## Related Files ### [Category 1] - `path/to/file1.ts` - Description - `path/to/file2.ts` - Description ### [Category 2] - `path/to/file3.ts` - Description --- ## References - **[Resource Name]**: [URL] - **[Documentation]**: [URL] - Related: [Cross-reference to other ADRs]
Writing Guidelines
1. Title and Header
Title Format:
# ADR-NNN: Clear, Concise Description
Examples:
- •✅
ADR-001: Load Fresh User Data from Database (Not Stale JWT) - •✅
ADR-005: tRPC Over REST API - •✅
ADR-015: Email Blacklist and Bounce Management - •❌
ADR-001: User Data(too vague) - •❌
ADR-005: API Decision(not descriptive)
Header Fields:
**Date:** 2025-12-17 (ISO format) **Status:** Accepted (current state) **Context:** Brief one-liner about when/why this decision was needed
Status Values:
- •
Proposed- Under consideration - •
Accepted- Active and implemented - •
Deprecated- No longer recommended - •
Superseded- Replaced by another ADR (reference ADR-XXX)
2. Problem Section
The Problem section should:
- •Explain the context (3-5 paragraphs minimum)
- •Show code examples of the problem or alternatives
- •List 2-4 alternatives considered
- •Explain trade-offs for each alternative
Good Problem Description Example:
## Problem
VedaGhosham sends authentication OTP emails via AWS SES. Without proper bounce
and complaint handling, we risk:
1. **Sender Reputation Damage**: Repeated bounces to invalid emails harm sender reputation
2. **Service Suspension**: AWS SES may suspend service if bounce rate exceeds 5%
3. **Wasted Resources**: Sending emails to blacklisted addresses wastes API calls and costs
4. **Poor User Experience**: Users with invalid emails get stuck in auth flow
5. **No Visibility**: No tracking of email delivery issues or spam complaints
### Without Blacklist Management
```typescript
// ❌ Sends to all emails without checking
export async function sendOTPEmail(email: string, code: string) {
// Always sends, even if email previously bounced
await sesClient.send(new SendEmailCommand({ ... }));
}
Problems:
- •Hard bounces (permanent failures) get retried every login
- •Spam complaints not tracked (user marked email as spam)
- •No way to prevent sending to known-bad addresses
- •AWS SES reputation degrades over time
**Key Points:** - Start with business impact (reputation, cost, UX) - Show concrete code examples - List specific, actionable problems ### 3. Decision Section **Format:** ```markdown ## Decision **Use [Technology/Pattern] for [Purpose].** [1-2 paragraph justification] **Why [Chosen Approach]:** 1. Reason 1 (with evidence) 2. Reason 2 (with evidence) 3. Reason 3 (with evidence)
Example:
## Decision **Use tRPC (TypeScript Remote Procedure Call) instead of REST or GraphQL.** tRPC provides end-to-end type safety without codegen, perfect for our TypeScript monorepo. **Why tRPC:** 1. **Full type safety with zero codegen**: Types flow automatically through AppRouter export 2. **Automatic validation**: Zod schemas validate input before handler runs 3. **Middleware composition**: Reusable auth/permission middleware 4. **Request batching**: Reduces Lambda invocations and latency
4. Implementation Section
The Implementation section should:
- •Show working code examples (not pseudocode)
- •Include file paths in code comments
- •Explain key patterns with inline comments
- •Cover all major aspects of implementation
Structure:
## Implementation ### 1. [Infrastructure/Configuration] [SST/config code with explanations] ### 2. [Core Business Logic] [Entity/service code] ### 3. [API Layer] [tRPC router/endpoint code] ### 4. [Frontend Integration] [Remix route/component code] ### 5. [Testing/Monitoring] [Test examples, CloudWatch alarms]
Code Example Standards:
// ✅ Good: Working code with comments
// packages/core/src/email/blacklist.ts
export async function checkEmailBlacklist(
email: string,
): Promise<BlacklistCheckResult> {
const normalizedEmail = normalizeEmail(email);
try {
const result = await User.query.byEmail({ email: normalizedEmail }).go();
if (result.data.length > 0) {
const user = result.data[0];
if (user.emailBlacklisted) {
return {
isBlacklisted: true,
reason: user.emailBlacklistReason || 'UNKNOWN',
userId: user.userId,
blacklistedAt: user.emailBlacklistedAt,
};
}
}
return { isBlacklisted: false };
} catch (error) {
// Database error - fail open to prevent auth system breakage
console.error('Failed to check email blacklist, allowing send:', error);
return { isBlacklisted: false };
}
}
// ❌ Bad: Pseudocode without context
function checkBlacklist(email) {
// Check if email is blacklisted
return query(email);
}
5. Benefits Section
Format:
## Benefits ### 1. **[Concrete Benefit]** [Explanation with evidence] **Example/Metric:** [Code example or quantified metric] ### 2. **[Concrete Benefit]** [Explanation with evidence]
Good Benefits Example:
## Benefits ### 1. **Cost Efficiency** Single table = one set of provisioned capacity. No cross-table query overhead. **Example:** - Multi-table: 7 tables × $5/month = **$35/month** (minimum) - Single-table: 1 table × $5/month = **$5/month** - **Savings: 85%** (at small scale) ### 2. **Consistent Performance** All queries within one table (no cross-table latency). GSIs provide O(1) lookups for all access patterns. Single-digit millisecond latency.
Key Points:
- •Use bold for benefit names
- •Provide concrete evidence (metrics, code examples)
- •Quantify when possible (percentages, time, cost)
6. Trade-offs Section
Format:
## Trade-offs ### What We Gained - ✅ Benefit 1 - ✅ Benefit 2 - ✅ Benefit 3 ### What We Lost - ❌ Limitation 1 - ❌ Limitation 2 - ❌ Limitation 3 ### Why Trade-offs Are Acceptable 1. **Limitation 1**: Detailed explanation of why this is acceptable 2. **Limitation 2**: Explanation with context 3. **Limitation 3**: Explanation
Good Trade-offs Example:
## Trade-offs ### What We Gained - ✅ Full type safety with zero codegen - ✅ Automatic validation (Zod schemas) - ✅ Simplified error handling (TRPCError) - ✅ Middleware composition for auth/permissions - ✅ Request batching (reduces Lambda invocations) - ✅ IntelliSense for all procedures ### What We Lost - ❌ No OpenAPI/Swagger documentation (tRPC-specific) - ❌ Smaller ecosystem than REST (fewer tools/integrations) - ❌ TypeScript-only (can't call from Python, Go, etc.) - ❌ Less familiar to REST-only developers ### Why Trade-offs Are Acceptable 1. **No OpenAPI**: Internal API only (not public-facing); tRPC docs are code itself 2. **Smaller Ecosystem**: TypeScript ecosystem is sufficient for our needs 3. **TypeScript-Only**: Entire stack is TypeScript (no other languages planned) 4. **Learning Curve**: One-time cost; tRPC is simpler than REST + OpenAPI codegen
7. Comparison Section
Use tables to compare alternatives across multiple dimensions:
## Comparison | Feature | Alternative 1 | Alternative 2 | Chosen Approach | |---------|--------------|---------------|-----------------| | **Feature 1** | ❌ No | ⚠️ Partial | ✅ Yes | | **Feature 2** | Value | Value | Value | | **Cost** | $X/month | $Y/month | $Z/month | | **Performance** | Metric | Metric | Metric |
Symbols to Use:
- •✅ = Advantage
- •❌ = Disadvantage
- •⚠️ = Partial/Conditional
- •Use actual values when possible (numbers, percentages)
8. Real-World Example
Provide a concrete scenario showing the decision in action:
## Real-World Example ### Scenario: Add "dateOfBirth" field to User **With Polyrepo (Multi-Repository):** ```bash # 1. Update core cd vedaghosham-core # Edit User entity git commit -m "Add dateOfBirth" npm version patch # 1.2.0 → 1.2.1 npm publish # 2. Update API cd ../vedaghosham-api npm install @vedaghosham/core@1.2.1 # Wait for npm registry propagation # Update API code git commit -m "Support dateOfBirth" # 3. Update Web cd ../vedaghosham-web npm install @vedaghosham/core@1.2.1 # Update web code git commit -m "Show dateOfBirth in profile" # Total time: 30-60 minutes
With Monorepo (Chosen Approach):
# 1. Update core cd packages/core/src/user # Edit entity.ts and types.ts # 2. Update functions (TypeScript error guides you!) cd packages/functions/src/user # Update router.ts # 3. Update web (TypeScript error guides you!) cd packages/web/app/routes # Update profile route # 4. Commit everything git commit -m "Add dateOfBirth field" # Total time: 5-10 minutes ✅
Winner: Monorepo (6× faster, no versioning hassle)
### 9. Future Considerations Discuss how the decision might evolve: ```markdown ## Future Considerations ### Adding Public API If we need a public API (for mobile apps, third-party integrations): - **Option A**: Keep tRPC for internal use; add separate REST API for public - **Option B**: Use tRPC + tRPC-OpenAPI adapter (generates OpenAPI from tRPC) **Current Status:** Internal API only; no public API needed yet. ### Non-TypeScript Clients If we need Python/Go clients: - **Option A**: Create thin REST wrapper around tRPC backend - **Option B**: Use HTTP directly (tRPC is just JSON-RPC over HTTP) **Current Status:** Full TypeScript stack; no other languages planned.
10. Related Files
List all files implementing the decision:
## Related Files
### Backend
- `packages/functions/src/shared/trpc.ts` - tRPC initialization
- `packages/functions/src/shared/middleware.ts` - Auth middleware
- `packages/functions/src/{entity}/router.ts` - Entity routers
### Frontend
- `packages/web/app/lib/trpc.ts` - tRPC client factory
- `packages/web/app/routes/_app.*.tsx` - Usage in Remix routes
### Schemas
- `packages/core/src/{entity}/schema.ts` - Zod schemas used by tRPC
11. References
Include all relevant resources:
## References - **tRPC Documentation**: https://trpc.io/ - **tRPC vs REST**: https://trpc.io/docs/concepts - **ElectricSQL tRPC Talk**: https://www.youtube.com/watch?v=2LYM8gf184U - **Zod Validation**: https://zod.dev/ - Related: ADR-004 (Single-Table DynamoDB) - Related: ADR-010 (Zod Shared Validation)
Code Example Standards
1. Use Real, Working Code
// ✅ Good: Actual code from codebase
export async function loader({ request }: LoaderFunctionArgs) {
await requireAuth(request);
const trpc = await createServerClient(request);
const user = await trpc.user.me.query();
return json({ user });
}
// ❌ Bad: Pseudocode
export function loader() {
// Get user
return user;
}
2. Include File Paths
// ✅ Good: Shows where code lives
// packages/web/app/routes/_app.tsx
export async function loader({ request }: LoaderFunctionArgs) {
// ...
}
// ❌ Bad: No context
export async function loader() {
// ...
}
3. Add Explanatory Comments
// ✅ Good: Explains why
try {
const result = await User.query.byEmail({ email: normalizedEmail }).go();
return { isBlacklisted: false };
} catch (error) {
// Database error - fail open to prevent auth system breakage
console.error('Failed to check email blacklist, allowing send:', error);
return { isBlacklisted: false };
}
// ❌ Bad: No explanation for non-obvious behavior
} catch (error) {
return { isBlacklisted: false };
}
4. Show Before/After
// ❌ WRONG: Checks stale JWT token
export async function loader({ request }: LoaderFunctionArgs) {
const userData = await requireCompleteProfile(request);
// userData.profileCompleted is STALE!
}
// ✅ CORRECT: Loads fresh data from database
export async function loader({ request }: LoaderFunctionArgs) {
await requireAuth(request);
const trpc = await createServerClient(request);
const user = await trpc.user.me.query();
// user.profileCompleted is always fresh! ✅
}
Writing Style Guidelines
1. Be Concrete, Not Abstract
- •✅ "Single table costs $5/month vs multi-table $35/month (85% savings)"
- •❌ "Single table is cheaper"
2. Use Numbers and Metrics
- •✅ "3-6× faster initial load (300-500ms vs 2-3 seconds)"
- •❌ "Much faster initial load"
3. Show, Don't Just Tell
- •✅ Include code examples showing the problem and solution
- •❌ "The old approach had issues"
4. Explain Trade-offs Honestly
- •✅ "What we lost: No OpenAPI docs. Why acceptable: Internal API only"
- •❌ "Perfect solution with no downsides"
5. Future-Proof with Considerations
- •✅ "If we need public API: Option A vs Option B. Current status: Not needed"
- •❌ "We'll never need a public API"
Common Pitfalls to Avoid
❌ Don't Write Vague ADRs
## Decision We decided to use a database.
❌ Don't Skip Alternatives
## Problem We need to store data. ## Decision Use DynamoDB.
(Missing: Why not PostgreSQL? MongoDB? What were the trade-offs?)
❌ Don't Use Pseudocode
function doSomething() {
// Do the thing
}
❌ Don't Ignore Trade-offs
## Benefits - Fast - Cheap - Easy (Missing: What did we lose? Every decision has trade-offs)
❌ Don't Skip Real Examples
## Implementation We implemented the feature. (Missing: How? Show code!)
Checklist for Reviewing ADRs
Before finalizing an ADR, ensure:
- • Title is clear and descriptive (ADR-NNN format)
- • Date is in YYYY-MM-DD format
- • Status is set (Proposed/Accepted/Deprecated/Superseded)
- • Problem section explains context with 3+ paragraphs
- • Alternatives section lists 2-4 options considered
- • Decision section has clear justification (3+ reasons)
- • Implementation has real code examples with file paths
- • Benefits section quantifies advantages (metrics/percentages)
- • Trade-offs section honestly discusses limitations
- • Comparison table compares alternatives across multiple dimensions
- • Real-World Example shows concrete scenario
- • Future Considerations discusses evolution/extensions
- • Related Files lists all implementation files
- • References includes external docs and cross-references
- • Code examples use ✅/❌ to show right/wrong patterns
- • Numbers and metrics used throughout (not just "better/faster")
- • All code examples include comments explaining why
ADR Template
Use this template when creating a new ADR:
# ADR-NNN: [Title] **Date:** YYYY-MM-DD **Status:** Proposed | Accepted | Deprecated | Superseded **Context:** [One-line summary] --- ## Problem [3-5 paragraph problem description] ### Option 1: [First Alternative] [Code/description] **Problems:** - Issue 1 - Issue 2 ### Option 2: [Second Alternative] [Code/description] **Problems:** - Issue 1 - Issue 2 --- ## Decision **Use [Chosen Approach] for [Purpose].** [1-2 paragraph justification] **Why [Chosen Approach]:** 1. Reason 1 2. Reason 2 3. Reason 3 --- ## Implementation ### 1. [Implementation Aspect 1] [Code examples] ### 2. [Implementation Aspect 2] [Code examples] --- ## Benefits ### 1. **[Benefit Name]** [Explanation with evidence] ### 2. **[Benefit Name]** [Explanation with evidence] --- ## Trade-offs ### What We Gained - ✅ Benefit 1 - ✅ Benefit 2 ### What We Lost - ❌ Limitation 1 - ❌ Limitation 2 ### Why Trade-offs Are Acceptable 1. **Limitation 1**: Explanation 2. **Limitation 2**: Explanation --- ## Comparison | Feature | Alternative 1 | Alternative 2 | Chosen | |---------|--------------|---------------|--------| | **Feature 1** | Value | Value | Value | --- ## Real-World Example ### Scenario: [Use Case] **With [Alternative]:** [Step-by-step flow] **With [Chosen Approach]:** [Step-by-step flow] **Winner:** [Chosen] ([quantified benefit]) --- ## Future Considerations ### [Topic 1] [Discussion] **Current Status:** [State] --- ## Related Files ### [Category 1] - `path/to/file.ts` - Description --- ## References - **[Resource]**: [URL] - Related: ADR-XXX
Example: Simplified ADR
Here's a simplified example showing the key elements:
# ADR-016: Rate Limiting for API Endpoints
**Date:** 2025-12-20
**Status:** Proposed
**Context:** Protecting API from abuse and ensuring fair usage
---
## Problem
VedaGhosham's tRPC API is publicly accessible (after authentication). Without
rate limiting, we risk:
1. **DoS Attacks**: Malicious users could overwhelm Lambda functions
2. **Cost Spikes**: Unlimited requests = unlimited AWS costs
3. **Poor UX**: One user's heavy usage slows down others
4. **No Abuse Protection**: No mechanism to block bad actors
### Option 1: No Rate Limiting
```typescript
// No protection - anyone can spam requests
export const userRouter = router({
list: protectedProcedure.query(async () => {
return await User.scan.go(); // Expensive query, no limits
}),
});
Problems:
- •No protection against abuse
- •AWS costs unbounded
- •Poor experience for legitimate users
Option 2: Lambda Concurrency Limits
Set reserved concurrency on Lambda functions.
Problems:
- •All-or-nothing (affects all users equally)
- •No per-user fairness
- •Hard to configure correctly
Option 3: DynamoDB-Based Rate Limiting
Track request counts per user in DynamoDB with TTL.
Benefits:
- •Per-user fairness
- •Serverless (no Redis needed)
- •Fine-grained control
Decision
Use DynamoDB-based rate limiting with per-user counters and TTL.
We'll create a RateLimit entity tracking requests per user per time window.
Why DynamoDB Rate Limiting:
- •Serverless: No Redis/Memcached infrastructure needed
- •Per-User Fairness: Each user has independent limit
- •Automatic Cleanup: DynamoDB TTL removes old records
- •Fine-Grained: Can set limits per endpoint or globally
Implementation
1. RateLimit Entity
// packages/core/src/rate-limit/entity.ts
export const RateLimit = new Entity({
model: {
entity: 'RateLimit',
version: '1',
service: 'vedaghosham',
},
attributes: {
userId: { type: 'string', required: true },
endpoint: { type: 'string', required: true },
windowStart: { type: 'number', required: true }, // Unix timestamp
requestCount: { type: 'number', required: true, default: 0 },
ttl: { type: 'number' }, // DynamoDB TTL (auto-delete after 1 hour)
},
indexes: {
primary: {
pk: { field: 'pk', composite: ['userId', 'endpoint'] },
sk: { field: 'sk', composite: ['windowStart'] },
},
},
}, configuration);
2. Rate Limiting Middleware
// packages/functions/src/shared/rate-limit.ts
const RATE_LIMITS = {
'user.list': { requests: 100, windowMs: 60000 }, // 100 req/min
'course.create': { requests: 10, windowMs: 60000 }, // 10 req/min
default: { requests: 1000, windowMs: 60000 }, // 1000 req/min
};
export const rateLimitMiddleware = t.middleware(async ({ ctx, path, next }) => {
const userId = ctx.user?.userId;
if (!userId) return next(); // Skip for unauthenticated
const limit = RATE_LIMITS[path] || RATE_LIMITS.default;
const windowStart = Math.floor(Date.now() / limit.windowMs) * limit.windowMs;
// Check current count
const result = await RateLimit.get({
userId,
endpoint: path,
windowStart,
}).go();
if (result.data && result.data.requestCount >= limit.requests) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: `Rate limit exceeded. Try again in ${limit.windowMs / 1000}s`,
});
}
// Increment counter
await RateLimit.upsert({
userId,
endpoint: path,
windowStart,
requestCount: (result.data?.requestCount || 0) + 1,
ttl: Math.floor(Date.now() / 1000) + 3600, // Expire after 1 hour
}).go();
return next();
});
3. Apply to Procedures
// packages/functions/src/shared/procedures.ts export const protectedProcedure = t.procedure .use(isAuthenticated) .use(rateLimitMiddleware);
Benefits
1. Cost Protection
Rate limiting prevents runaway costs from abuse.
Example:
- •Without limit: Malicious user makes 1M requests = $200+ Lambda cost
- •With limit: Blocked at 1,000 requests = $0.20 maximum cost per user
- •Savings: 99.9% in abuse scenario
2. Fair Resource Allocation
Each user has independent quota (one user can't starve others).
3. No Additional Infrastructure
Uses existing DynamoDB table (no Redis/Memcached needed).
Trade-offs
What We Gained
- •✅ Protection from abuse and DoS
- •✅ Cost predictability
- •✅ Per-user fairness
- •✅ Serverless (no Redis)
What We Lost
- •❌ DynamoDB write costs (1 write per request)
- •❌ Slight latency increase (~10ms per request)
- •❌ Complexity (middleware + entity)
Why Trade-offs Are Acceptable
- •DynamoDB Writes: ~$0.001 per 1,000 requests (negligible vs abuse cost)
- •Latency: 10ms overhead acceptable for cost/abuse protection
- •Complexity: Essential for production API
Comparison
| Feature | No Limit | Lambda Limits | DynamoDB Limit |
|---|---|---|---|
| Cost Protection | ❌ No | ⚠️ Global | ✅ Per-user |
| Fairness | ❌ No | ❌ No | ✅ Yes |
| Infrastructure | ✅ None | ✅ None | ✅ Existing DB |
| Granularity | N/A | ❌ Coarse | ✅ Fine-grained |
Real-World Example
Scenario: User spams "list all courses" endpoint
Without Rate Limiting:
User makes 10,000 requests in 1 minute → 10,000 Lambda invocations × $0.0000002 = $2.00 → 10,000 DynamoDB scans × $0.00025 = $2.50 → Total cost: $4.50 per abuse incident → API slow for all users
With Rate Limiting (100 req/min):
User makes 10,000 requests in 1 minute → First 100 succeed → Next 9,900 blocked with 429 error → Cost: 100 requests = $0.045 → Other users unaffected
Winner: Rate limiting (99% cost reduction, better UX)
Future Considerations
Distributed Rate Limiting
If we need multi-region:
- •Use DynamoDB Global Tables for consistent limits across regions
Current Status: Single region sufficient.
Redis Alternative
If DynamoDB becomes too expensive:
- •Switch to ElastiCache Redis (faster, lower per-request cost)
Current Status: DynamoDB cost acceptable (<$1/month).
Related Files
Core
- •
packages/core/src/rate-limit/entity.ts- RateLimit entity
Functions
- •
packages/functions/src/shared/rate-limit.ts- Middleware - •
packages/functions/src/shared/procedures.ts- Applied to procedures
Infrastructure
- •
infra/database.ts- DynamoDB table (already has TTL enabled)
References
- •DynamoDB TTL: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/TTL.html
- •Rate Limiting Patterns: https://aws.amazon.com/blogs/architecture/rate-limiting-strategies-for-serverless-applications/
- •tRPC Middleware: https://trpc.io/docs/server/middlewares
- •Related: ADR-004 (Single-Table DynamoDB)
- •Related: ADR-005 (tRPC Over REST)
--- ## Summary High-quality ADRs in VedaGhosham: 1. **Document significant decisions** (not routine changes) 2. **Show alternatives considered** (2-4 options with trade-offs) 3. **Provide working code examples** (real code from codebase) 4. **Quantify benefits** (metrics, percentages, cost savings) 5. **Explain trade-offs honestly** (what we lost and why it's acceptable) 6. **Include comparison tables** (features across alternatives) 7. **Show real-world scenarios** (concrete before/after examples) 8. **Discuss future evolution** (how decision might change) 9. **List all related files** (where implementation lives) 10. **Reference external docs** (AWS docs, library docs, related ADRs) Use the ADR template and checklist to ensure comprehensive, maintainable architecture documentation that will serve the project for years to come.