Error Handling Strategy using Result<T, E>#
This document outlines how the Result<T, E> type (Ok<T, E> | Err<T, E>) from src/shared/core/Result.ts should be used for error handling across the different layers of the application, following DDD and layered architecture best practices.
Core Principle#
The Result type should be used for operations that can predictably fail due to invalid input, violated business rules, unavailable resources, or external system issues. It makes potential failures explicit in the function signature and forces the caller to handle both success (Ok) and failure (Err) paths, improving robustness and clarity.
Avoid using Result for:
- Truly exceptional, unrecoverable system errors (e.g., out of memory, critical infrastructure failure). Standard exceptions might be more appropriate here, potentially caught at a high-level boundary.
- Flow control that isn't related to a success/failure outcome.
Usage by Layer#
1. Domain Layer (src/<context>/domain/)#
-
Purpose: Handles failures related to business rule validation and invariant enforcement.
-
Where:
- Static factory methods (
create) for Value Objects and Entities: These methods are the primary place to enforce invariants. If creation fails due to invalid input or violated rules, return anErr. - Domain Services: Methods within domain services that perform complex operations or validations involving multiple domain objects might return a
Result. - Aggregate Root methods: Methods that change the state of an aggregate might return a
Resultif the change could violate an invariant based on the provided input or current state.
- Static factory methods (
-
Error Type (
E): Use specific, fine-grained domain error classes derived from a baseDomainErroror similar. Examples:InvalidEmailError,OrderCannotBeCancelledError,InsufficientStockError. These errors convey specific business meaning. -
Example:
// src/modules/annotations/domain/value-objects/AnnotationNote.ts import { ValueObject } from '../../../../shared/domain/ValueObject'; import { Result, ok, err } from '../../../../shared/core/Result'; export class InvalidNoteError extends Error { /* ... */ } // Specific Domain Error export class AnnotationNote extends ValueObject<{ value: string }> { public static readonly MAX_LENGTH = 1000; getValue(): string { return this.props.value; } private constructor(props: { value: string }) { super(props); } public static create( value: string, ): Result<AnnotationNote, InvalidNoteError> { if (value.length > this.MAX_LENGTH) { return err( new InvalidNoteError( `Note exceeds maximum length of ${this.MAX_LENGTH}`, ), ); } return ok(new AnnotationNote({ value })); } }
2. Application Layer (src/<context>/application/)#
-
Purpose: Handles failures related to use case orchestration, authorization, finding resources, and mapping errors from lower layers.
-
Where:
- Use Case
executemethods: The primary return type for use cases should bePromise<Result<T, UseCaseError>>.Tis the successful output (often a DTO orvoid), andUseCaseErroris a more general application-level error. - Application Services: Similar to use cases, services coordinating tasks return
Result.
- Use Case
-
Error Handling:
- Use cases call domain methods and infrastructure repositories/services that return
Result. - They must check the result of each call (
isOk()/isErr()). - If a lower layer returns an
Err, the use case might:- Return the error directly if it's already a suitable
UseCaseError. - Map the specific domain or infrastructure error to a more general
UseCaseError(e.g., mapInvalidNoteErrortoValidationError, mapDatabaseConnectionErrortoAppError.UnexpectedError). - Perform compensating actions if necessary.
- Return the error directly if it's already a suitable
- Use
combinefromResult.tswhen multiple independent operations must all succeed.
- Use cases call domain methods and infrastructure repositories/services that return
-
Error Type (
E): Use general application/use case error classes derived fromUseCaseError(src/shared/core/UseCaseError.ts). Examples:ValidationError: Input failed validation.ResourceNotFoundError: A required entity (e.g., User, Annotation) wasn't found.PermissionDeniedError: User is not authorized.AppError.UnexpectedError: Wraps unexpected errors caught from lower layers or unforeseen exceptions.
Note:
UseCaseErroris an abstract class that only provides amessageproperty. Derived error classes should not setthis.nameas it's not part of the base class interface. -
Example:
// src/modules/annotations/application/use-cases/CreateAnnotationUseCase.ts import { UseCase } from '../../../../shared/core/UseCase'; import { Result, ok, err } from '../../../../shared/core/Result'; import { UseCaseError } from '../../../../shared/core/UseCaseError'; import { AppError } from '../../../../shared/core/AppError'; import { IAnnotationRepository } from '../repositories/IAnnotationRepository'; import { Annotation } from '../../domain/aggregates/Annotation'; import { AnnotationNote, InvalidNoteError, } from '../../domain/value-objects/AnnotationNote'; // ... other imports export class ValidationError extends UseCaseError { constructor(message: string) { super(message); } } export class CreateAnnotationUseCase implements UseCase< InputDTO, Result<void, ValidationError | AppError.UnexpectedError> > { constructor(private repo: IAnnotationRepository) {} async execute( request: InputDTO, ): Promise<Result<void, ValidationError | AppError.UnexpectedError>> { try { const noteResult = AnnotationNote.create(request.note); if (noteResult.isErr()) { // Map DomainError to UseCaseError return err(new ValidationError(noteResult.error.message)); } // ... create other value objects, potentially returning err(...) on failure ... const annotation = Annotation.create({ /* ..., note: noteResult.value, ... */ }); // Assuming Annotation.create doesn't return Result here const saveResult = await this.repo.save(annotation); // Assuming repo.save returns Result<void, InfrastructureError> if (saveResult.isErr()) { // Wrap InfrastructureError in UnexpectedError return err(AppError.UnexpectedError.create(saveResult.error)); } return ok(undefined); // Success (void) } catch (e) { // Catch any truly unexpected exceptions return err(AppError.UnexpectedError.create(e)); } } }
3. Infrastructure Layer (src/<context>/infrastructure/)#
-
Purpose: Handles failures related to technical concerns like database access, network communication, external API calls, file system operations, etc.
-
Where:
- Repository implementations: Methods like
findById,save,deleteshould returnPromise<Result<T | null, InfrastructureError>>orPromise<Result<void, InfrastructureError>>. - External service clients/adapters: Adapters interacting with third-party APIs or message queues.
- Publishers: Implementations of
IPublisherinterfaces.
- Repository implementations: Methods like
-
Error Handling:
- Catch exceptions thrown by underlying libraries (e.g., database drivers, HTTP clients, SDKs).
- Wrap these exceptions into specific
InfrastructureErrortypes and return them asErr. - Do not let raw library exceptions leak upwards.
-
Error Type (
E): Use specific infrastructure error classes derived from a baseInfrastructureErroror similar. Examples:DatabaseError,NetworkError,ApiUnavailableError,FileSystemError. -
Example:
// src/modules/annotations/infrastructure/persistence/repositories/DrizzleAnnotationRepository.ts import { IAnnotationRepository } from '../../../application/repositories/IAnnotationRepository'; import { Result, ok, err } from '../../../../../shared/core/Result'; import { Annotation } from '../../../domain/aggregates/Annotation'; import { TID } from '../../../../../atproto/domain/value-objects/TID'; // ... other imports export class DatabaseError extends Error { constructor(message: string, cause?: Error) { super(message); this.cause = cause; } } // Infrastructure Error export class DrizzleAnnotationRepository implements IAnnotationRepository { // ... constructor ... async findById(id: TID): Promise<Result<Annotation | null, DatabaseError>> { try { const dbResult = await this.db.query.annotationsTable.findFirst({ where: eq(schema.annotationsTable.id, id.toString()), }); if (!dbResult) { return ok(null); // Found nothing, but not an error } const annotation = AnnotationMapper.toDomain(dbResult); // Assuming a mapper return ok(annotation); } catch (e) { console.error('Database error in findById:', e); return err( new DatabaseError( 'Failed to query annotation by ID', e instanceof Error ? e : undefined, ), ); } } async save(annotation: Annotation): Promise<Result<void, DatabaseError>> { try { const persistenceModel = AnnotationMapper.toPersistence(annotation); // ... perform drizzle insert/update ... await this.db .insert(schema.annotationsTable) .values(persistenceModel) .onConflictDoUpdate(/* ... */); return ok(undefined); } catch (e) { console.error('Database error saving annotation:', e); return err( new DatabaseError( 'Failed to save annotation', e instanceof Error ? e : undefined, ), ); } } // ... other methods ... }
Error Handling Flow Summary#
- Infrastructure: Catches technical exceptions -> Wraps in
InfrastructureError-> ReturnsErr. - Application (Use Case): Calls Infra/Domain -> Checks
Result-> IfErr, maps toUseCaseError(orAppError.UnexpectedError) -> ReturnsErr. - Presentation (e.g., API Controller): Calls Use Case -> Checks
Result-> IfOk, maps value to success response (e.g., 200 OK) -> IfErr, mapsUseCaseErrorto appropriate error response (e.g., 400 Bad Request, 404 Not Found, 500 Internal Server Error).
This layered approach ensures that errors are handled appropriately at each level, preventing low-level details from leaking upwards and providing meaningful error information to the caller at each boundary.