Architectural Philosophy: Building for Real-World Use

Introduction

FailExtract’s architecture emerged from a fundamental question: How do we build developer tools that are powerful enough for complex use cases while remaining simple enough for everyday use? This document explores the architectural philosophy that guided FailExtract’s design, the principles we discovered through development, and the real-world constraints that shaped our decisions.

Core Philosophical Principles

1. Respect User Context and Constraints

Principle: Every user operates within unique constraints - deployment restrictions, security policies, performance requirements, and skill levels. Our architecture should honor these constraints rather than forcing users to adapt to our assumptions.

Manifestation in FailExtract:

  • Zero-dependency core: Basic functionality works without any external dependencies

  • Optional enhancement layers: Advanced features available through extras without forcing adoption

  • Multiple performance profiles: Static mode for production, trace mode for debugging

  • Flexible deployment: Works in serverless, containers, or traditional environments

Real-World Impact: A data scientist running tests in a secure environment can use FailExtract without IT approval for additional dependencies, while a DevOps team can add rich formatting and CI integration when needed.

2. Progressive Enhancement Over Feature Completeness

Principle: It’s better to do one thing excellently and allow enhancement than to do many things adequately. Users should be able to start simple and grow sophisticated without architectural rewrites.

Traditional Approach (Rejected):

# Kitchen sink approach
failextract.extract(
    output="report.md",
    include_source=True,
    include_fixtures=True,
    include_coverage=True,
    include_dependencies=True,
    format_style="detailed",
    analytics_enabled=True,
    storage_backend="postgresql",
    notification_channels=["slack", "email"],
    ci_integration="github_actions"
)

FailExtract Approach:

# Start simple
@extract_on_failure
def test_something(): ...

# Enhance when needed
@extract_on_failure("report.json")
def test_something(): ...

# Full customization available
@extract_on_failure(OutputConfig(
    output="report.md", 
    format="markdown",
    include_fixtures=True
))
def test_something(): ...

Architectural Impact: This philosophy led to a layered architecture where each layer adds capability without changing the fundamental API. The core extraction engine remains stable while formatting, configuration, and integration layers evolve independently.

3. Explicit Over Implicit Complexity

Principle: When complexity is necessary, make it explicit and controllable. Don’t hide complexity behind “smart” defaults that users can’t understand or modify.

Example - Performance Trade-offs: Rather than a single “smart” mode that tries to balance performance and features, we expose explicit modes:

# Explicit performance choice
extractor = FailureExtractor(mode="static")    # <5% overhead
extractor = FailureExtractor(mode="profile")   # ~50% overhead, more data
extractor = FailureExtractor(mode="trace")     # ~300% overhead, complete data

Why This Matters: Performance characteristics affect production deployment decisions. Making trade-offs explicit allows informed decisions rather than mysterious performance surprises.

4. Fail Fast with Helpful Guidance

Principle: When something goes wrong, fail immediately with clear guidance on how to fix it. Don’t fail silently or with cryptic messages.

Implementation Pattern:

try:
    import yaml
except ImportError:
    raise ImportError(
        "PyYAML is required for YAML output format. "
        "Install it with: pip install failextract[formatters]"
    ) from None

Architectural Implication: Error handling becomes a first-class concern in API design. Every optional dependency, every configuration parameter, every integration point needs explicit error paths with actionable guidance.

Architectural Patterns and Their Rationale

1. Plugin Architecture for Extensibility

Pattern: Abstract base classes + registry pattern for formatters Rationale: Enable extension without modification of core code

class OutputFormatter(ABC):
    @abstractmethod
    def format(self, failures: List[Dict[str, Any]]) -> str: ...

class FormatterRegistry:
    _formatters = {
        OutputFormat.JSON: JSONFormatter(),
        OutputFormat.MARKDOWN: MarkdownFormatter(),
        # Custom formatters can be registered
    }

Why This Pattern:

  • Open/Closed Principle: Open for extension, closed for modification

  • Dependency Inversion: Core depends on abstractions, not concrete implementations

  • Testability: Each formatter can be tested independently

  • User Extension: Advanced users can add custom formatters without forking

Alternative Considered - Entry Points:

# setuptools entry points approach (rejected)
[console_scripts]
failextract_json = failextract.formatters:JSONFormatter
failextract_custom = my_package:CustomFormatter

