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 stableBuilt-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.