This document contains the detailed log of key architectural decisions made for FocusFlow, including rationale, consequences, and code examples.
Decision: Use Onion Architecture (Clean Architecture variant)
Rationale:
- Domain layer has zero dependencies (pure business logic)
- Easier to test (mock infrastructure)
- Framework-agnostic domain layer
- Enforces dependency inversion (infrastructure depends on domain, not vice versa)
Consequences:
✅ Better testability (~95% domain coverage)
✅ Business logic isolated from infrastructure changes
Decision: Separate Commands (writes) from Queries (reads) using MediatR
Rationale:
- Single Responsibility - Each handler does one thing
- Testability - Mock
IMediatorinstead of 10+ service methods - Performance - Queries can bypass domain validation
- Clarity -
CreateProjectCommandvsGetAllProjectsQueryis self-documenting
Consequences:
✅ 100+ handlers, each <50 lines
✅ Easy to add new features (just add handler)
Example:
// Command (write)
public record CreateProjectCommand(string Name, DateTime StartDate) : IRequest<Result<ProjectDto>>;
// Query (read)
public record GetAllProjectsQuery(string UserId) : IRequest<Result<List<ProjectDto>>>;Decision: Organize by feature (Tasks/, Projects/) instead of technical layer (Commands/, Queries/)
Rationale:
- Cohesion - All "Create Task" artifacts in one folder
- Discoverability - Easy to find related code
- Team scalability - Features can be developed independently
Structure:
Tasks/
Commands/
CreateTaskCommand.cs
CreateTaskCommandHandler.cs
CreateTaskCommandValidator.cs
Queries/
GetTaskByIdQuery.cs
GetTaskByIdQueryHandler.cs
Dtos/
TaskDto.cs
Decision: Prefix all domain exceptions with FocusFlow (e.g., FocusFlowNotFoundException)
Rationale:
- Explicit - No confusion with framework exceptions
- Searchable - Easy to find in logs
- Branding - Clear ownership of exception types
Consequences:
✅ No namespace conflicts
✅ Global exception handler easily maps to HTTP status codes
Exception Hierarchy:
FocusFlowException (base)
├── FocusFlowValidationException → 400 Bad Request
├── FocusFlowBusinessRuleException → 422 Unprocessable Entity
├── FocusFlowNotFoundException → 404 Not Found
└── FocusFlowUnauthorizedException → 403 Forbidden
Decision: Use FluentValidation for all validation logic
Rationale:
- Testable - Validators are POCO classes
- Expressive -
RuleFor(x => x.EndDate).GreaterThan(x => x.StartDate) - Reusable - Compose validators
- Complex rules - Cross-property, async DB checks
Consequences:
✅ Validation lives in Application layer (not Domain)
✅ MediatR pipeline runs all validators automatically
Example:
public class CreateProjectCommandValidator : AbstractValidator<CreateProjectCommand>
{
public CreateProjectCommandValidator()
{
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Project name is required")
.MaximumLength(200);
RuleFor(x => x.EndDate)
.GreaterThan(x => x.StartDate)
.When(x => x.EndDate.HasValue);
}
}Decision: Use AutoMapper for Entity↔DTO mapping
Rationale:
- DRY - Define mapping once, use everywhere
- Convention-based - Automatically maps properties with same name
- Testable -
AssertConfigurationIsValid()
Consequences:
✅ Eliminates 500+ lines of manual mapping code
✅ Profiles document transformations
Decision: Use Fluxor (Redux pattern) instead of Blazor's built-in state containers
Rationale:
- Predictable - Single source of truth
- DevTools - Redux DevTools support (time-travel debugging)
- Testable - Reducers are pure functions
- Scalable - Clear action → effect → reducer flow
Consequences:
✅ Easier to debug state changes
✅ Supports complex async workflows (e.g., optimistic updates)
Flow:
Component → Dispatch(CreateProjectAction)
→ CreateProjectEffect (async API call)
→ Dispatch(CreateProjectSuccessAction)
→ ProjectReducer (updates state)
→ Component re-renders
Decision: Use MudBlazor over Bootstrap or custom components
Rationale:
- Rich components - DataGrid, DatePicker, Autocomplete, Dialogs
- Material Design - Modern, consistent UI
- Accessibility - ARIA attributes built-in
- Active maintenance - 7k+ GitHub stars
Consequences:
✅ Faster UI development
✅ Responsive grid system
Decision: Use Playwright over Selenium
Rationale:
- Auto-wait - No
Thread.Sleep()or manual waits - Cross-browser - Chromium, Firefox, WebKit
- Video/screenshot - Automatic on test failure
- Modern API - Async/await, auto-retry
Consequences:
✅ Reliable E2E tests (no flakiness)
✅ Debugging with video recordings
Decision: Use PostgreSQL as the primary database
Rationale:
- Open-source - No licensing costs
- Docker-friendly - Official image, easy setup
- JSON support - Native JSONB type (future analytics)
- Performance - Better concurrency with MVCC
Consequences:
✅ Easy local development (Docker)
✅ Cloud-agnostic (AWS RDS, Azure PostgreSQL, etc.)
IDENTITY, uses SERIAL)
Decision: Use JWT tokens for API authentication (not session cookies)
Rationale:
- Stateless - No server-side session storage
- Scalable - Works across multiple API instances
- Mobile-friendly - Easy to use in non-browser clients
- Claims-based - Roles embedded in token
Consequences:
✅ Blazor app stores JWT in LocalStorage
✅ API validates token signature (no DB lookup per request)
Decision: Provide docker-compose.yml as the primary local dev environment
Rationale:
- Consistency - Same environment for all developers
- Database included - No manual PostgreSQL setup
- CI/CD parity - Mirrors production deployment
- Fast onboarding -
docker-compose up= working app
Consequences:
✅ New developers productive in <5 minutes
✅ Tests run against real database (not in-memory SQLite)
Decision: Run Docker containers with HTTP (not HTTPS) in development
Rationale:
- Simplicity - No certificate setup for first-time users
- Faster - No HTTPS overhead
- Localhost - Browsers allow HTTP on localhost
Consequences:
✅ docker-compose up works immediately
✅ No certificate errors
scripts/setup-dev-certs.ps1 for cert generation)
Decision: Use Repository + UnitOfWork pattern despite EF Core's DbContext already being a UoW
Rationale:
- Testability - Mock
IProjectRepositoryinstead of DbContext - Abstraction - Business logic doesn't know about EF Core
- Complex queries - Encapsulate in repository methods
Consequences:
✅ Easy to test Application layer (mock repositories)
✅ Could swap EF Core for Dapper/ADO.NET without changing handlers
Decision: Use bUnit for testing Blazor components
Rationale:
- In-memory - No browser needed
- Fast - ~100ms per test
- Queries - Find elements like
Find("button.save") - Event simulation - Click, input, etc.
Consequences:
✅ Tests Blazor components in isolation
✅ Catches UI bugs before E2E tests
Decision: Use Testcontainers to orchestrate real Docker containers (PostgreSQL + API + Blazor) for E2E tests
Rationale:
- Real environment - Tests against actual PostgreSQL, not in-memory SQLite
- Container parity - Same Docker images used in production
- Isolation - Fresh database per test suite
- Networking - Tests inter-container communication (API ↔ DB, Blazor ↔ API)
- DataProtection - Validates shared key scenarios between API and Blazor
Consequences:
✅ E2E tests catch Docker configuration issues
✅ No "works on my machine" - consistent test environment
✅ Tests real database migrations and seeding
Implementation:
// E2ETestEnvironment orchestrates 3 containers:
// 1. PostgreSQL (fresh DB per test suite)
// 2. API container (focusflow-api:test image)
// 3. Blazor container (focusflow-client:test image)
// + Custom Docker network for inter-container communication
// + Shared DataProtection keys volumeDecision: Use SignalR for real-time bi-directional communication (Server → Client). Rationale:
- Abstraction - Handles WebSockets/Long Polling automatically.
- Groups - Easy to broadcast to specific users (e.g., Project Members).
- Integration - Native ASP.NET Core support.
Consequences:
✅ Instant updates across multiple tabs/users.
✅ Reduced need for manual refreshing.
Bridge Pattern (SignalR → Fluxor):
// SignalR Listener intercepts the event
hubConnection.On<TaskCreatedNotification>("TaskCreated", notification =>
{
// Dispatches it as a standard Fluxor Action
dispatcher.Dispatch(new TaskCreatedAction(notification.Task));
});