Why Rejected: More complex packaging, harder for users to understand, discovery complexity.

2. Singleton Pattern for Global State

Pattern: Thread-safe singleton for failure collection Rationale: Need consistent global state across test sessions

class FailureExtractor:
    _instance = None
    _lock = threading.Lock()
    
    def __new__(cls):
        if cls._instance is None:
            with cls._lock:
                if cls._instance is None:
                    cls._instance = super().__new__(cls)
        return cls._instance

Why This Pattern:

  • Cross-cutting concern: Failure collection spans multiple test files

  • Simple API: Users don’t need to manage extractor instances

  • Session consistency: All failures collected in single session

  • Memory efficiency: One instance regardless of usage

Alternative Considered - Module Globals:

# Module-level approach (rejected)
_global_failures = []
_global_config = None

def extract_failure(failure_data):
    _global_failures.append(failure_data)

Why Rejected: Harder to test (can’t easily reset state), not object-oriented, harder to extend.

Alternative Considered - Dependency Injection:

# DI approach (rejected for complexity)
def test_something(failure_extractor: FailureExtractor):
    # Test implementation
    pass

Why Rejected: Breaks test framework conventions, requires explicit wiring, not transparent.

3. Layered Configuration Architecture

Pattern: Multiple configuration layers with clear precedence Architecture:

1. Defaults (built into code)
2. Configuration files (.failextract.toml)
3. Environment variables (FAILEXTRACT_*)
4. Explicit parameters (@extract_on_failure(config))

Implementation:

class OutputConfig:
    def __init__(self, 
                 output: Union[str, Path, OutputFormat] = None,
                 format: Union[str, OutputFormat] = None,
                 append: bool = False):
        # Layer resolution logic
        self.output = self._resolve_output(output)
        self.format = self._resolve_format(format, output)
        self.append = self._resolve_append(append)

Why This Layered Approach:

  • Flexibility: Users can configure at appropriate level (global vs per-test)

  • Predictability: Clear precedence rules prevent configuration surprises

  • Development vs Production: Different configuration strategies for different environments

  • Team Coordination: Shared configuration files for consistent team behavior

Design Decisions and Trade-offs

1. Modular Dependencies vs. Monolithic Package

Decision: Modular with optional extras Trade-offs Considered:

Approach

Pros

Cons

Monolithic

Simple packaging, all features available

Heavy dependencies, security surface, deployment restrictions

Modular Extras

Lightweight core, user choice

More packaging complexity, feature discovery

Micropackages

Maximum flexibility

Packaging overhead, coordination complexity

Chosen: Modular with extras Rationale: Balances simplicity with flexibility. Core use case (JSON output) has zero dependencies, while advanced features available when needed.

2. Static vs. Dynamic Feature Detection

Decision: Explicit import with helpful errors Implementation:

def __getattr__(name: str):
    """Dynamic import with helpful error messages."""
    if name == "YAMLFormatter":
        try:
            from .core.formatters.yaml import YAMLFormatter
            return YAMLFormatter
        except ImportError:
            raise ImportError(
                "YAMLFormatter requires PyYAML. "
                "Install with: pip install failextract[formatters]"
            ) from None
    raise AttributeError(f"module {__name__} has no attribute {name}")

Alternative Considered - Runtime Feature Detection:

# Runtime detection approach (rejected)
def get_available_formatters():
    formatters = ["json", "csv", "markdown"]
    try:
        import yaml
        formatters.append("yaml")
    except ImportError:
        pass
    return formatters

Why Rejected: Hides dependency requirements, harder to understand what’s available, complicates testing.

3. Performance Monitoring Strategy

Decision: Multiple performance profiles rather than universal optimization Profiles Implemented:

  • Static Mode: ~0% overhead, basic failure recording

  • Profile Mode: ~50% overhead, structured data collection

  • Trace Mode: ~300% overhead, complete execution tracing

Alternative Considered - Universal Optimization: Why Rejected: Impossible to optimize for all use cases simultaneously. Production environments need minimal overhead; debugging environments benefit from complete information.

Architectural Impact: This decision led to mode-based architecture where behavior changes significantly based on user intent, rather than one-size-fits-all approach.

Lessons from Development Experience

1. Documentation Drives Architecture

