Skip to content

Part 3: Understanding Failure Events and the Result Type

Why do we use explicit failure events instead of exceptions?

The Problem with Exceptions

Consider this traditional approach:

def create_code(name: str, color: Color) -> Code:
    """Create a new code. Raises ValueError on invalid input."""
    if not name:
        raise ValueError("Name cannot be empty")
    if not is_unique(name):
        raise DuplicateNameError(f"Name '{name}' already exists")
    if not is_valid_color(color):
        raise ValueError("Invalid color")
    return Code(name=name, color=color)

Problems:

1. Hidden Control Flow

The function signature says -> Code, but it might not return a Code at all! The caller must know (somehow) what exceptions to catch:

try:
    code = create_code(name, color)
except ValueError as e:
    # Was it the name? The color? Something else?
    show_error(str(e))
except DuplicateNameError as e:
    show_error("Name taken")

2. No Type Safety

Python's type system can't express "returns Code or raises these specific exceptions." The caller has no compile-time help.

3. Exception Proliferation

Each failure type needs its own exception class:

class EmptyNameError(ValueError): pass
class DuplicateNameError(ValueError): pass
class InvalidColorError(ValueError): pass
class InvalidPriorityError(ValueError): pass
# ... and on it goes

4. Composition is Awkward

Chaining operations that might fail requires nested try/except:

try:
    code = create_code(name, color)
    try:
        segment = apply_code(code.id, source_id, position)
    except CodeNotFoundError:
        ...
    except InvalidPositionError:
        ...
except DuplicateNameError:
    ...

The Failure Events Solution

QualCoder v2 uses failure events - domain events that represent failed operations. These are defined in src/contexts/coding/core/failure_events.py:

@dataclass(frozen=True)
class CodeNotCreated(FailureEvent):
    """Failure event: Code creation failed."""

    name: str | None = None
    category_id: CategoryId | None = None

    @classmethod
    def empty_name(cls) -> CodeNotCreated:
        return cls(
            event_id=cls._generate_id(),
            occurred_at=cls._now(),
            event_type="CODE_NOT_CREATED/EMPTY_NAME",
        )

    @classmethod
    def duplicate_name(cls, name: str) -> CodeNotCreated:
        return cls(
            event_id=cls._generate_id(),
            occurred_at=cls._now(),
            event_type="CODE_NOT_CREATED/DUPLICATE_NAME",
            name=name,
        )

Now our function signature is honest:

def derive_create_code(...) -> CodeCreated | CodeNotCreated:

The return type tells you: "You'll get a CodeCreated success event OR a CodeNotCreated failure event."

Note: The codebase also provides the OperationResult pattern from src/shared/common/operation_result.py for use in command handlers.

Benefits

1. Explicit Error Handling

The caller must handle both cases:

result = derive_create_code(name, color, state)

if isinstance(result, CodeNotCreated):
    # Handle error - can't accidentally ignore it
    return handle_error(result)

# result is CodeCreated here
save_and_publish(result)

2. Pattern Matching on Failure Events

Failure events have a reason property extracted from their event_type:

result = derive_create_code(name, color, state)

if isinstance(result, CodeNotCreated):
    match result.reason:
        case "EMPTY_NAME":
            show_error("Please enter a name")
        case "DUPLICATE_NAME":
            show_error(f"'{result.name}' already exists")
        case "CATEGORY_NOT_FOUND":
            show_error(f"Category not found")
        case _:
            show_error(result.message)  # Fallback to human-readable message

3. Failure Events are Data

Failure events carry context as data:

@dataclass(frozen=True)
class CodeNotCreated(FailureEvent):
    name: str | None = None
    category_id: CategoryId | None = None

    @property
    def message(self) -> str:
        match self.reason:
            case "EMPTY_NAME":
                return "Code name cannot be empty"
            case "DUPLICATE_NAME":
                return f"Code name '{self.name}' already exists"
            case _:
                return super().message

You can: - Access result.name for the conflicting name - Access result.message for user-friendly text - Access result.reason for programmatic handling - Publish failure events to the EventBus for policies to react

4. No Hidden Control Flow

The function signature tells the whole story. No surprise exceptions.

5. Composition with Explicit Checking

For chaining operations, explicit checking works well:

def create_and_apply(name, color, source_id, position, state):
    # First operation
    code_result = derive_create_code(name, color, state)
    if isinstance(code_result, CodeNotCreated):
        return code_result  # Pass through the failure event

    # Second operation
    segment_result = derive_apply_code_to_text(
        code_id=code_result.code_id,
        source_id=source_id,
        start=position.start,
        end=position.end,
        selected_text="...",
        memo=None,
        importance=0,
        owner=None,
        state=state,
    )

    return segment_result  # Could be SegmentCoded or SegmentNotCoded

Note: The OperationResult pattern from src/shared/common/operation_result.py is also available for command handlers that need to wrap success/failure outcomes.

Failure Events in QualCoder

Look at the failure events in src/contexts/coding/core/failure_events.py:

@dataclass(frozen=True)
class CodeNotCreated(FailureEvent):
    """Failure event: Code creation failed."""
    name: str | None = None
    category_id: CategoryId | None = None

    @classmethod
    def empty_name(cls) -> CodeNotCreated:
        return cls(event_type="CODE_NOT_CREATED/EMPTY_NAME", ...)

    @classmethod
    def duplicate_name(cls, name: str) -> CodeNotCreated:
        return cls(event_type="CODE_NOT_CREATED/DUPLICATE_NAME", name=name, ...)

@dataclass(frozen=True)
class CodeNotDeleted(FailureEvent):
    """Failure event: Code deletion failed."""
    code_id: CodeId | None = None
    reference_count: int = 0

    @classmethod
    def not_found(cls, code_id: CodeId) -> CodeNotDeleted:
        return cls(event_type="CODE_NOT_DELETED/NOT_FOUND", code_id=code_id, ...)

    @classmethod
    def has_references(cls, code_id: CodeId, count: int) -> CodeNotDeleted:
        return cls(event_type="CODE_NOT_DELETED/HAS_REFERENCES", code_id=code_id, reference_count=count, ...)

Each failure event: - Inherits from FailureEvent base class - Is a frozen dataclass (immutable) - Has factory methods for each failure reason - Carries relevant context (IDs, names, counts) - Has a message property for human-readable output - Has a reason property extracted from event_type - Can be published to the EventBus for policies to react

When to Use Each

Use failure events for: - Business rule violations (duplicate name, invalid priority) - Missing entities (code not found, category not found) - Invalid state transitions - Any failure that policies or UI might need to react to

Use exceptions for: - Programming errors (should never happen in production) - Infrastructure failures (database down, network error) - System-level issues (out of memory)

The domain layer returns failure events. The infrastructure/application layers handle exceptions.

Summary

Failure events (CodeCreated | CodeNotCreated) provide:

  • Explicit error handling - can't forget to check
  • Type-safe - errors are data, not exceptions
  • Pattern matchable - easy to handle different reasons
  • Publishable - failure events can be published to EventBus for policies
  • Rich context - carry IDs, names, and human-readable messages
  • No hidden control flow - signature tells all

Next Steps

Now let's trace how events flow from the domain to the UI.

Next: Part 4: Events Flow Through the System