Design Document: Budget Application
Overview
This document outlines the design for a serverless budget application that implements event sourcing for transaction management. The system uses AWS Lambda, DynamoDB, and TypeScript to provide a scalable, maintainable financial tracking platform.
The architecture follows a library-based approach where core business logic is implemented as a reusable TypeScript library, consumed directly by HTTP handlers to avoid network overhead. The system supports multiple users with multiple budgets, using event sourcing to maintain transaction history and enable state reconstruction.
Architecture
High-Level Architecture
graph TB
subgraph "AWS Infrastructure"
CF[CloudFront] --> APIGW[API Gateway]
APIGW --> AUTH[Lambda Authorizer]
APIGW --> HTTP[HTTP Handler Lambda]
HTTP --> CORE[Core Business Library]
CORE --> DDB[DynamoDB]
S3[S3 Static Assets] --> CF
COG[Cognito User Pool] --> AUTH
end
subgraph "Core Library Components"
CORE --> ENTITIES[Entity Management]
CORE --> EVENTS[Event Sourcing]
CORE --> STATE[State Calculation]
CORE --> PLANS[Plan Execution]
end
Component Responsibilities
CloudFront + S3: Serves static assets (CSS, JavaScript, favicon) with caching and TLS termination.
API Gateway: Routes HTTP requests and handles CORS. Uses custom Lambda authorizer for authentication.
Lambda Authorizer: Validates JWT tokens from HttpOnly cookies, extracts user ID from Cognito subject claim.
HTTP Handler Lambda: Processes web requests, uses core library for business logic, renders HTML responses using templates.
Core Business Library: Implements all business logic including entity management, event sourcing, state calculation, and plan execution.
DynamoDB: Single table design storing all entities, events, and metadata with appropriate access patterns.
Cognito: Manages user authentication with JWT tokens stored as secure cookies.
Components and Interfaces
Core Library Structure
export interface BudgetCore {
// Entity operations
accounts: AccountService;
pots: PotService;
categories: CategoryService;
goals: GoalService;
plans: PlanService;
// Transaction operations
transactions: TransactionService;
events: EventService;
// State management
state: StateService;
snapshots: SnapshotService;
}
// Service interfaces - all methods require userId and budgetId for authorization
export interface AccountService {
create(userId: string, budgetId: string, metadata: AccountMetadata): Promise<string>;
update(userId: string, budgetId: string, accountId: string, metadata: Partial<AccountMetadata>): Promise<void>;
get(userId: string, budgetId: string, accountId: string): Promise<Account>;
list(userId: string, budgetId: string): Promise<Account[]>;
listActive(userId: string, budgetId: string): Promise<Account[]>;
setActive(userId: string, budgetId: string, accountId: string): Promise<void>;
setInactive(userId: string, budgetId: string, accountId: string): Promise<void>;
}
export interface PotService {
create(userId: string, budgetId: string, accountId: string, metadata: PotMetadata): Promise<string>;
update(userId: string, budgetId: string, potId: string, metadata: Partial<PotMetadata>): Promise<void>;
get(userId: string, budgetId: string, potId: string): Promise<Pot>;
list(userId: string, budgetId: string): Promise<Pot[]>;
listActive(userId: string, budgetId: string): Promise<Pot[]>;
setActive(userId: string, budgetId: string, potId: string): Promise<void>;
setInactive(userId: string, budgetId: string, potId: string): Promise<void>;
}
export interface CategoryService {
create(userId: string, budgetId: string, metadata: CategoryMetadata): Promise<string>;
update(userId: string, budgetId: string, categoryId: string, metadata: Partial<CategoryMetadata>): Promise<void>;
get(userId: string, budgetId: string, categoryId: string): Promise<Category>;
list(userId: string, budgetId: string): Promise<Category[]>;
listActive(userId: string, budgetId: string): Promise<Category[]>;
setActive(userId: string, budgetId: string, categoryId: string): Promise<void>;
setInactive(userId: string, budgetId: string, categoryId: string): Promise<void>;
}
export interface GoalService {
create(userId: string, budgetId: string, potId: string, metadata: GoalMetadata): Promise<string>;
update(userId: string, budgetId: string, goalId: string, metadata: Partial<GoalMetadata>): Promise<void>;
get(userId: string, budgetId: string, goalId: string): Promise<Goal>;
list(userId: string, budgetId: string): Promise<Goal[]>;
markAchieved(userId: string, budgetId: string, goalId: string): Promise<void>;
unmarkAchieved(userId: string, budgetId: string, goalId: string): Promise<void>;
}
export interface PlanService {
create(userId: string, budgetId: string, metadata: PlanMetadata, schedule: PlanSchedule): Promise<string>;
update(userId: string, budgetId: string, planId: string, metadata: Partial<PlanMetadata>): Promise<void>;
get(userId: string, budgetId: string, planId: string): Promise<Plan>;
list(userId: string, budgetId: string): Promise<Plan[]>;
setInactive(userId: string, budgetId: string, planId: string): Promise<void>;
realize(userId: string, budgetId: string, planId: string, upToDate: Date): Promise<string[]>;
predict(userId: string, budgetId: string, planId: string, fromDate: Date, toDate: Date): Promise<Transaction[]>;
}
export interface TransactionService {
create(userId: string, budgetId: string, transaction: TransactionData): Promise<string>;
update(userId: string, budgetId: string, transactionId: string, updates: Partial<TransactionData>): Promise<void>;
delete(userId: string, budgetId: string, transactionId: string): Promise<void>;
get(userId: string, budgetId: string, transactionId: string): Promise<Transaction>;
list(userId: string, budgetId: string, filters: TransactionFilters): Promise<Transaction[]>;
}
export interface EventService {
list(userId: string, budgetId: string, fromEventId?: string): Promise<Event[]>;
getLastEventId(userId: string, budgetId: string): Promise<string | null>;
}
export interface StateService {
calculateBalances(userId: string, budgetId: string, asOfDate?: Date): Promise<BalanceState>;
getAccountBalance(userId: string, budgetId: string, accountId: string, asOfDate?: Date): Promise<number>;
getPotBalance(userId: string, budgetId: string, potId: string, asOfDate?: Date): Promise<number>;
}
export interface SnapshotService {
create(userId: string, budgetId: string, date: Date, state: BalanceState): Promise<void>;
get(userId: string, budgetId: string, date: Date): Promise<BalanceState | null>;
invalidate(userId: string, budgetId: string, fromDate: Date): Promise<void>;
}
HTTP Handler Interface
// HTTP handlers use core library directly
export class BudgetHttpHandler {
constructor(private core: BudgetCore) {}
async handleAccountsPage(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult>;
async handleTransactionsPage(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult>;
async handleCreateTransaction(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult>;
// ... other handlers
}
Data Models
DynamoDB Single Table Design
The system uses a single DynamoDB table with the following key structure:
interface DynamoDBRecord {
PK: string; // Partition Key
SK: string; // Sort Key
GSI1PK?: string; // Global Secondary Index 1 Partition Key
GSI1SK?: string; // Global Secondary Index 1 Sort Key
EntityType: string;
// ... entity-specific attributes
}
Security-First Key Design
All entity keys include the userId to ensure authorization is enforced at the database level:
Security Benefits:
- Impossible to bypass authorization - wrong userId returns zero results
- No additional lookups required - single query validates ownership
- Fail-safe by design - developers cannot accidentally forget authorization checks
- Performance - no extra round trips for ownership validation
- Clear audit trail - all database operations are scoped to specific users
Key Structure Pattern:
- All entity keys follow the pattern:
USER#${userId}#BUDGET#${budgetId}orUSER#${userId}#META#${entityId} - This ensures that providing an incorrect userId (even if valid for another user) returns no results
- The database layer enforces the security model without requiring application-level checks
Key Patterns
Users and Budgets:
- User:
PK: USER#${userId},SK: PROFILE - Budget:
PK: USER#${userId},SK: BUDGET#${budgetId}
Timeless Entities (userId included for security):
- Account:
PK: USER#${userId}#BUDGET#${budgetId},SK: ACCOUNT#${accountId} - Pot:
PK: USER#${userId}#BUDGET#${budgetId},SK: POT#${potId} - Category:
PK: USER#${userId}#BUDGET#${budgetId},SK: CATEGORY#${categoryId} - Goal:
PK: USER#${userId}#BUDGET#${budgetId},SK: GOAL#${goalId} - Plan:
PK: USER#${userId}#BUDGET#${budgetId},SK: PLAN#${planId}
Metadata (Versioned, userId included for security):
- Current:
PK: USER#${userId}#META#${entityId},SK: v0 - Historical:
PK: USER#${userId}#META#${entityId},SK: v${timestamp}
Events (userId included for security):
- Event:
PK: USER#${userId}#BUDGET#${budgetId},SK: EVENT#${ulid} - GSI1:
PK: USER#${userId}#EVENT#${budgetId},SK: ${timestamp}#${ulid}(for chronological ordering)
Transactions (Reconstructible, userId included for security):
- Transaction:
PK: USER#${userId}#BUDGET#${budgetId},SK: TXN#${transactionId} - GSI1:
PK: USER#${userId}#TXN#${budgetId}#${accountOrPotId},SK: ${date}#${creationTimestamp}(for account/pot filtering)
Snapshots (userId included for security):
- Snapshot:
PK: USER#${userId}#BUDGET#${budgetId},SK: SNAP#${date}
Entity Definitions
interface Account {
id: string;
budgetId: string;
metadata: AccountMetadata;
balance?: number; // Calculated, not stored
isActive: boolean; // Current status only - does not affect historical processing
}
interface AccountMetadata {
name: string;
description?: string;
notes?: string;
version: number;
timestamp: Date;
}
interface Pot {
id: string;
accountId: string;
budgetId: string;
metadata: PotMetadata;
balance?: number; // Calculated, not stored
isActive: boolean; // Current status only - does not affect historical processing
}
interface Category {
id: string;
budgetId: string;
metadata: CategoryMetadata;
isActive: boolean; // Current status only - does not affect historical processing
}
interface Transaction {
id: string;
budgetId: string;
amount: number; // Always positive
date: Date;
changeType: 'income' | 'expense' | 'transfer';
source?: string; // Account or Pot ID
destination?: string; // Account or Pot ID
categoryId?: string;
metadata: TransactionMetadata;
planId?: string;
planVersion?: number;
}
interface Event {
id: string; // ULID
budgetId: string;
timestamp: Date;
eventType: 'create' | 'update' | 'delete';
transactionId: string;
transactionData?: TransactionData; // For create/update events
}
interface Plan {
id: string;
budgetId: string;
metadata: PlanMetadata;
schedule: PlanSchedule;
lastRealizedDate?: Date;
isActive: boolean; // Current status only - does not affect historical processing
}
interface PlanSchedule {
type: 'one-time' | 'monthly' | 'weekly';
startDate: Date;
endDate?: Date;
dayOfMonth?: number; // For monthly
dayOfWeek?: number; // For weekly (0 = Sunday)
weekendAdjustment?: 'friday-before' | 'monday-after' | 'none';
}
interface Goal {
id: string;
potId: string;
budgetId: string;
metadata: GoalMetadata;
isAchieved: boolean;
achievedDate?: Date;
}
Active Status Temporal Behavior
The isActive property on entities has specific temporal semantics that are important for system correctness:
Current State Only: The isActive property represents the current status of an entity and is not versioned or tied to specific dates.
New Operations: Active status affects:
- New transaction creation (inactive entities excluded from dropdowns)
- UI display (inactive entities shown with different styling)
- Form validation (inactive entities cannot be selected for new transactions)
Historical Processing: Active status does NOT affect:
- Event sourcing and transaction reconstruction
- Balance calculations from historical data
- Snapshot creation and invalidation
- Processing of existing transactions and events
Editing Existing Transactions: When editing transactions that reference inactive entities:
- The inactive entity remains selectable (to preserve data integrity)
- The UI displays inactive entities with distinctive styling
- Users can change to active entities or keep the inactive reference
Reversibility: Entities can be reactivated at any time, which immediately:
- Makes them available for new transaction creation
- Changes their display styling to active
- Does not affect any historical data or calculations
## Correctness Properties
*A property is a characteristic or behavior that should hold true across all valid executions of a system—essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
Based on the requirements analysis, the following properties must hold for the system to be considered correct:
### Property 1: Entity Creation Consistency
*For any* entity creation request (account, pot, plan, category, goal), the system should generate a unique UUID, store the entity with proper metadata structure, and return the generated UUID to the caller.
**Validates: Requirements 4.1, 4.2, 5.1, 5.2, 6.1, 6.2, 7.1, 7.2, 8.1, 8.2**
### Property 2: User Data Isolation
*For any* user and any budget operation, the system should only allow access to data owned by that user and prevent access to other users' data.
**Validates: Requirements 2.1, 2.3, 2.5, 2.6**
### Property 3: Referential Integrity
*For any* entity creation or reference operation, the system should verify that referenced entities exist and prevent deletion of entities that are referenced by others.
**Validates: Requirements 3.2, 3.9, 3.10, 12.5**
### Property 4: Event Sourcing Consistency
*For any* event stream, processing the events in order should produce identical transaction states, and events should maintain strict chronological ordering with unique ULID identifiers.
**Validates: Requirements 3.3, 3.4, 9.1, 9.6, 10.8, 11.4**
### Property 5: Balance Calculation Accuracy
*For any* account or pot, the calculated balance should equal the sum of all transactions affecting that entity, applied in date order (with creation timestamp as tiebreaker for same dates).
**Validates: Requirements 4.4, 4.5, 5.4, 11.1, 11.2, 11.3**
### Property 6: Pot Balance Constraints
*For any* pot transaction that would result in negative balance, the pot balance should be set to zero and the remaining amount should be deducted from the parent account.
**Validates: Requirements 5.5, 5.6, 5.7**
### Property 7: Transaction Validation
*For any* transaction creation, the system should require positive amounts, valid dates, appropriate source/destination based on transaction type, and reject transactions where source equals destination.
**Validates: Requirements 10.2, 10.3, 10.4, 10.5, 10.6, 10.9, 10.10, 10.11**
### Property 8: Metadata Versioning
*For any* metadata update operation, the system should create a new version with timestamp while maintaining historical versions and using conditional updates based on current version.
**Validates: Requirements 3.8, 4.6, 7.5**
### Property 9: Plan Scheduling Accuracy
*For any* plan with scheduling rules, the system should calculate next execution dates correctly based on plan type (one-time, monthly, weekly) and apply weekend adjustments when specified.
**Validates: Requirements 6.4, 6.5, 6.6, 6.7, 6.8, 6.9, 6.10**
### Property 10: Plan Lifecycle Management
*For any* plan, the system should respect activation status, end dates, and last realized dates when determining whether to generate transactions.
**Validates: Requirements 6.12, 6.13, 6.14, 6.15, 6.16, 6.17**
### Property 11: Goal Achievement Logic
*For any* goal, the system should correctly determine achievement status based on pot balance vs target amount, handle manual achievement marking, and maintain achievement status regardless of subsequent balance changes when manually set.
**Validates: Requirements 8.8, 8.10, 8.11, 8.12**
### Property 12: Snapshot Invalidation
*For any* transaction change that affects historical data, the system should invalidate all snapshots from the affected date forward to ensure state consistency.
**Validates: Requirements 11.8, 11.9**
### Property 13: Input Validation Completeness
*For any* user input, the system should validate all required fields, data types, formats, and business rules on the backend regardless of frontend validation.
**Validates: Requirements 12.1, 12.3, 12.4, 12.6, 12.7**
### Property 14: Error Handling Consistency
*For any* validation failure or error condition, the system should return appropriate error responses with clear descriptions and preserve user input where possible for correction.
**Validates: Requirements 12.2, 12.8, 12.10**
## Error Handling
### Validation Strategy
The system implements comprehensive backend validation regardless of frontend checks. All user input is validated for:
- **Data Types**: Numeric amounts, valid dates, proper UUID formats
- **Business Rules**: Positive amounts, valid entity references, required fields
- **Security**: User authorization, data isolation, input sanitization
### Error Response Format
```typescript
interface ErrorResponse {
error: {
code: string;
message: string;
details?: Record<string, string[]>; // Field-specific errors
timestamp: Date;
requestId: string;
};
preservedInput?: Record<string, any>; // For form re-population
}
Error Categories
Validation Errors (400): Invalid input data, missing required fields, business rule violations Authentication Errors (401): Missing or invalid JWT tokens Authorization Errors (403): User attempting to access other users’ data Not Found Errors (404): Referenced entities don’t exist Conflict Errors (409): Conditional update failures, referential integrity violations Server Errors (500): Unexpected system failures, database errors
Error Recovery
- Form Preservation: Invalid form submissions preserve user input for correction
- Partial Success: Batch operations report which items succeeded/failed
- Retry Logic: Transient failures include retry guidance
- Logging: All errors logged with context for debugging
Testing Strategy
Dual Testing Approach
The system uses both unit tests and property-based tests to ensure comprehensive coverage:
Unit Tests: Verify specific examples, edge cases, and error conditions
- Test concrete scenarios with known inputs and expected outputs
- Validate error handling with specific invalid inputs
- Test integration points between components
- Focus on boundary conditions and edge cases
Property-Based Tests: Verify universal properties across all inputs
- Generate random valid inputs to test properties at scale
- Ensure properties hold across the entire input space
- Catch edge cases that might be missed in unit tests
- Validate system behavior under diverse conditions
Property-Based Testing Configuration
Framework: Use fast-check library for TypeScript property-based testing
Test Iterations: Minimum 100 iterations per property test
Test Tagging: Each property test references its design document property
Tag Format: // Feature: budget-application, Property {number}: {property_text}
Core Testing Areas
Entity Management:
- Unit tests for specific entity creation/update scenarios
- Property tests for entity creation consistency (Property 1)
- Property tests for referential integrity (Property 3)
Event Sourcing:
- Unit tests for specific event sequences
- Property tests for event sourcing consistency (Property 4)
- Property tests for snapshot invalidation (Property 12)
Balance Calculations:
- Unit tests for specific transaction scenarios
- Property tests for balance calculation accuracy (Property 5)
- Property tests for pot balance constraints (Property 6)
Plan Scheduling:
- Unit tests for specific date scenarios (month-end, weekends)
- Property tests for scheduling accuracy (Property 9)
- Property tests for lifecycle management (Property 10)
Security and Validation:
- Unit tests for specific attack scenarios
- Property tests for user data isolation (Property 2)
- Property tests for input validation (Property 13)
Test Data Generation
Smart Generators: Create realistic test data that respects business constraints
- Generate valid UUIDs, dates within reasonable ranges
- Create entity relationships that maintain referential integrity
- Generate transaction amounts and dates that create meaningful scenarios
Edge Case Coverage: Ensure generators include boundary conditions
- Zero balances, maximum amounts, edge dates
- Weekend dates for plan scheduling tests
- Month-end dates for monthly plan tests
Integration Testing
End-to-End Scenarios: Test complete user workflows
- Account creation → pot creation → transaction creation → balance verification
- Plan creation → plan realization → transaction generation
- Goal creation → progress tracking → achievement marking
Database Integration: Test with actual DynamoDB (local)
- Verify single-table design access patterns
- Test conditional updates and optimistic locking
- Validate GSI query performance
Continuous Testing
Automated Test Execution: All tests run on every code change Property Test Monitoring: Track property test failure rates and patterns Performance Testing: Monitor test execution time and resource usage Coverage Reporting: Ensure both unit and property tests contribute to coverage metrics