AWS Builder Center
How I Built a Production Wellness App Using AWS Blocks Architecture

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.

Sharing, Learning, Growing Together
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

  1. Problem Statement 
  2. The Solution: AWS Blocks Architecture 
  3. How I Built It 
  4. Architecture Deep Dive 
  5. Infrastructure Cost Breakdown 
  6. Challenges & Lessons Learned 
  7. Conclusion 
  8. Resources 


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:
  1. A stable interface - the contract that business logic programs against
  2. A local adapter - works offline, no cloud needed
  3. An AWS adapter - production implementation using real services
  4. 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 403

Step 7: CloudFormation (Infrastructure as Code)

The infrastructure deploys with one command:
1
npm run deploy
Creates: Cognito UserPool, DynamoDB Table (with GSI), Lambda, API Gateway, IAM roles, CloudWatch all with least-privilege permissions.

Cleanup

After testing:
1
npm run destroy
or 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

BlockIDLocalAWSPurpose
AuthBlockauth-blockIn-memory sessionsAmazon CognitoAuthentication & authorization
DataBlockdata-blockJSON filesAmazon DynamoDBDurable persistence
ApiBlockapi-blockExpress.jsLambda + API GatewayHTTP API layer
AiInsightBlockai-insight-blockDeterministic rulesAmazon BedrockAI wellness insights
CloudFormationBlockcloudformation-blockTemplate generatorAWS CloudFormationInfrastructure generation

DynamoDB Design

Based on actual application access patterns:
Access PatternDynamoDB Implementation
Get record by IDGetItem on primary key (id)
List user's records (sorted)Query on GSI (userId + date DESC)
Filter by date rangeGSI with sort key condition
Create with uniquenessPutItem with attribute_not_exists(id)
Optimistic concurrencyVersion 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)

ServiceUsageCost
Amazon Cognito1,000 MAU (free tier: 50K MAU)$0.00
Amazon DynamoDBOn-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 HostingStatic hosting, <1GB transfer$0.00 (free tier: 15GB)
Total~$0.13/month

At Scale (10K users, 500K API calls/month)

ServiceCost
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:
  1. npm run dev works in 3 seconds - no Docker, no AWS account, no setup
  2. npm run deploy puts it on AWS - real Cognito, DynamoDB, Bedrock, Lambda
  3. Same code, both environments - business logic is never duplicated
  4. Type-safe contracts - TypeScript interfaces guarantee adapter compatibility
  5. Security by design - ownership enforcement, least-privilege IAM, AI guardrails
  6. 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

AWS Services Used

Technologies

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:
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article