
How I Built a Production Wellness App Using AWS Blocks Architecture
A complete guide to building, deploying, and running a real-time production application using AWS Blocks from local development to live AWS services with zero code duplication.
One of the biggest challenges in cloud application development is bridging the gap between local development and production on AWS. Developers often end up maintaining different implementations, duplicating logic, and dealing with integration issues when moving from local environments to the cloud.
In this article, I’ll show how I built a production-ready Wellness App using the AWS Blocks Architecture a block-based abstraction approach that allows the same application logic to run locally and on AWS by switching the underlying adapters.
The diagram below gives you a quick view of how
Application Logic → Block Interfaces → Local and AWS implementations
work together, with local storage and AI on one side and AWS services such as DynamoDB, Cognito, and Amazon Bedrock with Nova on the other.
Let’s break down how I built it, deployed it, and designed it to keep development simple while remaining production-ready.

Table of Contents
- Problem Statement
- The Solution: AWS Blocks Architecture
- How I Built It
- Architecture Deep Dive
- Infrastructure Cost Breakdown
- Challenges & Lessons Learned
- Conclusion
- Resources
Live Demo: https://main.d3qq6gk7snyk6x.amplifyapp.com
Problem Statement
Building modern full-stack applications that run on AWS comes with several recurring pain points:
1. The Local-to-Cloud Gap
Developers build locally with SQLite, in-memory stores, or mock services. When it's time to deploy, they rewrite everything for DynamoDB, Cognito, and Bedrock. This leads to:
- Code duplication - business logic gets written twice
- Integration bugs - "works on my machine" but fails on AWS
- Slow iteration - can't test without deploying
2. Tight Coupling to AWS Services
Applications hardcode AWS SDK calls throughout the codebase. Changing from DynamoDB to PostgreSQL, or from Cognito to Auth0, requires touching dozens of files.
3. Infrastructure Complexity
Setting up Cognito + DynamoDB + Lambda + API Gateway + IAM with proper least-privilege permissions is a full day's work before writing any business logic.
4. AI Integration Without Guardrails
Many apps give AI models direct database access or let them execute arbitrary operations. This creates security, cost, and reliability risks in production.
What I Wanted
- One codebase that runs identically locally and on AWS
- Swap runtimes with a single environment variable
- Type-safe contracts between application logic and infrastructure
- AI that's authorized and grounded no direct database access
- Zero AWS credentials needed for local development
The Solution: AWS Blocks Architecture
I created a Block-based abstraction layer where each Block represents a capability (auth, data, AI, API) with:
- A stable interface - the contract that business logic programs against
- A local adapter - works offline, no cloud needed
- An AWS adapter - production implementation using real services
- A runtime switch - one env var selects which adapter runs

