add restructure design spec + implementation plan

This commit is contained in:
2026-07-12 03:41:24 +02:00
parent f2f56b7143
commit a46ced4aca
2 changed files with 1779 additions and 0 deletions

View File

@@ -0,0 +1,393 @@
# K-TV Backend Restructure — Design Spec
## Goal
Restructure the k-tv backend from the current 3-crate layout (`domain/infra/api` inside `k-tv-backend/`) to a proper DDD hexagonal architecture matching the movies-diary reference project. The old code stays untouched; new code lives in `crates/` at the repo root.
## Decisions Made
| Decision | Choice |
|----------|--------|
| k-core dependency | Drop entirely, inline what's needed |
| MCP crate | Keep as separate crate under `crates/mcp` |
| Event system | Full event infrastructure (ports + adapter crates) |
| Adapter granularity | One crate per adapter |
| Entity encapsulation | Full — private fields, `new()`, `from_persistence()`, getters |
| ID types | Newtype IDs via `uuid_id!` macro |
| infra-wiring | Used only by presentation/mcp + adapters, NOT by application |
| Test doubles | InMemory + Noops (no Fakes or Panics) |
| Migration approach | Incremental bottom-up — old `k-tv-backend/` stays, new code in `crates/` |
## Workspace Layout
```
/mnt/drive/dev/k-tv/
Cargo.toml # NEW workspace root
crates/
domain/
application/
api-types/
infra-wiring/
presentation/
mcp/
adapters/
adapter-common/
sqlite/
postgres/
auth/
jellyfin/
local-files/
event-publisher/
k-tv-backend/ # OLD code (DEPRECATED, untouched)
k-tv-frontend/ # Frontend (untouched)
migrations_sqlite/ # Copied from k-tv-backend/migrations_sqlite/
```
## Dependency Graph
```
domain (zero I/O)
application (depends on domain only)
api-types (depends on domain only)
adapters/* (depend on domain, some on adapter-common, some on infra-wiring for DbPool)
presentation / mcp (depend on everything, wire it all together)
infra-wiring (DbPool enum, startup Config — used by presentation + adapters)
```
Application does NOT depend on infra-wiring. Use cases receive everything through ports and parameters.
## Domain Crate (`crates/domain/`)
Zero I/O dependencies. Contains all business logic, types, ports, and test doubles.
### Module Structure
```
src/
lib.rs
errors/
mod.rs # DomainError enum (#[non_exhaustive]), DomainResult<T>
events/
mod.rs # DomainEvent enum, EventEnvelope, AckHandle trait
models/
mod.rs
user.rs # User
channel.rs # Channel, ScheduleConfig, ProgrammingBlock, BlockContent
schedule.rs # GeneratedSchedule, ScheduledSlot, CurrentBroadcast
media.rs # MediaItem, PlaybackRecord
library.rs # LibraryItem, LibraryCollection, ShowSummary, SeasonSummary, LibrarySyncResult, LibrarySyncLogEntry
config_snapshot.rs # ChannelConfigSnapshot
activity.rs # ActivityEvent
provider_config.rs # ProviderConfigRow
collections.rs # PageParams, Paginated<T>
value_objects/
mod.rs # re-exports + uuid_id! macro
ids.rs # UserId, ChannelId, SlotId, BlockId, ScheduleId, MediaItemId (newtypes)
auth.rs # Email, Password, ValidationError
scheduling.rs # ContentType, MediaFilter, FillStrategy, RecyclePolicy, Weekday
channel.rs # AccessMode, LogoPosition
oidc.rs # IssuerUrl, ClientId, ClientSecret, RedirectUrl, etc.
search.rs # LibrarySearchFilter
ports/
mod.rs
auth.rs # AuthService (password hashing, JWT validation)
user.rs # UserCommand, UserQuery (CQRS split)
channel.rs # ChannelCommand, ChannelQuery
schedule.rs # ScheduleCommand, ScheduleQuery
library.rs # LibraryCommand, LibraryQuery, LibrarySyncAdapter
media.rs # IMediaProvider, IProviderRegistry, ProviderCapabilities, StreamingProtocol
events.rs # EventPublisher, EventConsumer, EventHandler
settings.rs # AppSettingsRepository
activity.rs # ActivityLogRepository
provider_config.rs # ProviderConfigRepository
transcode.rs # TranscodeSettingsRepository
services/
mod.rs
schedule/
mod.rs # ScheduleEngineService (generate_schedule, get_current_broadcast, get_epg)
fill.rs # fill_block(), fill_best_fit(), fill_sequential()
recycle.rs # apply_recycle_policy()
iptv.rs # generate_m3u(), generate_xmltv() pure functions
testing/
mod.rs # gated behind "test-helpers" feature
in_memory.rs # InMemory* repositories (Mutex<HashMap>)
noops.rs # NoopEventPublisher, NoopMediaProvider, etc.
```
### Entity Pattern
All entities have private fields with:
- `new(...)` — validated constructor for creation
- `from_persistence(...)` — unchecked constructor for DB hydration
- Getter methods for each field
- No `pub` fields
Example:
```rust
pub struct User { id: UserId, email: Email, ... }
impl User {
pub fn new(email: Email, password_hash: Option<String>, is_admin: bool) -> Self { ... }
pub fn from_persistence(id: UserId, ...) -> Self { ... }
pub fn id(&self) -> UserId { self.id }
pub fn email(&self) -> &Email { &self.email }
}
```
### ID Types
`uuid_id!` macro generates typed wrappers:
```rust
uuid_id!(UserId, ChannelId, SlotId, BlockId, ScheduleId);
```
Each generates a newtype around `Uuid` with `new()`, `Display`, `FromStr`, `Serialize`, `Deserialize`, `Hash`, `sqlx::Type`.
`MediaItemId` remains a manual newtype around `String` (not UUID-based).
### Port Design (CQRS)
Ports split into Command (write) and Query (read):
```rust
#[async_trait]
pub trait ChannelCommand: Send + Sync {
async fn save(&self, channel: &Channel) -> DomainResult<()>;
async fn delete(&self, id: ChannelId) -> DomainResult<()>;
async fn save_config_snapshot(&self, snapshot: &ChannelConfigSnapshot) -> DomainResult<()>;
// ...
}
#[async_trait]
pub trait ChannelQuery: Send + Sync {
async fn find_by_id(&self, id: ChannelId) -> DomainResult<Option<Channel>>;
async fn find_by_owner(&self, owner_id: UserId) -> DomainResult<Vec<Channel>>;
async fn find_all(&self) -> DomainResult<Vec<Channel>>;
// ...
}
```
### Test Doubles
Behind `test-helpers` feature flag:
- **InMemory**: `InMemoryUserRepository`, `InMemoryChannelRepository`, etc. — `Mutex<HashMap>` implementations of both Command and Query traits
- **Noops**: `NoopEventPublisher`, `NoopMediaProvider`, etc. — return `Ok(())` / empty results
## Application Crate (`crates/application/`)
Depends only on `domain`. One bounded context per subdirectory.
### Pattern Per Bounded Context
```
auth/
mod.rs # re-exports
deps.rs # AuthDeps { user_command, user_query, auth_service, event_publisher }
commands.rs # RegisterCommand, LoginCommand (plain structs, primitive fields)
queries.rs # GetCurrentUserQuery
register.rs # pub async fn execute(deps: &AuthDeps, cmd: RegisterCommand) -> DomainResult<User>
login.rs
logout.rs
refresh.rs
tests/ # per-use-case test files
```
### Bounded Contexts
- `auth/` — register, login, logout, refresh
- `channels/` — create, update, delete, get, list, list_by_owner
- `schedule/` — generate, get_active, get_current_broadcast, get_epg, get_stream_url, list_history
- `library/` — sync, search, list_collections, list_shows, list_seasons
- `config_snapshots/` — save, restore, list, patch_label
- `admin/` — update_settings, get_settings, activity_log
- `providers/` — upsert, delete, list
- `iptv/` — m3u, xmltv
### Use Case Signature
```rust
pub async fn execute(deps: &ChannelCommandDeps, cmd: CreateChannelCommand) -> DomainResult<Channel> {
let channel = Channel::new(
UserId::from(cmd.owner_id),
cmd.name,
cmd.timezone,
)?;
deps.channel_command.save(&channel).await?;
deps.event_publisher.publish(DomainEvent::ChannelCreated { id: channel.id() }).await?;
Ok(channel)
}
```
### Test Pattern
```rust
#[cfg(test)]
#[path = "tests/create.rs"]
mod tests;
// In tests/create.rs:
use domain::testing::{InMemoryChannelRepository, NoopEventPublisher};
#[tokio::test]
async fn creates_channel() {
let channels = Arc::new(InMemoryChannelRepository::new());
let events = Arc::new(NoopEventPublisher::new());
let deps = ChannelCommandDeps {
channel_command: channels.clone(),
channel_query: channels.clone(),
event_publisher: events,
};
let result = create::execute(&deps, CreateChannelCommand { ... }).await.unwrap();
assert_eq!(result.name(), "Test Channel");
}
```
## API Types Crate (`crates/api-types/`)
Depends only on `domain` and `utoipa`. Pure data types for HTTP request/response.
```
src/
lib.rs
auth.rs # LoginRequest, RegisterRequest, TokenResponse, UserResponse
channels.rs # CreateChannelRequest, UpdateChannelRequest, ChannelResponse
schedule.rs # ScheduleResponse, SlotResponse, CurrentBroadcastResponse
library.rs # LibraryItemResponse, CollectionResponse, ShowResponse
admin.rs # SettingsResponse, ActivityEventResponse
config.rs # ConfigResponse, ProviderInfo
providers.rs # ProviderConfigRequest, ProviderConfigResponse
transcode.rs # TranscodeSettingsResponse, TranscodeStatsResponse
common.rs # PaginatedResponse<T>, ErrorResponse
```
All structs: `#[derive(Serialize, Deserialize, utoipa::ToSchema)]`.
## Adapter Crates (`crates/adapters/`)
Each adapter is a standalone crate implementing domain ports.
| Crate | Depends on | Implements | Entry point |
|-------|-----------|------------|-------------|
| `adapter-common` | `domain` | Shared helpers: `map_sqlx_error()`, row converters | N/A |
| `sqlite` | `domain`, `adapter-common`, `infra-wiring` | All `*Command`/`*Query` ports | `wire(pool) -> SqliteOutput` |
| `postgres` | `domain`, `adapter-common`, `infra-wiring` | Same ports for Postgres | `wire(pool) -> PostgresOutput` |
| `auth` | `domain` | `AuthService` (JWT, OIDC) | `JwtAuthService::new()`, `OidcService::new()` |
| `jellyfin` | `domain` | `IMediaProvider` | `JellyfinMediaProvider::new()` |
| `local-files` | `domain`, `infra-wiring` | `IMediaProvider`, `LocalIndex`, `TranscodeManager` | `LocalFilesBundle::new()` |
| `event-publisher` | `domain` | `EventPublisher`, `EventConsumer` | `ChannelEventBus::new()` |
### SQLite Wire Output
```rust
pub struct SqliteWireOutput {
pub user_command: Arc<dyn UserCommand>,
pub user_query: Arc<dyn UserQuery>,
pub channel_command: Arc<dyn ChannelCommand>,
pub channel_query: Arc<dyn ChannelQuery>,
pub schedule_command: Arc<dyn ScheduleCommand>,
pub schedule_query: Arc<dyn ScheduleQuery>,
pub library_command: Arc<dyn LibraryCommand>,
pub library_query: Arc<dyn LibraryQuery>,
// ... all ports
}
```
## Infra-Wiring Crate (`crates/infra-wiring/`)
Minimal crate, breaks dependency cycles:
```rust
pub enum DbPool {
Sqlite(SqlitePool),
Postgres(PgPool),
}
pub struct Config { /* 30+ env var fields, startup config only */ }
impl Config {
pub fn from_env() -> Result<Self, ConfigError> { ... }
}
```
Used by: `presentation`, `mcp`, database adapter crates. NOT by `application`.
## Presentation Crate (`crates/presentation/`)
Main HTTP binary. Axum-based.
```
src/
main.rs # Startup wiring
state.rs # AppState
context.rs # AppContext { repositories, services }
factory.rs # Builds adapters, constructs AppContext
routes.rs # Router construction
errors.rs # ApiError → HTTP response
extractors.rs # CurrentUser, AdminUser, OptionalCurrentUser
openapi/
mod.rs # Merged OpenAPI doc via utoipa
auth.rs, channels.rs, schedule.rs, library.rs, admin.rs, config.rs, iptv.rs
handlers/
auth.rs, channels.rs, schedule.rs, library.rs, admin.rs, config.rs, files.rs, iptv.rs
mappers/
mod.rs
auth.rs, channels.rs, schedule.rs, library.rs, admin.rs
background/
mod.rs
library_sync.rs
auto_scheduler.rs
broadcast_poller.rs
webhook_consumer.rs
```
Handlers: receive HTTP → map to application command/query → call `execute()` → map result to api-types response.
## MCP Crate (`crates/mcp/`)
Separate binary, stdio transport. Calls application use cases (not domain services directly).
```
src/
main.rs # DB setup, wiring, stdio transport
server.rs # KTvMcpServer, tool dispatch
error.rs # MCP error types
tools/
channels.rs, library.rs, schedule.rs
```
## Migration Order (Bottom-Up, Bounded-Context-at-a-Time)
1. **Domain foundation** — errors, value objects (ids, auth, scheduling, channel), `uuid_id!` macro
2. **Domain models** — User, Channel, ScheduleConfig, ProgrammingBlock, BlockContent, MediaItem
3. **Domain models (continued)** — GeneratedSchedule, ScheduledSlot, CurrentBroadcast, LibraryItem, remaining models
4. **Domain ports** — all port traits (CQRS split)
5. **Domain services** — ScheduleEngineService, fill strategies, recycle policy, IPTV
6. **Domain events** — DomainEvent, EventEnvelope, EventPublisher/Consumer/Handler ports
7. **Domain testing** — InMemory repos, Noops
8. **Application: auth** — deps, commands, queries, use cases, tests
9. **Application: channels** — deps, commands, queries, use cases, tests
10. **Application: schedule** — deps, commands, queries, use cases, tests
11. **Application: library** — deps, commands, queries, use cases, tests
12. **Application: remaining** — config_snapshots, admin, providers, iptv
13. **API types** — all request/response DTOs with utoipa derives
14. **Infra-wiring** — DbPool, Config
15. **Adapter: adapter-common** — shared helpers
16. **Adapter: sqlite** — all repo implementations, wire function
17. **Adapter: postgres** — all repo implementations
18. **Adapter: auth** — JWT + OIDC
19. **Adapter: jellyfin** — media provider
20. **Adapter: local-files** — media provider, index, transcoder
21. **Adapter: event-publisher** — broadcast channel bus
22. **Presentation** — handlers, mappers, routes, extractors, openapi, background tasks, main
23. **MCP** — tools, server, main
24. **Cleanup** — deprecate `k-tv-backend/`, update compose files, CI
Each step must compile and (where applicable) pass tests before moving to the next.