Discovery: Documentation honesty forced architectural clarity Before: Aspirational documentation describing desired features After: Reality-based documentation describing actual capabilities

Architectural Impact: When we committed to documenting only working features, it forced clear boundaries between core and optional functionality. This led to better separation of concerns and clearer APIs.

2. Test Organization Reflects System Architecture

Discovery: Test structure mirrors and reinforces system architecture Evolution: Large monolithic test files → Focused, modular test suites

Impact on Architecture:

  • Clear boundaries between components became evident through test organization

  • Integration points surfaced through test dependencies

  • Performance characteristics became measurable through dedicated performance tests

3. Performance Requirements Drive API Design

Discovery: Performance requirements aren’t optional - they fundamentally shape API design Example: The need for <5% overhead in production led to static mode, which required mode-based architecture rather than feature-flag architecture.

Architectural Lesson: Performance isn’t something you optimize later - it’s a constraint that shapes fundamental architectural decisions.

Anti-Patterns Avoided

1. Configuration Framework Complexity

Anti-pattern: Making configuration a framework unto itself Example of What We Avoided:

# Overly complex configuration (avoided)
failextract.configure({
    "extraction": {
        "modes": ["static", "profile"],
        "filters": {
            "include": ["tests/**"],
            "exclude": ["vendor/**"]
        }
    },
    "formatters": {
        "json": {"indent": 2, "sort_keys": True},
        "yaml": {"flow_style": False}
    },
    "integrations": {
        "ci": {"provider": "github", "token_env": "GITHUB_TOKEN"}
    }
})

What We Did Instead:

# Simple, focused configuration
@extract_on_failure(OutputConfig("report.json", format="json"))

2. Premature Abstraction

Anti-pattern: Abstracting before understanding patterns Example: We could have created abstract base classes for everything (Extractor, Analyzer, Processor, etc.) but instead waited until patterns emerged naturally.

Lesson: Abstraction should follow from concrete experience, not precede it.

3. Feature-Driven Architecture

Anti-pattern: Organizing around features rather than stable abstractions Example of What We Avoided:

failextract/
├── html_features/
├── analytics_features/  
├── ide_features/
└── ci_features/

What We Did Instead:

failextract/
├── core/           # Stable extraction logic
├── formatters/     # Stable output abstraction
├── integrations/   # Stable integration points
└── api/           # Stable public interfaces

Future Architectural Considerations

1. Scalability Boundaries

Current Architecture: Designed for single-process, single-machine testing Scaling Considerations:

  • Team Scale: Current architecture supports 2-4 developers effectively

  • Test Scale: Memory usage grows linearly with failure count

  • Feature Scale: Plugin architecture allows growth without core complexity

Future Decisions: If scaling beyond current boundaries, consider:

  • Streaming output for large failure sets

  • Distributed collection for multi-process testing

  • Service-oriented architecture for team-scale features

2. Extension Points

Designed Extension Points:

  • Custom formatters through OutputFormatter interface

  • Custom extractors through protocol interfaces

  • Custom integrations through plugin registration

Undesigned Extension Points (intentionally):

  • Core extraction logic (too complex for plugin architecture)

  • Configuration system (risk of over-abstraction)

  • Performance monitoring (too implementation-specific)

3. Backward Compatibility Strategy

Current Approach: Semantic versioning with explicit breaking change policy Key Compatibility Promises:

  • Core decorator API (@extract_on_failure) remains stable

  • Built-in formatter interfaces remain stable

  • Configuration format remains backward-compatible

Areas Where Breaking Changes Acceptable:

  • Internal APIs (anything starting with _)

  • Experimental features (clearly marked)

  • Performance characteristics (can change for optimization)

Conclusion

FailExtract’s architecture reflects a philosophy of respectful software design - respecting user constraints, respecting performance requirements, and respecting the complexity of real-world deployment scenarios. The architecture emerged from principled decisions about trade-offs rather than pursuit of technical elegance for its own sake.

The key insight is that architecture isn’t just about organizing code - it’s about organizing possibilities. Good architecture enables users to solve their specific problems without forcing them to adopt complexity they don’t need.

Core Architectural Success: Users can start with zero configuration and grow to sophisticated customization without rewriting their approach. The architecture supports this journey rather than demanding it.

Measure of Success: The architecture succeeds when users can solve their immediate problem quickly, and only discover the full power of the system when they need it.