View a markdown version of this page

Best practices for AWS Blocks - AWS Blocks

Best practices for AWS Blocks

This topic covers best practices for structuring, securing, testing, and scaling AWS Blocks applications.

Project structure

Organize your backend code for clarity and maintainability.

Keep the IFC layer thin. Your aws-blocks/index.ts should contain Block instantiations and API definitions. Move complex business logic into separate modules.

// aws-blocks/index.ts - thin orchestration layer import { ApiNamespace, Scope, KVStore } from '@aws-blocks/blocks'; import { createOrder, listOrders } from './orders.js'; const scope = new Scope('my-app'); const store = new KVStore(scope, 'orders', {}); export const api = new ApiNamespace(scope, 'api', (context) => ({ createOrder: (input) => createOrder(store, context, input), listOrders: () => listOrders(store, context), }));
// aws-blocks/orders.ts - business logic, testable in isolation export async function createOrder(store, context, input) { // validation, business rules, persistence }

Use a single Scope per application. Multiple scopes in one backend create separate resource namespaces, which adds complexity without benefit for most applications.

Co-locate related Blocks. If your app has distinct domains (orders, users, notifications), group them logically but keep them in the same IFC layer file unless the file becomes unwieldy.

Error handling

AWS Blocks propagates errors from your API methods to the frontend with the error name preserved. Use named errors to enable structured error handling on the client.

// Backend import { isBlocksError } from '@aws-blocks/blocks'; export const api = new ApiNamespace(scope, 'api', (context) => ({ async getItem(id: string) { const item = await store.get(id); if (!item) { const err = new Error(`Item ${id} not found`); err.name = 'NotFoundError'; throw err; } return item; }, }));
// Frontend import { api } from '../aws-blocks/index.js'; import { isBlocksError } from '@aws-blocks/blocks'; try { await api.getItem('abc'); } catch (err) { if (isBlocksError(err, 'NotFoundError')) { // Handle not found } }

Best practices for errors:

  • Use descriptive error names (NotFoundError, ValidationError, UnauthorizedError). Avoid generic Error.

  • Never expose internal details (stack traces, AWS resource ARNs) in error messages. The Error.cause property stays server-side and is never sent to the client.

  • Let unhandled errors bubble up. AWS Blocks returns them as generic 500 responses without leaking internals.

Authentication and authorization

Always validate the user in every API method that accesses user data. Don’t rely on frontend-only checks.

export const api = new ApiNamespace(scope, 'api', (context) => ({ async getMyData() { // Always authenticate server-side const user = await auth.getCurrentUser(context); return store.get(`user:${user.userId}`); }, }));

Scope data access by user. Use the user ID as a key prefix to prevent users from accessing each other’s data:

// Good: scoped to user await store.set(`${user.userId}:${itemId}`, data); // Bad: no user scoping - any authenticated user can access any item await store.set(itemId, data);

Choose the right auth Block:

  • AuthBasic: Username/password for prototypes and internal tools.

  • AuthOIDC: Social login with Google, GitHub, Okta, or any OIDC-compliant provider.

  • AuthCognito: Production-ready authentication with social sign-in, MFA, SAML, passkeys, and OAuth/OIDC.

Local development

Develop locally first. Use npm run dev as your primary development loop. Only deploy to a sandbox when you need to test behavior specific to real AWS services.

Understand local implementation limitations. Local implementations replicate the API surface but not all production behavior:

Block Local behavior Production difference

KVStore

Local filesystem store

DynamoDB has eventual consistency for some operations, item size limits (400 KB)

DistributedTable

In-memory store

DynamoDB query pagination, GSI propagation delays

Database

PGlite (embedded Postgres)

Aurora Serverless v2 has connection limits, cold start latency

AuthBasic

Local JWT tokens

Tokens are not portable between local and deployed environments

Realtime

Local WebSocket server

API Gateway WebSocket has connection limits and message size limits

Clear local data when needed. Delete the .bb-data/ directory to reset all local state:

rm -rf .bb-data

Testing

Unit test your business logic independently. Extract logic from API handlers into pure functions that accept Block instances as parameters.

// orders.ts - testable without the full framework export async function createOrder(store: KVStore, userId: string, input: OrderInput) { if (!input.title) throw new Error('Title required'); const order = { id: crypto.randomUUID(), ...input, userId }; await store.set(`${userId}:${order.id}`, order); return order; }
// orders.test.ts import { createOrder } from './orders.js'; it('creates an order', async () => { const mockStore = { set: vi.fn(), get: vi.fn() }; const result = await createOrder(mockStore, 'user-1', { title: 'Test' }); expect(result.title).toBe('Test'); expect(mockStore.set).toHaveBeenCalled(); });

Integration test locally. Run your full application with npm run dev and test the API endpoints. Local implementations are deterministic and fast.

End-to-end test with sandbox. For critical paths, deploy to a sandbox and run tests against real AWS services to catch behavior differences.

Performance

Minimize Block instantiations. Each Block maps to AWS resources. Don’t create a new KVStore for every data type. Use key prefixes to partition data within a single store.

// Good: one store, partitioned by prefix const store = new KVStore(scope, 'data', {}); await store.set(`users:${id}`, userData); await store.set(`orders:${id}`, orderData); // Avoid: separate stores for each entity (more DynamoDB tables, more cost) const userStore = new KVStore(scope, 'users', {}); const orderStore = new KVStore(scope, 'orders', {});

Use DistributedTable for query-heavy workloads. If you need to query by multiple attributes or sort data, DistributedTable with indexes is more efficient than scanning a KVStore.

Keep API methods focused. Each ApiNamespace method becomes a Lambda invocation path. Avoid methods that do too many things. Split them into focused operations.

Deployment

Use sandbox for development, full deploy for production. Sandboxes use Lambda hot-swapping for speed but aren’t suitable for production traffic.

Set up separate AWS accounts for development, staging, and production. AWS Blocks deploys the same code to any account. The resources are derived from your code, not from environment-specific configuration.

Configure environment-specific settings in the CDK layer. Custom domains, VPC configuration, and other environment differences belong in aws-blocks/index.cdk.ts, not in your runtime code.

// aws-blocks/index.cdk.ts const isProd = process.env.DEPLOY_ENV === 'production'; const stack = await BlocksStack.create(app, stackName, { /* ... */ }); if (isProd) { // Production-only: custom domain, WAF, etc. new Hosting(stack, 'Hosting', { customDomain: 'app.example.com', }); }

Working with AI agents

AWS Blocks is designed to work well with AI coding assistants. Steering files ship in the npm package, guiding agents to produce correct architecture from the start.

To get the best results from AI coding agents:

  • Keep your IFC layer clean and readable. AI agents use it as context to understand your application.

  • Use descriptive Block IDs. new KVStore(scope, 'user-sessions', {}) gives agents more context than new KVStore(scope, 's1', {}).

  • Write JSDoc comments on API methods. Agents use these to understand intent when generating frontend code.

  • Install Block packages. Their README.md and type definitions in node_modules provide agents with API documentation and usage examples.

  • Use the Agent Block for AI features. The Agent block provides tool calling, HITL approval, and conversation persistence. Locally, it uses a canned provider for predictable testing without API keys.

Example prompt for AI agents

The following prompt demonstrates how an AI agent can add multiple capabilities in a single interaction:

Add a FileBucket for user avatars, an AsyncJob that generates thumbnails on upload, and a Realtime channel that notifies the UI when processing completes.