14 KiB
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 creationfrom_persistence(...)— unchecked constructor for DB hydration- Getter methods for each field
- No
pubfields
Example:
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:
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):
#[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. — returnOk(())/ 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, refreshchannels/— create, update, delete, get, list, list_by_ownerschedule/— generate, get_active, get_current_broadcast, get_epg, get_stream_url, list_historylibrary/— sync, search, list_collections, list_shows, list_seasonsconfig_snapshots/— save, restore, list, patch_labeladmin/— update_settings, get_settings, activity_logproviders/— upsert, delete, listiptv/— m3u, xmltv
Use Case Signature
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
#[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
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:
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)
- Domain foundation — errors, value objects (ids, auth, scheduling, channel),
uuid_id!macro - Domain models — User, Channel, ScheduleConfig, ProgrammingBlock, BlockContent, MediaItem
- Domain models (continued) — GeneratedSchedule, ScheduledSlot, CurrentBroadcast, LibraryItem, remaining models
- Domain ports — all port traits (CQRS split)
- Domain services — ScheduleEngineService, fill strategies, recycle policy, IPTV
- Domain events — DomainEvent, EventEnvelope, EventPublisher/Consumer/Handler ports
- Domain testing — InMemory repos, Noops
- Application: auth — deps, commands, queries, use cases, tests
- Application: channels — deps, commands, queries, use cases, tests
- Application: schedule — deps, commands, queries, use cases, tests
- Application: library — deps, commands, queries, use cases, tests
- Application: remaining — config_snapshots, admin, providers, iptv
- API types — all request/response DTOs with utoipa derives
- Infra-wiring — DbPool, Config
- Adapter: adapter-common — shared helpers
- Adapter: sqlite — all repo implementations, wire function
- Adapter: postgres — all repo implementations
- Adapter: auth — JWT + OIDC
- Adapter: jellyfin — media provider
- Adapter: local-files — media provider, index, transcoder
- Adapter: event-publisher — broadcast channel bus
- Presentation — handlers, mappers, routes, extractors, openapi, background tasks, main
- MCP — tools, server, main
- Cleanup — deprecate
k-tv-backend/, update compose files, CI
Each step must compile and (where applicable) pass tests before moving to the next.