Part 0: The Big Picture¶
Before diving into code, let's understand why QualCoder v2 uses this architecture.
The Problem: Why Traditional Approaches Fail¶
QualCoder is a qualitative data analysis tool with complex requirements:
1. Complex Validation Rules¶
Consider creating a new Code: - Name can't be empty - Name must be unique (case-insensitive) - If assigning to a category, that category must exist - Color must have valid RGB values
These rules interact. Testing them in a traditional Code.create() method means:
- Mocking the database for uniqueness checks
- Setting up category fixtures
- Testing each combination of valid/invalid inputs
2. Real-Time UI Updates¶
When an AI agent creates a Code in a background thread: - The codebook tree must update immediately - The activity feed must show what happened - Other views might need to refresh
Traditional approaches scatter this logic: - Observer patterns couple components - Signals/slots become spaghetti - Race conditions lurk in threading
3. Testing is Painful¶
To test "creating a code with a duplicate name fails":
Traditional approach:
def test_duplicate_name_rejected():
# Setup database connection
db = setup_test_database()
# Create existing code
repo = CodeRepository(db)
repo.save(Code(name="Theme A", ...))
# Create service with dependencies
service = CodeService(repo, event_bus, ...)
# Finally test
with pytest.raises(DuplicateNameError):
service.create_code("Theme A", ...)
With fDDD:
def test_duplicate_name_rejected():
state = CodingState(existing_codes=(Code(name="Theme A", ...),))
result = derive_create_code("Theme A", ..., state=state)
assert isinstance(result, CodeNotCreated)
assert result.reason == "DUPLICATE_NAME"
No database. No mocks. Just data in, data out.
The Solution: Functional Core / Imperative Shell¶
The architecture separates pure logic from side effects:
graph TB
subgraph Presentation ["Presentation Layer (PySide6 Widgets)"]
UI[UI Widgets]
UI_DESC["• Receives SignalPayloads<br/>• Renders UI<br/>• Captures user input"]
end
subgraph Application ["Application Layer (EventBus, SignalBridge)"]
APP[Controllers & Bridges]
APP_DESC["• Routes domain events<br/>• Converts events → UI payloads<br/>• Handles threading"]
end
subgraph Domain ["Domain Layer (Pure Functions)"]
DOM[Invariants, Derivers, Events]
DOM_DESC["• PURE FUNCTIONS - no I/O<br/>• All business rules live here<br/>• Fully testable in isolation"]
end
subgraph Infrastructure ["Infrastructure Layer (Repositories)"]
INFRA[Repositories & Adapters]
INFRA_DESC["• Database access<br/>• File I/O<br/>• External services"]
end
INFRA -->|Data| Domain
Domain -->|Domain Events| Application
Application -->|Qt Signals| Presentation
Key insight: The Domain Layer is a "pure functional core" - given the same inputs, it always produces the same outputs. Side effects (database writes, UI updates) happen at the edges.
The 5 Building Blocks¶
graph LR
subgraph "Domain Layer"
INV[Invariants]
DER[Derivers]
EVT[Events]
end
subgraph "Application Layer"
BUS[EventBus]
BRG[SignalBridge]
end
INV -->|validate| DER
DER -->|produce| EVT
EVT -->|publish to| BUS
BUS -->|route to| BRG
BRG -->|emit| SIG[Qt Signals]
1. Invariants¶
Pure predicate functions that validate business rules.
def is_valid_code_name(name: str) -> bool:
"""Name must be non-empty and <= 100 chars."""
return is_non_empty_string(name) and is_within_length(name, 1, 100)
Properties:
- Take data, return bool
- No side effects
- Named is_* or can_*
2. Derivers¶
Pure functions that compose invariants and derive events.
def derive_create_code(name, color, state) -> CodeCreated | CodeNotCreated:
if not is_valid_code_name(name):
return CodeNotCreated.empty_name()
if not is_code_name_unique(name, state.existing_codes):
return CodeNotCreated.duplicate_name(name)
return CodeCreated.create(name=name, color=color, ...)
Properties:
- Take command + state, return success event or failure event
- Compose multiple invariants
- Pattern: (command, state) -> SuccessEvent | FailureEvent
3. Events¶
Immutable records of things that happened.
Properties:
- Past tense naming (CodeCreated, not CreateCode)
- Immutable (frozen dataclass)
- Carry all data needed by subscribers
4. EventBus¶
Pub/sub infrastructure for domain events.
# Subscribe
bus.subscribe("coding.code_created", handle_code_created)
# Publish
bus.publish(CodeCreated(...))
Properties: - Decouples publishers from subscribers - Type-based or string-based subscription - Thread-safe
5. SignalBridge¶
Converts domain events to Qt signals.
class CodingSignalBridge(BaseSignalBridge):
code_created = Signal(object) # Emits CodeCreatedPayload
def _register_converters(self):
self.register_converter(
"coding.code_created",
CodeCreatedConverter(),
"code_created"
)
Properties: - Bridges background threads to Qt main thread - Converts domain events to UI-friendly payloads - One bridge per bounded context
How They Work Together¶
When a user clicks "Create Code":
sequenceDiagram
participant UI as UI Widget
participant CH as Command Handler
participant D as Deriver
participant R as Repository
participant EB as EventBus
participant SB as SignalBridge
participant TV as TreeView
UI->>CH: create_code("Theme A", color)
CH->>R: get_all()
R-->>CH: existing_codes
CH->>D: derive_create_code(name, color, state)
Note over D: Validates using invariants:<br/>is_valid_code_name() ✓<br/>is_code_name_unique() ✓
D-->>CH: CodeCreated event
CH->>R: save(event)
CH->>EB: publish(event)
EB->>SB: _on_code_created(event)
Note over SB: Convert to payload
SB->>TV: code_created.emit(payload)
TV->>TV: Update tree view
Note: In QualCoder v2, "Command Handlers" live in
src/contexts/{context}/core/commandHandlers/. Each handler is a standalone function (not a class method) that orchestrates: build state → call deriver → persist → publish event.
Next Steps¶
Now that you understand the big picture, let's write your first invariant.