How I Built It
Step 1: Define Block Interfaces
Every Block has a TypeScript interface that both adapters must satisfy:
1
2
3
4
5
6
7
8
9
10
11
12
// IDataBlockAdapter - the contract
export interface IDataBlockAdapter {
create<T extends { id: string }>(tableName: string, record: T): Promise<T>;
getById<T extends { id: string }>(tableName: string, id: string): Promise<T | null>;
queryByUserId<T>(tableName: string, userId: string, options?: {
startDate?: string;
endDate?: string;
limit?: number;
}): Promise<T[]>;
update<T extends { id: string }>(tableName: string, id: string, updates: Partial<T>): Promise<T | null>;
delete(tableName: string, id: string): Promise<boolean>;
}Step 2: Local Implementation (Zero Setup)
The local adapter uses JSON files for persistence:
1
2
3
4
5
6
7
8
9
export class LocalDataAdapter implements IDataBlockAdapter {
async create<T extends { id: string }>(tableName: string, record: T): Promise<T> {
// Reads/writes JSON files in .data/ directory
const records = this.readTable<T>(tableName);
records.push(record);
this.writeTable(tableName, records);
return record;
}
}This means
npm run dev works immediately - no Docker, no cloud accounts, no setup.Step 3: AWS Implementation (Production)
The AWS adapter uses DynamoDB with proper access patterns:
1
2
3
4
5
6
7
8
9
10
11
12
13
export class DynamoDBDataAdapter implements IDataBlockAdapter {
async queryByUserId<T>(tableName: string, userId: string, options?: {...}): Promise<T[]> {
const command = new QueryCommand({
TableName: this.tableName,
IndexName: 'userId-date-index', // GSI designed for this access pattern
KeyConditionExpression: 'userId = :userId',
ExpressionAttributeValues: { ':userId': userId },
ScanIndexForward: false, // newest first
});
const response = await this.docClient.send(command);
return response.Items as T[];
}
}Step 4: Runtime Resolver
One function selects the implementation:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export function resolveRuntime(): ResolvedRuntime {
if (process.env.BLOCK_RUNTIME === 'aws') {
return {
auth: new CognitoAuthAdapter(),
data: new DynamoDBDataAdapter(),
aiInsight: new BedrockAiInsightAdapter(data),
};
} else {
return {
auth: new LocalAuthAdapter(),
data: new LocalDataAdapter(),
aiInsight: new LocalAiInsightAdapter(),
};
}
}Step 5: AI with Typed Tools (No Direct DB Access)
The AI Block uses a tool-based architecture - it can only access data through pre-defined, authorized typed tools:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
export class BedrockAiInsightAdapter implements IAiInsightBlockAdapter {
// Tool 1: Only gets the authenticated user's records
async getWellnessRecords(userId: string): Promise<WellnessRecord[]> {
return this.dataAdapter.queryByUserId('wellness_records', userId);
}
// Tool 2: Pure computation on retrieved data
calculateWeeklyStats(records: WellnessRecord[]): WeeklyStats { ... }
// Tool 3: Bedrock generates insight from stats ONLY (not raw data)
async generateWellnessSummary(stats: WeeklyStats, question: string): Promise<string> {
const command = new InvokeModelCommand({
modelId: this.modelId, // configurable, ee.g. Amazon Nova 2 Lite
body: this.buildPrompt(stats, question),
});
return this.bedrockClient.send(command);
}
}The AI never gets:
- Direct database access
- Arbitrary query capabilities
- Other users' data
- Shell/filesystem/network access
Step 6: Security - Ownership Enforcement
1
2
3
4
5
6
7
8
9
10
// Backend ALWAYS derives userId from the authenticated session
function requireAuth(req, res, next) {
const token = req.headers.authorization?.substring(7);
const session = authBlock.requireAuth(token); // throws 401 if invalid
req.userId = session.userId; // NEVER from frontend input
next();
}
// Ownership check — server-side, not hidden UI elements
apiBlock.enforceOwnership(record.userId, req.userId); // throws 403Step 7: CloudFormation (Infrastructure as Code)
The infrastructure deploys with one command:
1
npm run deployCreates: Cognito UserPool, DynamoDB Table (with GSI), Lambda, API Gateway, IAM roles, CloudWatch all with least-privilege permissions.
Cleanup
After testing:
1
npm run destroyor whatever command your project actually uses.
Then mention that readers should verify:
- CloudFormation stack
- Cognito User Pool
- DynamoDB table
- Lambda functions
- API Gateway
- CloudWatch log groups
- Amplify resources
Only include the command if your project actually supports it.
Architecture Deep Dive
Blocks Implemented
| Block | ID | Local | AWS | Purpose |
|---|---|---|---|---|
| AuthBlock | auth-block | In-memory sessions | Amazon Cognito | Authentication & authorization |
| DataBlock | data-block | JSON files | Amazon DynamoDB | Durable persistence |
| ApiBlock | api-block | Express.js | Lambda + API Gateway | HTTP API layer |
| AiInsightBlock | ai-insight-block | Deterministic rules | Amazon Bedrock | AI wellness insights |
| CloudFormationBlock | cloudformation-block | Template generator | AWS CloudFormation | Infrastructure generation |
DynamoDB Design
Based on actual application access patterns:
| Access Pattern | DynamoDB Implementation |
|---|---|
| Get record by ID | GetItem on primary key (id) |
| List user's records (sorted) | Query on GSI (userId + date DESC) |
| Filter by date range | GSI with sort key condition |
| Create with uniqueness | PutItem with attribute_not_exists(id) |
| Optimistic concurrency | Version field increment on update |
Security Model
1
2
3
4
5
6
7
Frontend → API Gateway → Lambda → AuthBlock.requireAuth()
│
▼ (userId from JWT, NOT from request)
DataBlock.queryByUserId(authenticatedUserId)
│
▼ (only user's own data)
AiInsightBlock (receives filtered data only)- Users can never access other users' records
- The AI can never query arbitrary data
- IAM policies grant only the specific actions needed
Infrastructure Cost Breakdown
Estimated Monthly Cost (Low Traffic: ~1000 users, ~10K API calls/month)
| Service | Usage | Cost |
|---|---|---|
| Amazon Cognito | 1,000 MAU (free tier: 50K MAU) | $0.00 |
| Amazon DynamoDB | On-demand, ~10K reads, ~5K writes | $0.01 |
| AWS Lambda | ~10K invocations, 256MB, <1s avg | $0.00 (free tier: 1M/month) |
| API Gateway | ~10K requests | $0.04 |
| Amazon Bedrock (Amazon Nova 2 Lite) | ~500 requests, ~200 tokens avg | $0.03 |
| CloudWatch Logs | ~100MB logs | $0.05 |
| AWS Amplify Hosting | Static hosting, <1GB transfer | $0.00 (free tier: 15GB) |
| Total | ~$0.13/month |
At Scale (10K users, 500K API calls/month)
| Service | Cost |
|---|---|
| Cognito | $0.00 (under 50K MAU) |
| DynamoDB | $1.25 |
| Lambda | $0.20 |
| API Gateway | $1.75 |
| Bedrock | $15.00 |
| CloudWatch | $0.50 |
| Amplify | $0.50 |
| Total | ~$19.20/month |
Key Cost Decisions
- DynamoDB PAY_PER_REQUEST perfect for variable traffic, no idle cost
- Lambda pay only when code runs, scales to zero
- Bedrock Amazon Nova cost-efficient model ($0.00030/1K input tokens)
- No NAT Gateway, no EC2, no RDS - serverless everything
Challenges & Lessons Learned
1. Lambda Filesystem is Read-Only
Problem: The local DataBlock writes to
.data/ folder. Lambda's /var/task is read-only.Solution: Detect Lambda environment and use
/tmp for ephemeral storage, or switch to DynamoDB adapter via BLOCK_RUNTIME=aws.1
2
3
const DATA_DIR = process.env.AWS_LAMBDA_FUNCTION_NAME
? path.join('/tmp', '.data')
: path.join(process.cwd(), '.data');2. Session Cookies Don't Work Cross-Origin
Problem: Local Express uses
express-session with cookies. When frontend is on Amplify and backend on API Gateway (different domains), cookies are blocked.Solution: Token-based authentication. The sign-in response returns a Bearer token that the frontend stores in localStorage and sends in
Authorization headers.3. Amplify Rewrites for POST Requests
Problem: Amplify's rewrite rules documentation focuses on GET. Unclear if POST/PUT/DELETE work.
Solution: Tested they do work. Amplify proxies all HTTP methods through rewrite rules. The frontend uses relative
/api/... paths and Amplify rewrites to API Gateway.4. DynamoDB Scan vs Query
Problem: The local adapter uses filter functions (equivalent to a full table scan). DynamoDB Scans are expensive.
Solution: Added
queryByUserId() to the interface that maps to a DynamoDB GSI Query. The local adapter does the same filtering but the DynamoDB adapter uses the index.5. AI Guardrails
Problem: Giving Bedrock direct access to all data creates security and cost risks.
Solution: Tool-based architecture. The AI receives pre-computed statistics, not raw database access. It can only see the authenticated user's data, and can only generate text observations — not modify data.
Conclusion
The AWS Blocks architecture delivers on its promise:
npm run devworks in 3 seconds - no Docker, no AWS account, no setupnpm run deployputs it on AWS - real Cognito, DynamoDB, Bedrock, Lambda- Same code, both environments - business logic is never duplicated
- Type-safe contracts - TypeScript interfaces guarantee adapter compatibility
- Security by design - ownership enforcement, least-privilege IAM, AI guardrails
- Cost-effective - serverless scales to zero, ~$0.13/month for low traffic
The Block pattern isn't just abstraction for abstraction's sake. It solves real problems:
- Faster local development (no cloud dependency)
- Safer deployments (contract tests prove equivalence)
- Cleaner architecture (concerns are separated at the infrastructure level)
- Easier migration (swap one adapter, not your whole app)
If you're building on AWS and tired of the "works locally, breaks in production" cycle — try Blocks.
Resources
Project
- Live Demo: https://main.d3qq6gk7snyk6x.amplifyapp.com
- GitHub: https://github.com/user/WellnessBlock-AI
AWS Services Used
- Amazon Cognito - User authentication
- Amazon DynamoDB - NoSQL database
- AWS Lambda - Serverless compute
- Amazon API Gateway - REST API management
- Amazon Bedrock - Foundation models (Amazon Nova)
- AWS Amplify Hosting - Static frontend hosting
- AWS CloudFormation - Infrastructure as Code
- Amazon CloudWatch - Logging & monitoring
Technologies
- TypeScript - Type-safe JavaScript
- Express.js - HTTP server framework
- Zod - Schema validation
- esbuild - Fast JavaScript bundler
- serverless-http - Express-to-Lambda adapter
Architecture Patterns
Built by DD Developed by Kiro - AI-powered development environment.
Thank you for taking the time to read my article! If you found it helpful, feel free to like, share, and drop your thoughts in the comments; I’d love to hear from you.
If you want to connect or dive deeper into cloud, AI and DevOps, feel free to follow me on my socials:
💼 LinkedIn
👨💻 DEV Community
🛡️Medium
🐙 GitHub
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article