Files
k-tv/docs/superpowers/specs/2026-07-12-backend-restructure-design.md

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 creation
  • from_persistence(...) — unchecked constructor for DB hydration
  • Getter methods for each field
  • No pub fields

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. — 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

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)

  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.