ADR-014 — Alert System Extensibility
Status: Accepted
Date: 2026-02-19
Decision Makers: Lead Developer
Context: Bike Trip Planner MVP — Local-first bikepacking trip generator
Context and Problem Statement
The alert engine must be extensible to allow adding new alert rules without modifying existing code. Rules need to be discovered automatically, executed in a predictable priority order, and each independently unit-testable.
Decision
We use the Tagged Iterator pattern from Symfony DI to implement a Chain of Responsibility.
Architecture
Interface
// src/Analyzer/StageAnalyzerInterface.php
#[AutoconfigureTag('app.stage_analyzer')]
interface StageAnalyzerInterface
{
/** @return list<Alert> */
public function analyze(Stage $stage, array $context = []): array;
/** Lower value = higher priority (5 = critical, 100 = nudge) */
public static function getPriority(): int;
}
Registry
The AnalyzerRegistry collects all tagged services, sorts them by priority, and runs them in
order:
final class AnalyzerRegistry
{
public function __construct(
#[TaggedIterator('app.stage_analyzer')]
iterable $analyzers,
) { ... }
public function analyze(Stage $stage, array $context = []): list<Alert> { ... }
}
Priority conventions
| Priority | Use case | Example |
|---|---|---|
| 5 | Continuity | ContinuityAnalyzer |
| 10 | Critical terrain | ElevationAlertAnalyzer |
| 20 | Terrain warnings | SurfaceAlertAnalyzer, TrafficDangerAnalyzer |
| 100 | Nudges | LunchNudgeAnalyzer |
Context keys available to analyzers
| Key | Type | Available after |
|---|---|---|
nextStage |
?Stage |
Always (from Registry) |
tripDays |
int |
After GenerateStages |
startDate |
?\DateTimeImmutable |
After PATCH dates |
endDate |
?\DateTimeImmutable |
After PATCH dates |
osmPois |
array |
After ScanPois |
osmWays |
array |
After AnalyzeTerrain |
weatherData |
array |
After FetchWeather |
How to add a new alert rule
-
Create the class in
src/Analyzer/Rules/:namespace App\Analyzer\Rules; use App\Analyzer\StageAnalyzerInterface; use App\ApiResource\Model\Alert; use App\ApiResource\Model\Stage; use App\Enum\AlertType; final class MyNewAnalyzer implements StageAnalyzerInterface { public function analyze(Stage $stage, array $context = []): array { if ($someCondition) { return [new Alert(AlertType::Warning, 'Message actionnable', $lat, $lon)]; } return []; } public static function getPriority(): int { return 50; // 10=critical, 100=nudge } } -
That is all. Symfony autoconfiguration discovers the class automatically via
#[AutoconfigureTag]on the interface. -
Write the unit test in
tests/Analyzer/Rules/MyNewAnalyzerTest.php.
Conventions
- One analyzer = one file = one test.
- Naming:
{Concept}Analyzer.php+{Concept}AnalyzerTest.php. - Alert messages must be actionable — not "error detected" but "Unpaved section of 3km between km 45 and km 48".
- Always provide
lat/lonwhen possible for map rendering. - Use
AlertType::NUDGEfor informational suggestions,WARNINGfor things to prepare for,CRITICALfor genuine danger.
Consequences
- Adding a new rule requires only one new file and one new test.
- No modification to
AnalyzerRegistry,AnalyzeTerrainHandler, or any configuration file. - Rules are auto-discovered and sorted by priority at container compile time.
- All rules are independently unit-testable with a mock
Stage.