Your First Command
Your First Command
Section titled “Your First Command”What You’ll Build: Add a complete command handler with validation, business logic, and automated API generation.
Overview
Section titled “Overview”In the previous guide, you created a basic service with one command handler. Now you’ll learn how to create a more complete command that demonstrates validation, business rules, and the full power of the platform’s automatic API generation.
We’ll add an UpdateUser command to your service that shows:
- Input validation
- Business rule enforcement
- Error handling
- Policy-based authorization
- Automatic REST and GraphQL endpoints
Learning Objectives
Section titled “Learning Objectives”By the end of this guide, you will be able to:
- Create commands with validation rules
- Implement business logic in command handlers
- Use policy-based authorization (Layer 2)
- Handle errors gracefully
- Test commands via REST and GraphQL
Prerequisites
Section titled “Prerequisites”- Completed Your First Service
- Service running with
CreateUserCommand - Basic understanding of TypeScript
What We’re Building
Section titled “What We’re Building”We’ll add an UpdateUser command that:
- Validates email format
- Enforces business rule: users can only update their own profile (unless admin)
- Returns updated user data
- Automatically gets REST
PUT /api/users/:idendpoint - Automatically gets GraphQL
updateUsermutation
Step 1: Define the Contract
Section titled “Step 1: Define the Contract”Commands are defined in your contracts package. This creates the type-safe API.
Create the Update Command
Section titled “Create the Update Command”Create packages/contracts/src/commands.ts (or add to existing file):
import { Command } from '@banyanai/platform-contract-system';
// Keep your existing CreateUserCommand...
@Command({ name: 'MyService.Commands.UpdateUser', description: 'Update user profile information', requiredPermissions: ['users:update'],})export class UpdateUserCommand { userId!: string; email?: string; firstName?: string; lastName?: string;}
export interface UpdateUserResult { success: boolean; user?: { userId: string; email: string; firstName: string; lastName: string; updatedAt: string; }; error?: string;}Key Concepts:
@Commanddecorator registers the command with the platformrequiredPermissionsenforces Layer 1 authorization at the API Gateway- Optional fields (
email?,firstName?) allow partial updates - Result interface includes success flag and error handling
Export the Contract
Section titled “Export the Contract”Update packages/contracts/src/index.ts:
export * from './commands.js';Rebuild Contracts
Section titled “Rebuild Contracts”cd packages/contractspnpm run buildStep 2: Create a Simple Read Model
Section titled “Step 2: Create a Simple Read Model”Before we can update users, we need somewhere to store them. Create a simple in-memory read model.
Create Read Model File
Section titled “Create Read Model File”Create service/src/read-models/UserReadModel.ts:
import { ReadModel, ReadModelBase } from '@banyanai/platform-event-sourcing';
interface UserData { id: string; email: string; firstName: string; lastName: string; createdAt: Date; updatedAt: Date;}
@ReadModel({ tableName: 'users' })export class UserReadModel extends ReadModelBase<UserReadModel> { id!: string; email!: string; firstName!: string; lastName!: string; createdAt!: Date; updatedAt!: Date;
getId(): string { return this.id; }
// In-memory storage for this example private static users = new Map<string, UserData>();
static async findById(id: string): Promise<UserReadModel | null> { const userData = this.users.get(id); if (!userData) return null;
const model = new UserReadModel(); Object.assign(model, userData); return model; }
static async create(userData: UserData): Promise<void> { this.users.set(userData.id, userData); }
static async update(id: string, updates: Partial<UserData>): Promise<void> { const existing = this.users.get(id); if (!existing) { throw new Error(`User ${id} not found`); }
this.users.set(id, { ...existing, ...updates, updatedAt: new Date(), }); }}Key Concepts:
@ReadModeldecorator registers it with the platform- Static methods provide query interface
- In-memory Map for simplicity (in production, this would be PostgreSQL)
getId()required by platform
Step 3: Update Create Handler to Use Read Model
Section titled “Step 3: Update Create Handler to Use Read Model”Update your CreateUserHandler to store users:
import { CommandHandler, CommandHandlerDecorator } from '@banyanai/platform-base-service';import type { AuthenticatedUser } from '@banyanai/platform-core';import { Logger } from '@banyanai/platform-telemetry';import { CreateUserCommand, type CreateUserResult } from '@myorg/my-service-contracts';import { UserReadModel } from '../read-models/UserReadModel.js';
@CommandHandlerDecorator(CreateUserCommand)export class CreateUserHandler extends CommandHandler<CreateUserCommand, CreateUserResult> { constructor() { super(); }
async handle(command: CreateUserCommand, user: AuthenticatedUser | null): Promise<CreateUserResult> { Logger.info('Creating user', { email: command.email });
// Generate ID const userId = `user-${Date.now()}`;
// Store in read model await UserReadModel.create({ id: userId, email: command.email, firstName: command.firstName, lastName: command.lastName, createdAt: new Date(), updatedAt: new Date(), });
return { userId, email: command.email, createdAt: new Date().toISOString(), }; }}Step 4: Create the Update Handler
Section titled “Step 4: Create the Update Handler”Now create the update command handler with validation and authorization.
Create Handler File
Section titled “Create Handler File”Create service/src/commands/UpdateUserHandler.ts:
import { CommandHandler, CommandHandlerDecorator } from '@banyanai/platform-base-service';import type { AuthenticatedUser } from '@banyanai/platform-core';import { Logger } from '@banyanai/platform-telemetry';import { UpdateUserCommand, type UpdateUserResult } from '@myorg/my-service-contracts';import { UserReadModel } from '../read-models/UserReadModel.js';
@CommandHandlerDecorator(UpdateUserCommand)export class UpdateUserHandler extends CommandHandler<UpdateUserCommand, UpdateUserResult> { constructor() { super(); }
async handle(command: UpdateUserCommand, user: AuthenticatedUser | null): Promise<UpdateUserResult> { try { Logger.info('Updating user', { userId: command.userId });
// 1. Validation: Check user exists const existingUser = await UserReadModel.findById(command.userId); if (!existingUser) { return { success: false, error: `User ${command.userId} not found`, }; }
// 2. Validation: Email format (if provided) if (command.email && !this.isValidEmail(command.email)) { return { success: false, error: 'Invalid email format', }; }
// 3. Layer 2 Authorization: Policy-based business rule // Users can only update their own profile (unless they're admin) if (user) { const isAdmin = user.permissions?.includes('users:admin'); const isOwnProfile = user.userId === command.userId;
if (!isOwnProfile && !isAdmin) { return { success: false, error: 'You can only update your own profile', }; } }
// 4. Business Logic: Update user const updates: any = {}; if (command.email) updates.email = command.email; if (command.firstName) updates.firstName = command.firstName; if (command.lastName) updates.lastName = command.lastName;
await UserReadModel.update(command.userId, updates);
// 5. Return updated user const updatedUser = await UserReadModel.findById(command.userId); if (!updatedUser) { throw new Error('Failed to retrieve updated user'); }
Logger.info('User updated successfully', { userId: command.userId });
return { success: true, user: { userId: updatedUser.id, email: updatedUser.email, firstName: updatedUser.firstName, lastName: updatedUser.lastName, updatedAt: updatedUser.updatedAt.toISOString(), }, }; } catch (error) { Logger.error('Failed to update user', { error, userId: command.userId }); return { success: false, error: error instanceof Error ? error.message : 'Unknown error occurred', }; } }
private isValidEmail(email: string): boolean { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return emailRegex.test(email); }}Key Concepts:
- Validation: Check user exists and email format is valid
- Layer 2 Authorization: Business rule enforcement in handler
- Business Logic: Only update fields that were provided
- Error Handling: Try-catch with detailed error messages
- Success Response: Return updated user data
Step 5: Build and Run
Section titled “Step 5: Build and Run”Rebuild Service
Section titled “Rebuild Service”cd servicepnpm run buildRestart Service
Section titled “Restart Service”node dist/index.jsExpected output:
Handler discovery completed { commandHandlers: 2, queryHandlers: 0, eventHandlers: 0, totalHandlers: 2}Service registered with discovery: my-serviceContract broadcast complete: 2 contractsMy Service started successfullyNotice: 2 command handlers discovered (CreateUser and UpdateUser)
Step 6: Test Your Command
Section titled “Step 6: Test Your Command”Create a User First
Section titled “Create a User First”curl -X POST http://localhost:3003/api/users \ -H "Content-Type: application/json" \ -H "X-Dev-User-Id: user-1705334567890" \ -H "X-Dev-Permissions: users:create" \ -d '{ "email": "john@example.com", "firstName": "John", "lastName": "Doe" }'Response:
{ "userId": "user-1705334567890", "email": "john@example.com", "createdAt": "2025-01-15T10:30:45.123Z"}Update the User
Section titled “Update the User”curl -X POST http://localhost:3003/api/users/update \ -H "Content-Type: application/json" \ -H "X-Dev-User-Id: user-1705334567890" \ -H "X-Dev-Permissions: users:update" \ -d '{ "userId": "user-1705334567890", "firstName": "Jonathan", "email": "jonathan@example.com" }'Response:
{ "success": true, "user": { "userId": "user-1705334567890", "email": "jonathan@example.com", "firstName": "Jonathan", "lastName": "Doe", "updatedAt": "2025-01-15T10:35:22.456Z" }}Test Authorization
Section titled “Test Authorization”Try updating someone else’s profile:
curl -X POST http://localhost:3003/api/users/update \ -H "Content-Type: application/json" \ -H "X-Dev-User-Id: different-user-999" \ -H "X-Dev-Permissions: users:update" \ -d '{ "userId": "user-1705334567890", "firstName": "Hacker" }'Response:
{ "success": false, "error": "You can only update your own profile"}Test Validation
Section titled “Test Validation”Try invalid email:
curl -X POST http://localhost:3003/api/users/update \ -H "Content-Type: application/json" \ -H "X-Dev-User-Id: user-1705334567890" \ -H "X-Dev-Permissions: users:update" \ -d '{ "userId": "user-1705334567890", "email": "not-an-email" }'Response:
{ "success": false, "error": "Invalid email format"}Understanding What Happened
Section titled “Understanding What Happened”1. Layer 1 Authorization (API Gateway)
Section titled “1. Layer 1 Authorization (API Gateway)”The API Gateway checked the users:update permission before routing the request:
requiredPermissions: ['users:update']If the user didn’t have this permission, they’d get a 403 before reaching your handler.
2. Layer 2 Authorization (Handler)
Section titled “2. Layer 2 Authorization (Handler)”Your handler enforced the business rule:
if (!isOwnProfile && !isAdmin) { return { success: false, error: 'You can only update your own profile', };}This is a policy - a business rule that can’t be expressed as a simple permission.
3. Automatic API Generation
Section titled “3. Automatic API Generation”The platform automatically created:
REST Endpoint:
POST /api/users/updateGraphQL Mutation:
mutation { updateUser(input: { userId: "user-123" firstName: "Jonathan" }) { success user { userId email firstName lastName updatedAt } error }}4. Type Safety
Section titled “4. Type Safety”The entire flow is type-safe:
- Contract defines input/output types
- Handler is strongly typed
- API Gateway validates input against contract
- No runtime type errors possible
Next Steps
Section titled “Next Steps”Now that you understand commands, you can:
- Add a Query Handler - Learn read operations
- Add Event Handlers - React to state changes
- Tutorial: Todo Service - Build complete CRUD service
- Authorization Deep Dive - Master two-layer auth
Common Patterns
Section titled “Common Patterns”Partial Updates
Section titled “Partial Updates”Only update fields that are provided:
const updates: Partial<UserData> = {};if (command.email) updates.email = command.email;if (command.firstName) updates.firstName = command.firstName;Validation
Section titled “Validation”Create reusable validation methods:
private isValidEmail(email: string): boolean { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return emailRegex.test(email);}Error Handling
Section titled “Error Handling”Always wrap in try-catch and return success/error:
try { // Business logic return { success: true, user: updatedUser };} catch (error) { Logger.error('Operation failed', { error }); return { success: false, error: error.message };}Policy Checks
Section titled “Policy Checks”Implement business rules as policy checks:
const isAdmin = user.permissions?.includes('users:admin');const isOwner = resource.userId === user.userId;
if (!isOwner && !isAdmin) { throw new PolicyViolationError('Access denied');}Troubleshooting
Section titled “Troubleshooting”Handler Not Discovered
Section titled “Handler Not Discovered”Problem: New handler doesn’t appear in discovery output
Solution: Check these requirements:
- File is in
src/commands/directory - File name ends with
Handler.ts - Class has
@CommandHandlerDecorator(Command)decorator - Class extends
CommandHandler<Input, Output> - Contract was rebuilt:
cd packages/contracts && pnpm run build
User Not Found
Section titled “User Not Found”Problem: Always returns “User not found”
Solution: Ensure you’re using the same user ID:
- Create user and note the
userIdin response - Use that exact
userIdin update command - Check CreateUserHandler is storing users in UserReadModel
Authorization Always Fails
Section titled “Authorization Always Fails”Problem: “You can only update your own profile” even for own profile
Solution: Ensure X-Dev-User-Id header matches the userId in the command:
# These must match:-H "X-Dev-User-Id: user-1705334567890"-d '{ "userId": "user-1705334567890", ... }'