# 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 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 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) 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, 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>; async fn find_by_owner(&self, owner_id: UserId) -> DomainResult>; async fn find_all(&self) -> DomainResult>; // ... } ``` ### Test Doubles Behind `test-helpers` feature flag: - **InMemory**: `InMemoryUserRepository`, `InMemoryChannelRepository`, etc. — `Mutex` 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 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 { 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, 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, pub user_query: Arc, pub channel_command: Arc, pub channel_query: Arc, pub schedule_command: Arc, pub schedule_query: Arc, pub library_command: Arc, pub library_query: Arc, // ... 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 { ... } } ``` 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.