CP-03: Result Pattern (Either Monad)
Intent
Replace exception-based control flow for expected business failures with an explicit Result<T> type that forces callers to handle both success and failure paths. Exceptions are reserved for unexpected infrastructure failures only.
Core Type
// core/primitives/result.ts
export class Result<T> {
private constructor(
private readonly _isSuccess: boolean,
private readonly _error: string | undefined,
private readonly _value: T | undefined,
) {}
get isSuccess(): boolean { return this._isSuccess; }
get isFailure(): boolean { return !this._isSuccess; }
get value(): T {
if (!this._isSuccess) throw new Error('Cannot access value of a failed Result.');
return this._value as T;
}
get error(): string {
if (this._isSuccess) throw new Error('Cannot access error of a successful Result.');
return this._error as string;
}
static ok<T>(value: T): Result<T> {
return new Result<T>(true, undefined, value);
}
static fail<T>(error: string): Result<T> {
return new Result<T>(false, error, undefined);
}
// Monadic chain — applies fn only on success
map<U>(fn: (value: T) => U): Result<U> {
if (this.isFailure) return Result.fail<U>(this.error);
return Result.ok(fn(this.value));
}
// Async chain
async flatMapAsync<U>(fn: (value: T) => Promise<Result<U>>): Promise<Result<U>> {
if (this.isFailure) return Result.fail<U>(this.error);
return fn(this.value);
}
}
Domain Usage
// core/entities/user.aggregate.ts
deactivate(): Result<void> {
if (this._status === 'INACTIVE') {
return Result.fail('User is already inactive.'); // expected business rule
}
this._status = 'INACTIVE';
this.addDomainEvent(new UserDeactivatedEvent(this.id));
return Result.ok(undefined);
}
Use Case Usage
// application/use-cases/deactivate-user.use-case.ts
@Injectable()
export class DeactivateUserUseCase {
constructor(private readonly userRepository: IUserRepository) {}
async execute(command: DeactivateUserCommand): Promise<Result<void>> {
const user = await this.userRepository.findById(command.userId);
if (!user) return Result.fail('User not found.'); // not found = expected failure
const result = user.deactivate();
if (result.isFailure) return result;
await this.userRepository.save(user);
return Result.ok(undefined);
}
}
HTTP Controller Mapping
// infrastructure/controllers/user.controller.ts
@Patch(':id/deactivate')
async deactivate(@Param('id') id: string): Promise<void> {
const result = await this.deactivateUserUseCase.execute({ userId: id });
if (result.isFailure) {
const msg = result.error;
if (msg === 'User not found.') throw new NotFoundException(msg);
throw new UnprocessableEntityException(msg); // business rule violation → 422
}
// 200 OK implicit — no body for deactivation
}
Error Classification
| Category |
Handling |
HTTP Status |
| Not found (expected) |
Result.fail('... not found.') |
404 |
| Business rule violation |
Result.fail('...') |
422 |
| Validation (input) |
NestJS ValidationPipe + class-validator |
400 |
| Infrastructure failure |
throw new Error(...) (unexpected) |
500 |
| Auth / permission |
throw new ForbiddenException(...) |
403 |
Chaining Example
// Chaining multiple Result-returning operations
const result = await userResult
.flatMapAsync((user) => this.validateTenantAccess(user, tenantId))
.then((r) => r.flatMapAsync((user) => this.assignRole(user, roleId)));
if (result.isFailure) {
return Result.fail(result.error);
}
What NOT to Do
// ❌ Throwing for business rules
throw new Error('User already exists'); // Use Result.fail(...)
// ❌ Returning null for not-found
return null; // Use Result.fail('... not found.')
// ❌ Ignoring Result
const r = user.deactivate();
await this.repo.save(user); // Must check r.isFailure first
// ❌ Using Result for infrastructure errors
try {
return Result.fail('DB connection lost'); // This should throw — it's unexpected
} catch {}