Skip to content

Your First Command

What You’ll Build: Add a complete command handler with validation, business logic, and automated API generation.

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

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
  • Completed Your First Service
  • Service running with CreateUserCommand
  • Basic understanding of TypeScript

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/:id endpoint
  • Automatically gets GraphQL updateUser mutation

Commands are defined in your contracts package. This creates the type-safe API.

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:

  • @Command decorator registers the command with the platform
  • requiredPermissions enforces Layer 1 authorization at the API Gateway
  • Optional fields (email?, firstName?) allow partial updates
  • Result interface includes success flag and error handling

Update packages/contracts/src/index.ts:

export * from './commands.js';
Terminal window
cd packages/contracts
pnpm run build

Before we can update users, we need somewhere to store them. Create a simple in-memory read model.

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:

  • @ReadModel decorator 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(),
};
}
}

Now create the update command handler with validation and authorization.

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:

  1. Validation: Check user exists and email format is valid
  2. Layer 2 Authorization: Business rule enforcement in handler
  3. Business Logic: Only update fields that were provided
  4. Error Handling: Try-catch with detailed error messages
  5. Success Response: Return updated user data
Terminal window
cd service
pnpm run build
Terminal window
node dist/index.js

Expected output:

Handler discovery completed {
commandHandlers: 2,
queryHandlers: 0,
eventHandlers: 0,
totalHandlers: 2
}
Service registered with discovery: my-service
Contract broadcast complete: 2 contracts
My Service started successfully

Notice: 2 command handlers discovered (CreateUser and UpdateUser)

Terminal window
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"
}
Terminal window
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"
}
}

Try updating someone else’s profile:

Terminal window
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"
}

Try invalid email:

Terminal window
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"
}

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.

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.

The platform automatically created:

REST Endpoint:

POST /api/users/update

GraphQL Mutation:

mutation {
updateUser(input: {
userId: "user-123"
firstName: "Jonathan"
}) {
success
user {
userId
email
firstName
lastName
updatedAt
}
error
}
}

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

Now that you understand commands, you can:

Only update fields that are provided:

const updates: Partial<UserData> = {};
if (command.email) updates.email = command.email;
if (command.firstName) updates.firstName = command.firstName;

Create reusable validation methods:

private isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}

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 };
}

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');
}

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

Problem: Always returns “User not found”

Solution: Ensure you’re using the same user ID:

  1. Create user and note the userId in response
  2. Use that exact userId in update command
  3. Check CreateUserHandler is storing users in UserReadModel

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:

Terminal window
# These must match:
-H "X-Dev-User-Id: user-1705334567890"
-d '{ "userId": "user-1705334567890", ... }'