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

46 KiB

K-TV Backend Restructure Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Restructure k-tv-backend from 3-crate layout (domain/infra/api) to proper DDD hexagonal architecture with crates/ directory, matching movies-diary reference project.

Architecture: Bottom-up migration — build new crates under /mnt/drive/dev/k-tv/crates/, layer by layer: domain → application → api-types → infra-wiring → adapters → presentation → mcp. Old k-tv-backend/ stays untouched as reference. Each task must compile before moving to the next.

Tech Stack: Rust (edition 2024), Axum 0.8, SQLx, Tokio, Serde, chrono-tz, utoipa (OpenAPI), async-trait, thiserror, uuid

Global Constraints

  • Rust edition 2024 for all crates
  • No k-core dependency — inline what's needed
  • Domain crate: zero I/O deps (no sqlx, no reqwest, no tokio runtime)
  • All entity fields private with new(), from_persistence(), getters
  • ID types are newtypes via uuid_id! macro (not type aliases)
  • Ports split CQRS: separate Command (write) and Query (read) traits
  • Application use cases: free async fn execute(deps, cmd) functions
  • Test doubles (InMemory + Noops) in domain behind test-helpers feature
  • Tests in sibling tests/ directories via #[cfg(test)] #[path = "tests/foo.rs"] mod tests;
  • API types use utoipa::ToSchema for OpenAPI generation
  • Reference code lives at /mnt/drive/dev/k-tv/k-tv-backend/ — read it, don't modify it
  • Reference architecture lives at /mnt/drive/dev/movies-diary/ — follow its patterns
  • When creating a new crate, add it to [workspace] members in root Cargo.toml
  • Old k-tv-backend/ has its own Cargo.toml workspace — do NOT conflict with the new root workspace

Task 1: Workspace scaffold + domain foundation (errors, uuid_id macro, ID types)

Files:

  • Create: Cargo.toml (workspace root at /mnt/drive/dev/k-tv/Cargo.toml)
  • Create: crates/domain/Cargo.toml
  • Create: crates/domain/src/lib.rs
  • Create: crates/domain/src/errors/mod.rs
  • Create: crates/domain/src/value_objects/mod.rs
  • Create: crates/domain/src/value_objects/ids.rs

Interfaces:

  • Produces: DomainError, DomainResult<T>, uuid_id! macro, UserId, ChannelId, SlotId, BlockId, ScheduleId, MediaItemId

  • Step 1: Create workspace root Cargo.toml

Create /mnt/drive/dev/k-tv/Cargo.toml. Note: start with only crates/domain as a member. Add other members as their crates are created in later tasks. The glob crates/adapters/* only works once adapter dirs exist.

[workspace]
members = ["crates/domain"]
exclude = ["k-tv-backend", "k-tv-frontend"]
resolver = "2"

[workspace.dependencies]
async-trait = "0.1"
chrono = { version = "0.4", features = ["serde"] }
chrono-tz = { version = "0.10", features = ["serde"] }
email_address = "0.2"
rand = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
url = { version = "2.5", features = ["serde"] }
uuid = { version = "1", features = ["v4", "serde"] }
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "macros", "chrono", "uuid"] }
axum = { version = "0.8" }
axum-extra = { version = "0.10" }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
reqwest = { version = "0.12", features = ["json"] }
utoipa = { version = "5", features = ["chrono", "uuid"] }
jsonwebtoken = "9"

# Internal crates
domain = { path = "crates/domain" }
application = { path = "crates/application" }
api-types = { path = "crates/api-types" }
infra-wiring = { path = "crates/infra-wiring" }
adapter-common = { path = "crates/adapters/adapter-common" }
adapter-sqlite = { path = "crates/adapters/sqlite" }
adapter-postgres = { path = "crates/adapters/postgres" }
adapter-auth = { path = "crates/adapters/auth" }
adapter-jellyfin = { path = "crates/adapters/jellyfin" }
adapter-local-files = { path = "crates/adapters/local-files" }
adapter-event-publisher = { path = "crates/adapters/event-publisher" }
  • Step 2: Create domain crate Cargo.toml

Create crates/domain/Cargo.toml:

[package]
name = "domain"
version = "0.1.0"
edition = "2024"

[features]
test-helpers = []

[dependencies]
async-trait = { workspace = true }
chrono = { workspace = true }
chrono-tz = { workspace = true }
email_address = { workspace = true }
rand = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
url = { workspace = true }
uuid = { workspace = true }

[dev-dependencies]
tokio = { workspace = true }
  • Step 3: Create errors module

Create crates/domain/src/errors/mod.rs — copy from k-tv-backend/domain/src/errors.rs but change Uuid params to use the new ID newtypes where applicable. For now, keep Uuid since IDs aren't defined yet:

use thiserror::Error;
use uuid::Uuid;

#[derive(Debug, Error)]
#[non_exhaustive]
pub enum DomainError {
    #[error("User not found: {0}")]
    UserNotFound(Uuid),

    #[error("User already exists: {0}")]
    UserAlreadyExists(String),

    #[error("Channel not found: {0}")]
    ChannelNotFound(Uuid),

    #[error("No active schedule for channel: {0}")]
    NoActiveSchedule(Uuid),

    #[error("Validation error: {0}")]
    ValidationError(String),

    #[error("Invalid timezone: {0}")]
    TimezoneError(String),

    #[error("Unauthenticated: {0}")]
    Unauthenticated(String),

    #[error("Forbidden: {0}")]
    Forbidden(String),

    #[error("Repository error: {0}")]
    RepositoryError(String),

    #[error("Infrastructure error: {0}")]
    InfrastructureError(String),
}

impl DomainError {
    pub fn validation(message: impl Into<String>) -> Self {
        Self::ValidationError(message.into())
    }

    pub fn unauthenticated(message: impl Into<String>) -> Self {
        Self::Unauthenticated(message.into())
    }

    pub fn forbidden(message: impl Into<String>) -> Self {
        Self::Forbidden(message.into())
    }

    pub fn is_not_found(&self) -> bool {
        matches!(self, DomainError::UserNotFound(_) | DomainError::ChannelNotFound(_))
    }

    pub fn is_conflict(&self) -> bool {
        matches!(self, DomainError::UserAlreadyExists(_))
    }
}

pub type DomainResult<T> = Result<T, DomainError>;
  • Step 4: Create uuid_id macro and ID types

Create crates/domain/src/value_objects/ids.rs:

use uuid::Uuid;

macro_rules! uuid_id {
    ($name:ident) => {
        #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
        pub struct $name(Uuid);

        impl $name {
            pub fn generate() -> Self {
                Self(Uuid::new_v4())
            }

            pub fn from_uuid(uuid: Uuid) -> Self {
                Self(uuid)
            }

            pub fn value(&self) -> Uuid {
                self.0
            }
        }

        impl std::fmt::Display for $name {
            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
                write!(f, "{}", self.0)
            }
        }

        impl std::str::FromStr for $name {
            type Err = uuid::Error;
            fn from_str(s: &str) -> Result<Self, Self::Err> {
                Ok(Self(s.parse()?))
            }
        }

        impl From<Uuid> for $name {
            fn from(uuid: Uuid) -> Self {
                Self(uuid)
            }
        }
    };
}

pub(crate) use uuid_id;

uuid_id!(UserId);
uuid_id!(ChannelId);
uuid_id!(SlotId);
uuid_id!(BlockId);
uuid_id!(ScheduleId);

/// Opaque media item identifier — format is provider-specific.
/// The domain never inspects the string; it just passes it back to the provider.
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct MediaItemId(String);

impl MediaItemId {
    pub fn new(value: impl Into<String>) -> Self {
        Self(value.into())
    }

    pub fn into_inner(self) -> String {
        self.0
    }

    pub fn value(&self) -> &str {
        &self.0
    }
}

impl AsRef<str> for MediaItemId {
    fn as_ref(&self) -> &str {
        &self.0
    }
}

impl std::fmt::Display for MediaItemId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.0)
    }
}

impl From<String> for MediaItemId {
    fn from(s: String) -> Self {
        Self(s)
    }
}

impl From<&str> for MediaItemId {
    fn from(s: &str) -> Self {
        Self(s.to_string())
    }
}
  • Step 5: Create value_objects mod.rs

Create crates/domain/src/value_objects/mod.rs:

pub mod ids;

pub use ids::*;
  • Step 6: Create lib.rs

Create crates/domain/src/lib.rs:

pub mod errors;
pub mod value_objects;

pub use errors::{DomainError, DomainResult};
pub use value_objects::*;
  • Step 7: Verify it compiles

Run: cargo check -p domain Expected: compiles with no errors

  • Step 8: Commit
git add Cargo.toml crates/domain/
git commit -m "scaffold workspace + domain foundation (errors, ids, uuid_id macro)"

Files:

  • Create: crates/domain/src/value_objects/auth.rs
  • Create: crates/domain/src/value_objects/scheduling.rs
  • Create: crates/domain/src/value_objects/channel.rs
  • Create: crates/domain/src/value_objects/oidc.rs
  • Create: crates/domain/src/value_objects/search.rs
  • Modify: crates/domain/src/value_objects/mod.rs
  • Modify: crates/domain/src/errors/mod.rs

Interfaces:

  • Consumes: DomainError, DomainResult, ID types from Task 1

  • Produces: Email, Password, ValidationError, ContentType, MediaFilter, FillStrategy, RecyclePolicy, Weekday, AccessMode, LogoPosition, OIDC newtypes, LibrarySearchFilter

  • Step 1: Copy and adapt auth.rs

Copy from k-tv-backend/domain/src/value_objects/auth.rs into crates/domain/src/value_objects/auth.rs. Keep identical — ValidationError, Email, Password with all impls and tests.

  • Step 2: Add From<ValidationError> to errors

Add to crates/domain/src/errors/mod.rs:

impl From<crate::value_objects::auth::ValidationError> for DomainError {
    fn from(error: crate::value_objects::auth::ValidationError) -> Self {
        DomainError::ValidationError(error.to_string())
    }
}
  • Step 3: Copy and adapt scheduling.rs

Copy from k-tv-backend/domain/src/value_objects/scheduling.rs. Move MediaItemId, AccessMode, and LogoPosition OUT (they have their own files now). Keep: ContentType, MediaFilter, FillStrategy, RecyclePolicy, Weekday with all impls and tests.

  • Step 4: Create channel.rs

Create crates/domain/src/value_objects/channel.rs with AccessMode and LogoPosition (moved from scheduling.rs in old code):

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AccessMode {
    #[default]
    Public,
    PasswordProtected,
    AccountRequired,
    OwnerOnly,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum LogoPosition {
    TopLeft,
    #[default]
    TopRight,
    BottomLeft,
    BottomRight,
}
  • Step 5: Copy and adapt oidc.rs

Copy from k-tv-backend/domain/src/value_objects/oidc.rs into crates/domain/src/value_objects/oidc.rs. Keep identical — all OIDC newtypes plus JwtSecret.

  • Step 6: Create search.rs

Create crates/domain/src/value_objects/search.rs with LibrarySearchFilter (moved from library.rs in old code since it's a value object):

use crate::value_objects::ContentType;

#[derive(Debug, Clone)]
pub struct LibrarySearchFilter {
    provider_id: Option<String>,
    content_type: Option<ContentType>,
    series_names: Vec<String>,
    collection_id: Option<String>,
    genres: Vec<String>,
    decade: Option<u16>,
    min_duration_secs: Option<u32>,
    max_duration_secs: Option<u32>,
    search_term: Option<String>,
    season_number: Option<u32>,
    offset: u32,
    limit: u32,
}

impl LibrarySearchFilter {
    pub fn new() -> Self {
        Self::default()
    }

    // Builder methods
    pub fn with_provider_id(mut self, id: impl Into<String>) -> Self { self.provider_id = Some(id.into()); self }
    pub fn with_content_type(mut self, ct: ContentType) -> Self { self.content_type = Some(ct); self }
    pub fn with_series_names(mut self, names: Vec<String>) -> Self { self.series_names = names; self }
    pub fn with_collection_id(mut self, id: impl Into<String>) -> Self { self.collection_id = Some(id.into()); self }
    pub fn with_genres(mut self, genres: Vec<String>) -> Self { self.genres = genres; self }
    pub fn with_decade(mut self, decade: u16) -> Self { self.decade = Some(decade); self }
    pub fn with_min_duration_secs(mut self, secs: u32) -> Self { self.min_duration_secs = Some(secs); self }
    pub fn with_max_duration_secs(mut self, secs: u32) -> Self { self.max_duration_secs = Some(secs); self }
    pub fn with_search_term(mut self, term: impl Into<String>) -> Self { self.search_term = Some(term.into()); self }
    pub fn with_season_number(mut self, n: u32) -> Self { self.season_number = Some(n); self }
    pub fn with_offset(mut self, offset: u32) -> Self { self.offset = offset; self }
    pub fn with_limit(mut self, limit: u32) -> Self { self.limit = limit; self }

    // Getters
    pub fn provider_id(&self) -> Option<&str> { self.provider_id.as_deref() }
    pub fn content_type(&self) -> Option<&ContentType> { self.content_type.as_ref() }
    pub fn series_names(&self) -> &[String] { &self.series_names }
    pub fn collection_id(&self) -> Option<&str> { self.collection_id.as_deref() }
    pub fn genres(&self) -> &[String] { &self.genres }
    pub fn decade(&self) -> Option<u16> { self.decade }
    pub fn min_duration_secs(&self) -> Option<u32> { self.min_duration_secs }
    pub fn max_duration_secs(&self) -> Option<u32> { self.max_duration_secs }
    pub fn search_term(&self) -> Option<&str> { self.search_term.as_deref() }
    pub fn season_number(&self) -> Option<u32> { self.season_number }
    pub fn offset(&self) -> u32 { self.offset }
    pub fn limit(&self) -> u32 { self.limit }
}

impl Default for LibrarySearchFilter {
    fn default() -> Self {
        Self {
            provider_id: None,
            content_type: None,
            series_names: vec![],
            collection_id: None,
            genres: vec![],
            decade: None,
            min_duration_secs: None,
            max_duration_secs: None,
            search_term: None,
            season_number: None,
            offset: 0,
            limit: 50,
        }
    }
}
  • Step 7: Update value_objects/mod.rs
pub mod auth;
pub mod channel;
pub mod ids;
pub mod oidc;
pub mod scheduling;
pub mod search;

pub use auth::*;
pub use channel::*;
pub use ids::*;
pub use oidc::*;
pub use scheduling::*;
pub use search::*;
  • Step 8: Update lib.rs re-exports

Update crates/domain/src/lib.rs to re-export the new modules.

  • Step 9: Verify and commit

Run: cargo check -p domain Expected: compiles

git add crates/domain/
git commit -m "domain value objects: auth, scheduling, channel, oidc, search"

Task 3: Domain models — User, Channel, ScheduleConfig, ProgrammingBlock, BlockContent, MediaItem

Files:

  • Create: crates/domain/src/models/mod.rs
  • Create: crates/domain/src/models/user.rs
  • Create: crates/domain/src/models/channel.rs
  • Create: crates/domain/src/models/media.rs
  • Create: crates/domain/src/models/collections.rs
  • Modify: crates/domain/src/lib.rs

Interfaces:

  • Consumes: All value objects from Tasks 1-2
  • Produces: User, Channel, ScheduleConfig, ScheduleConfigCompat, OldScheduleConfig, ProgrammingBlock, BlockContent, MediaItem, PlaybackRecord, PageParams, Paginated<T>

Key transformation: ALL fields become private. Each entity gets new() for creation, from_persistence() for DB hydration, and getter methods. Read the old k-tv-backend/domain/src/entities.rs for the complete field lists. Use new ID newtypes (UserId, ChannelId, etc.) instead of bare Uuid.

For ScheduleConfig, ProgrammingBlock, BlockContent: these are Serde-serialized as JSON in the DB. They need Serialize/Deserialize but fields should still be private with getters. Use #[serde(into = "...", from = "...")] or keep serde derives and add getters alongside. Since these are complex nested structures frequently serialized, keep Serialize/Deserialize derives and add getters.

For MediaItem: this is embedded in ScheduledSlot as JSON. Same approach — derives + getters.

Refer to k-tv-backend/domain/src/entities.rs for the complete type definitions. Migrate all types and tests.

  • Step 1: Create user.rs with encapsulated User
  • Step 2: Create channel.rs with Channel, ScheduleConfig, ProgrammingBlock, BlockContent, ScheduleConfigCompat
  • Step 3: Create media.rs with MediaItem, PlaybackRecord
  • Step 4: Create collections.rs with PageParams, Paginated
  • Step 5: Create mod.rs with re-exports
  • Step 6: Update lib.rs
  • Step 7: Verify and commit

Run: cargo check -p domain

git add crates/domain/
git commit -m "domain models: User, Channel, ScheduleConfig, MediaItem, PlaybackRecord"

Task 4: Domain models — Schedule, Library, ConfigSnapshot, Activity, ProviderConfig

Files:

  • Create: crates/domain/src/models/schedule.rs
  • Create: crates/domain/src/models/library.rs
  • Create: crates/domain/src/models/config_snapshot.rs
  • Create: crates/domain/src/models/activity.rs
  • Create: crates/domain/src/models/provider_config.rs
  • Modify: crates/domain/src/models/mod.rs

Interfaces:

  • Consumes: All value objects, MediaItem, ScheduleConfig from Tasks 1-3
  • Produces: GeneratedSchedule, ScheduledSlot, CurrentBroadcast, LibraryItem, LibraryCollection, LibrarySyncResult, LibrarySyncLogEntry, ShowSummary, SeasonSummary, ChannelConfigSnapshot, ActivityEvent, ProviderConfigRow

Read old code from:

  • k-tv-backend/domain/src/entities.rs (GeneratedSchedule, ScheduledSlot, CurrentBroadcast, ChannelConfigSnapshot)
  • k-tv-backend/domain/src/library.rs (LibraryItem, LibraryCollection, etc.)
  • k-tv-backend/domain/src/repositories.rs (ActivityEvent, ProviderConfigRow)

All fields private with new(), from_persistence(), getters.

  • Step 1: Create schedule.rs — GeneratedSchedule, ScheduledSlot, CurrentBroadcast
  • Step 2: Create library.rs — LibraryItem, LibraryCollection, LibrarySyncResult, LibrarySyncLogEntry, ShowSummary, SeasonSummary
  • Step 3: Create config_snapshot.rs — ChannelConfigSnapshot
  • Step 4: Create activity.rs — ActivityEvent
  • Step 5: Create provider_config.rs — ProviderConfigRow
  • Step 6: Update mod.rs re-exports
  • Step 7: Verify and commit

Run: cargo check -p domain

git add crates/domain/
git commit -m "domain models: schedule, library, config_snapshot, activity, provider_config"

Task 5: Domain ports — all CQRS-split trait definitions

Files:

  • Create: crates/domain/src/ports/mod.rs
  • Create: crates/domain/src/ports/auth.rs
  • Create: crates/domain/src/ports/user.rs
  • Create: crates/domain/src/ports/channel.rs
  • Create: crates/domain/src/ports/schedule.rs
  • Create: crates/domain/src/ports/library.rs
  • Create: crates/domain/src/ports/media.rs
  • Create: crates/domain/src/ports/events.rs
  • Create: crates/domain/src/ports/settings.rs
  • Create: crates/domain/src/ports/activity.rs
  • Create: crates/domain/src/ports/provider_config.rs
  • Create: crates/domain/src/ports/transcode.rs
  • Modify: crates/domain/src/lib.rs

Interfaces:

  • Consumes: All models and value objects from Tasks 1-4
  • Produces: AuthService, UserCommand, UserQuery, ChannelCommand, ChannelQuery, ScheduleCommand, ScheduleQuery, LibraryCommand, LibraryQuery, LibrarySyncAdapter, IMediaProvider, IProviderRegistry, ProviderCapabilities, StreamingProtocol, StreamQuality, Collection, SeriesSummary, EventPublisher, EventConsumer, EventHandler, AppSettingsRepository, ActivityLogRepository, ProviderConfigRepository, TranscodeSettingsRepository

Read old code from:

  • k-tv-backend/domain/src/repositories.rs — split each old trait into Command + Query
  • k-tv-backend/domain/src/ports.rsIMediaProvider, IProviderRegistry, ProviderCapabilities, etc.
  • k-tv-backend/domain/src/library.rsILibraryRepository → split into LibraryCommand + LibraryQuery, LibrarySyncAdapter

CQRS split example for UserRepository:

// ports/user.rs
#[async_trait]
pub trait UserCommand: Send + Sync {
    async fn save(&self, user: &User) -> DomainResult<()>;
    async fn delete(&self, id: UserId) -> DomainResult<()>;
}

#[async_trait]
pub trait UserQuery: Send + Sync {
    async fn find_by_id(&self, id: UserId) -> DomainResult<Option<User>>;
    async fn find_by_subject(&self, subject: &str) -> DomainResult<Option<User>>;
    async fn find_by_email(&self, email: &str) -> DomainResult<Option<User>>;
    async fn count_users(&self) -> DomainResult<u64>;
}

New AuthService port (password hashing + verification, abstracted from infra):

// ports/auth.rs
#[async_trait]
pub trait AuthService: Send + Sync {
    fn hash_password(&self, password: &str) -> DomainResult<String>;
    fn verify_password(&self, password: &str, hash: &str) -> DomainResult<bool>;
}

New event ports:

// ports/events.rs
#[async_trait]
pub trait EventPublisher: Send + Sync {
    async fn publish(&self, event: DomainEvent) -> DomainResult<()>;
}

#[async_trait]
pub trait EventConsumer: Send + Sync {
    async fn subscribe(&self) -> DomainResult<Box<dyn EventStream>>;
}

#[async_trait]
pub trait EventHandler: Send + Sync {
    async fn handle(&self, event: &DomainEvent) -> DomainResult<()>;
}

media.rs — copy IMediaProvider, IProviderRegistry, ProviderCapabilities, StreamingProtocol, StreamQuality, Collection, SeriesSummary from k-tv-backend/domain/src/ports.rs.

  • Step 1: Create auth.rs port
  • Step 2: Create user.rs with UserCommand + UserQuery
  • Step 3: Create channel.rs with ChannelCommand + ChannelQuery
  • Step 4: Create schedule.rs with ScheduleCommand + ScheduleQuery
  • Step 5: Create library.rs with LibraryCommand + LibraryQuery + LibrarySyncAdapter
  • Step 6: Create media.rs with IMediaProvider, IProviderRegistry, etc.
  • Step 7: Create events.rs with EventPublisher, EventConsumer, EventHandler
  • Step 8: Create settings.rs, activity.rs, provider_config.rs, transcode.rs
  • Step 9: Create mod.rs with re-exports
  • Step 10: Update lib.rs
  • Step 11: Verify and commit

Run: cargo check -p domain

git add crates/domain/
git commit -m "domain ports: CQRS-split traits for all bounded contexts"

Task 6: Domain events

Files:

  • Create: crates/domain/src/events/mod.rs
  • Modify: crates/domain/src/lib.rs

Interfaces:

  • Consumes: ID types, models from prior tasks
  • Produces: DomainEvent enum

Read from k-tv-backend/domain/src/events.rs. Adapt to use new ID newtypes. The events should reference IDs, not full entities (keeps Clone cheap):

#[derive(Clone, Debug)]
pub enum DomainEvent {
    BroadcastTransition { channel_id: ChannelId, slot_id: SlotId },
    NoSignal { channel_id: ChannelId },
    ScheduleGenerated { channel_id: ChannelId, schedule_id: ScheduleId },
    ChannelCreated { channel_id: ChannelId },
    ChannelUpdated { channel_id: ChannelId },
    ChannelDeleted { channel_id: ChannelId },
}
  • Step 1: Create events/mod.rs
  • Step 2: Update lib.rs
  • Step 3: Verify and commit
git add crates/domain/
git commit -m "domain events: DomainEvent enum"

Task 7: Domain services — ScheduleEngineService, fill strategies, IPTV

Files:

  • Create: crates/domain/src/services/mod.rs
  • Create: crates/domain/src/services/schedule/mod.rs
  • Create: crates/domain/src/services/schedule/fill.rs
  • Create: crates/domain/src/services/schedule/recycle.rs
  • Create: crates/domain/src/services/iptv.rs
  • Modify: crates/domain/src/lib.rs

Interfaces:

  • Consumes: All models, ports, value objects
  • Produces: ScheduleEngineService, fill_block(), apply_recycle_policy(), generate_m3u(), generate_xmltv()

Read from:

  • k-tv-backend/domain/src/services/schedule/mod.rs (392 lines)
  • k-tv-backend/domain/src/services/schedule/fill.rs (151 lines)
  • k-tv-backend/domain/src/services/schedule/recycle.rs (55 lines)
  • k-tv-backend/domain/src/iptv.rs (93 lines)

Note: The old UserService and ChannelService are NOT migrated here — their logic moves to application use cases. Only ScheduleEngineService stays in domain because it contains pure scheduling algorithms.

Adapt all code to use new newtypes (getters instead of field access, UserId instead of Uuid, etc.).

  • Step 1: Create schedule/fill.rs — adapt fill_block, fill_best_fit, fill_sequential
  • Step 2: Create schedule/recycle.rs — adapt apply_recycle_policy
  • Step 3: Create schedule/mod.rs — adapt ScheduleEngineService
  • Step 4: Create iptv.rs — adapt generate_m3u, generate_xmltv
  • Step 5: Create services/mod.rs
  • Step 6: Update lib.rs
  • Step 7: Verify and commit

Run: cargo check -p domain

git add crates/domain/
git commit -m "domain services: schedule engine, fill strategies, IPTV"

Task 8: Domain testing — InMemory repos + Noops

Files:

  • Create: crates/domain/src/testing/mod.rs
  • Create: crates/domain/src/testing/in_memory.rs
  • Create: crates/domain/src/testing/noops.rs
  • Modify: crates/domain/src/lib.rs

Interfaces:

  • Consumes: All port traits from Task 5
  • Produces: InMemoryUserRepository, InMemoryChannelRepository, InMemoryScheduleRepository, InMemoryLibraryRepository, NoopEventPublisher, NoopMediaProvider, NoopActivityLog, etc.

InMemory repos: Arc<Mutex<HashMap<Id, Entity>>> implementing both Command and Query traits. One struct implements both traits (since InMemory doesn't need separate read/write stores).

Noops: return Ok(()) for writes, Ok(None) / Ok(vec![]) for reads.

Reference: movies-diary/crates/domain/src/testing/ for pattern.

Gated behind #[cfg(feature = "test-helpers")] in lib.rs:

#[cfg(feature = "test-helpers")]
pub mod testing;
  • Step 1: Create in_memory.rs — InMemory implementations for all ports
  • Step 2: Create noops.rs — Noop implementations for all ports
  • Step 3: Create testing/mod.rs — re-exports
  • Step 4: Update lib.rs — add cfg-gated pub mod testing
  • Step 5: Verify and commit

Run: cargo check -p domain --features test-helpers

git add crates/domain/
git commit -m "domain testing: InMemory repos + Noops behind test-helpers feature"

Task 9: Application crate — auth bounded context

Files:

  • Create: crates/application/Cargo.toml
  • Create: crates/application/src/lib.rs
  • Create: crates/application/src/auth/mod.rs
  • Create: crates/application/src/auth/deps.rs
  • Create: crates/application/src/auth/commands.rs
  • Create: crates/application/src/auth/queries.rs
  • Create: crates/application/src/auth/register.rs
  • Create: crates/application/src/auth/login.rs
  • Create: crates/application/src/auth/tests/register.rs
  • Create: crates/application/src/auth/tests/login.rs

Interfaces:

  • Consumes: domain::* (ports, models, value objects, testing)
  • Produces: AuthDeps, RegisterCommand, LoginCommand, auth::register::execute(), auth::login::execute()
# crates/application/Cargo.toml
[package]
name = "application"
version = "0.1.0"
edition = "2024"

[dependencies]
domain = { workspace = true, features = ["test-helpers"] }
async-trait = { workspace = true }
uuid = { workspace = true }

[dev-dependencies]
tokio = { workspace = true }

Wait — test-helpers should only be enabled in dev-dependencies. Fix:

[dependencies]
domain = { workspace = true }
async-trait = { workspace = true }
uuid = { workspace = true }

[dev-dependencies]
domain = { workspace = true, features = ["test-helpers"] }
tokio = { workspace = true }

Pattern for each use case file:

// auth/register.rs
use domain::*;
use super::deps::AuthDeps;
use super::commands::RegisterCommand;

pub async fn execute(deps: &AuthDeps, cmd: RegisterCommand) -> DomainResult<User> {
    // validate, create entity, persist, publish event
}

#[cfg(test)]
#[path = "tests/register.rs"]
mod tests;

Read old logic from:

  • k-tv-backend/domain/src/services/user.rs (UserService methods become use cases)

  • k-tv-backend/api/src/routes/auth/local.rs (registration/login handler logic)

  • Step 1: Create application Cargo.toml

  • Step 2: Create deps.rs — AuthDeps struct

  • Step 3: Create commands.rs and queries.rs

  • Step 4: Write register testtests/register.rs

  • Step 5: Implement register.rs

  • Step 6: Write login test

  • Step 7: Implement login.rs

  • Step 8: Create mod.rs and lib.rs

  • Step 9: Verify and commit

Run: cargo test -p application

git add crates/application/
git commit -m "application: auth bounded context (register, login)"

Task 10: Application crate — channels bounded context

Files:

  • Create: crates/application/src/channels/mod.rs
  • Create: crates/application/src/channels/deps.rs
  • Create: crates/application/src/channels/commands.rs
  • Create: crates/application/src/channels/queries.rs
  • Create: crates/application/src/channels/create.rs
  • Create: crates/application/src/channels/update.rs
  • Create: crates/application/src/channels/delete.rs
  • Create: crates/application/src/channels/get.rs
  • Create: crates/application/src/channels/list.rs
  • Create: crates/application/src/channels/list_by_owner.rs
  • Create: crates/application/src/channels/tests/

Interfaces:

  • Consumes: domain::*
  • Produces: ChannelCommandDeps, ChannelQueryDeps, CreateChannelCommand, UpdateChannelCommand, use case execute() functions

Read old logic from k-tv-backend/domain/src/services/channel.rs (ChannelService methods → use cases).

  • Step 1: Create deps.rs
  • Step 2: Create commands.rs and queries.rs
  • Step 3: Write and implement create.rs with tests
  • Step 4: Write and implement update.rs with tests
  • Step 5: Write and implement delete.rs with tests
  • Step 6: Write and implement get.rs, list.rs, list_by_owner.rs with tests
  • Step 7: Create mod.rs, update lib.rs
  • Step 8: Verify and commit

Run: cargo test -p application

git add crates/application/
git commit -m "application: channels bounded context"

Task 11: Application crate — schedule bounded context

Files:

  • Create: crates/application/src/schedule/mod.rs, deps.rs, commands.rs, queries.rs
  • Create: crates/application/src/schedule/generate.rs, get_active.rs, get_current_broadcast.rs, get_epg.rs, get_stream_url.rs, list_history.rs
  • Create: crates/application/src/schedule/tests/

Interfaces:

  • Consumes: domain::*, ScheduleEngineService
  • Produces: ScheduleDeps, GenerateScheduleCommand, schedule query functions

The schedule use cases orchestrate calling ScheduleEngineService (which stays in domain). Read old logic from k-tv-backend/domain/src/services/schedule/mod.rs.

  • Step 1-6: Create deps, commands, queries, implement all use cases with tests
  • Step 7: Verify and commit
git add crates/application/
git commit -m "application: schedule bounded context"

Task 12: Application crate — library bounded context

Files:

  • Create: crates/application/src/library/mod.rs, deps.rs, commands.rs, queries.rs
  • Create: crates/application/src/library/sync.rs, search.rs, list_collections.rs, list_shows.rs, list_seasons.rs
  • Create: crates/application/src/library/tests/

Interfaces:

  • Consumes: domain::*
  • Produces: LibraryCommandDeps, LibraryQueryDeps, TriggerSyncCommand, library query functions

Read old logic from k-tv-backend/api/src/routes/library.rs (handler logic → use cases).

  • Step 1-6: Create deps, commands, queries, implement all use cases with tests
  • Step 7: Verify and commit
git add crates/application/
git commit -m "application: library bounded context"

Task 13: Application crate — remaining bounded contexts

Files:

  • Create: crates/application/src/config_snapshots/ — save, restore, list, patch_label
  • Create: crates/application/src/admin/ — update_settings, get_settings, activity_log
  • Create: crates/application/src/providers/ — upsert, delete, list
  • Create: crates/application/src/iptv/ — m3u, xmltv

Interfaces:

  • Consumes: domain::*
  • Produces: All remaining use case functions

Follow the same pattern as Tasks 9-12 for each bounded context.

  • Step 1: config_snapshots — deps, commands, queries, use cases, tests
  • Step 2: admin — deps, commands, queries, use cases, tests
  • Step 3: providers — deps, commands, queries, use cases, tests
  • Step 4: iptv — deps, queries, use cases, tests
  • Step 5: Verify and commit

Run: cargo test -p application

git add crates/application/
git commit -m "application: config_snapshots, admin, providers, iptv"

Task 14: API types crate

Files:

  • Create: crates/api-types/Cargo.toml
  • Create: crates/api-types/src/lib.rs
  • Create: crates/api-types/src/auth.rs, channels.rs, schedule.rs, library.rs, admin.rs, config.rs, providers.rs, transcode.rs, common.rs

Interfaces:

  • Consumes: domain (for types referenced in responses)
  • Produces: All *Request, *Response, *Dto types with utoipa::ToSchema
# crates/api-types/Cargo.toml
[package]
name = "api-types"
version = "0.1.0"
edition = "2024"

[dependencies]
domain = { workspace = true }
serde = { workspace = true }
utoipa = { workspace = true }
chrono = { workspace = true }
uuid = { workspace = true }

Read old DTOs from k-tv-backend/api/src/dto.rs. Each DTO gets #[derive(Serialize, Deserialize, utoipa::ToSchema)].

  • Step 1: Create Cargo.toml
  • Step 2: Create common.rs — PaginatedResponse, ErrorResponse
  • Step 3: Create auth.rs — LoginRequest, RegisterRequest, TokenResponse, UserResponse
  • Step 4: Create channels.rs — CreateChannelRequest, UpdateChannelRequest, ChannelResponse
  • Step 5: Create schedule.rs, library.rs, admin.rs, config.rs, providers.rs, transcode.rs
  • Step 6: Create lib.rs
  • Step 7: Verify and commit

Run: cargo check -p api-types

git add crates/api-types/
git commit -m "api-types: HTTP DTOs with utoipa OpenAPI derives"

Task 15: Infra-wiring crate

Files:

  • Create: crates/infra-wiring/Cargo.toml
  • Create: crates/infra-wiring/src/lib.rs

Interfaces:

  • Produces: DbPool enum, Config struct
# crates/infra-wiring/Cargo.toml
[package]
name = "infra-wiring"
version = "0.1.0"
edition = "2024"

[features]
default = ["sqlite"]
sqlite = ["sqlx/sqlite"]
postgres = ["sqlx/postgres"]

[dependencies]
sqlx = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
url = { workspace = true }

Read old config from k-tv-backend/api/src/config.rs. Migrate Config struct and DbPool enum.

  • Step 1: Create Cargo.toml
  • Step 2: Create lib.rs — DbPool enum, Config struct, Config::from_env()
  • Step 3: Verify and commit

Run: cargo check -p infra-wiring

git add crates/infra-wiring/
git commit -m "infra-wiring: DbPool enum + Config"

Task 16: Adapter — adapter-common

Files:

  • Create: crates/adapters/adapter-common/Cargo.toml
  • Create: crates/adapters/adapter-common/src/lib.rs

Interfaces:

  • Consumes: domain
  • Produces: map_sqlx_error(), shared row-to-domain conversion helpers

Read old mapping code from k-tv-backend/infra/src/channel_repository/mapping.rs, user_repository/mapping.rs, schedule_repository/mapping.rs.

  • Step 1: Create Cargo.toml
  • Step 2: Create lib.rs — map_sqlx_error, row mapping helpers
  • Step 3: Verify and commit
git add crates/adapters/adapter-common/
git commit -m "adapter-common: shared DB mapping helpers"

Task 17: Adapter — SQLite

Files:

  • Create: crates/adapters/sqlite/Cargo.toml
  • Create: crates/adapters/sqlite/src/lib.rs
  • Create: crates/adapters/sqlite/src/wire.rs
  • Create: crates/adapters/sqlite/src/user.rs, channel.rs, schedule.rs, library.rs, activity.rs, settings.rs, provider_config.rs, transcode.rs
  • Copy: k-tv-backend/migrations_sqlite/ → top-level migrations_sqlite/

Interfaces:

  • Consumes: domain, adapter-common, infra-wiring
  • Produces: SqliteWireOutput, all Sqlite*Repository types

Read old implementations from k-tv-backend/infra/src/*_repository/sqlite.rs files. Each repo struct wraps SqlitePool and implements both Command + Query traits.

wire() function creates pool, runs migrations, returns SqliteWireOutput with Arc<dyn Port> for each.

  • Step 1: Create Cargo.toml
  • Step 2: Copy migrations
  • Step 3: Implement user.rs — SqliteUserRepository (UserCommand + UserQuery)
  • Step 4: Implement channel.rs — SqliteChannelRepository
  • Step 5: Implement schedule.rs — SqliteScheduleRepository
  • Step 6: Implement library.rs — SqliteLibraryRepository
  • Step 7: Implement activity.rs, settings.rs, provider_config.rs, transcode.rs
  • Step 8: Create wire.rs — SqliteWireOutput, wire() function
  • Step 9: Create lib.rs
  • Step 10: Verify and commit

Run: cargo check -p adapter-sqlite

git add crates/adapters/sqlite/ migrations_sqlite/
git commit -m "adapter-sqlite: all repository implementations + wire function"

Task 18: Adapter — PostgreSQL

Files:

  • Create: crates/adapters/postgres/Cargo.toml
  • Create: crates/adapters/postgres/src/ — same structure as sqlite

Interfaces:

  • Consumes: domain, adapter-common, infra-wiring
  • Produces: PostgresWireOutput, all Postgres*Repository types

Read old code from k-tv-backend/infra/src/*_repository/postgres.rs. Note: not all repos have Postgres implementations in the old code — implement what exists, stub the rest.

  • Step 1-8: Mirror SQLite adapter for PostgreSQL
  • Step 9: Verify and commit
git add crates/adapters/postgres/
git commit -m "adapter-postgres: repository implementations"

Task 19: Adapter — auth (JWT + OIDC)

Files:

  • Create: crates/adapters/auth/Cargo.toml
  • Create: crates/adapters/auth/src/lib.rs
  • Create: crates/adapters/auth/src/jwt.rs
  • Create: crates/adapters/auth/src/oidc.rs
  • Create: crates/adapters/auth/src/password.rs

Interfaces:

  • Consumes: domain (AuthService port)
  • Produces: JwtAuthService, OidcService, PasswordAuthService

Read from k-tv-backend/infra/src/auth/. Feature-gate OIDC behind auth-oidc.

  • Step 1-5: Create adapter with JWT, OIDC, password hashing
  • Step 6: Verify and commit
git add crates/adapters/auth/
git commit -m "adapter-auth: JWT, OIDC, password hashing"

Task 20: Adapter — Jellyfin

Files:

  • Create: crates/adapters/jellyfin/Cargo.toml
  • Create: crates/adapters/jellyfin/src/lib.rs, config.rs, mapping.rs, models.rs, provider.rs

Interfaces:

  • Consumes: domain (IMediaProvider port)
  • Produces: JellyfinMediaProvider

Read from k-tv-backend/infra/src/jellyfin/.

  • Step 1-5: Create Jellyfin adapter
  • Step 6: Verify and commit
git add crates/adapters/jellyfin/
git commit -m "adapter-jellyfin: media provider"

Task 21: Adapter — local-files

Files:

  • Create: crates/adapters/local-files/Cargo.toml
  • Create: crates/adapters/local-files/src/ — lib.rs, config.rs, index.rs, provider.rs, scanner.rs, transcoder.rs

Interfaces:

  • Consumes: domain, infra-wiring
  • Produces: LocalFilesProvider, LocalIndex, TranscodeManager, LocalFilesBundle

Read from k-tv-backend/infra/src/local_files/.

  • Step 1-6: Create local-files adapter
  • Step 7: Verify and commit
git add crates/adapters/local-files/
git commit -m "adapter-local-files: media provider, index, transcoder"

Task 22: Adapter — event-publisher

Files:

  • Create: crates/adapters/event-publisher/Cargo.toml
  • Create: crates/adapters/event-publisher/src/lib.rs

Interfaces:

  • Consumes: domain (EventPublisher, EventConsumer ports)
  • Produces: ChannelEventBus (tokio broadcast-based)
pub struct ChannelEventBus {
    tx: broadcast::Sender<DomainEvent>,
}

impl ChannelEventBus {
    pub fn new(capacity: usize) -> Self { ... }
    pub fn subscriber(&self) -> broadcast::Receiver<DomainEvent> { ... }
}

#[async_trait]
impl EventPublisher for ChannelEventBus {
    async fn publish(&self, event: DomainEvent) -> DomainResult<()> {
        let _ = self.tx.send(event);
        Ok(())
    }
}
  • Step 1-3: Create event-publisher adapter
  • Step 4: Verify and commit
git add crates/adapters/event-publisher/
git commit -m "adapter-event-publisher: broadcast channel bus"

Task 23: Presentation crate

Files:

  • Create: crates/presentation/Cargo.toml
  • Create: crates/presentation/src/main.rs
  • Create: crates/presentation/src/state.rs
  • Create: crates/presentation/src/context.rs
  • Create: crates/presentation/src/factory.rs
  • Create: crates/presentation/src/routes.rs
  • Create: crates/presentation/src/errors.rs
  • Create: crates/presentation/src/extractors.rs
  • Create: crates/presentation/src/openapi/ — mod.rs + per-module files
  • Create: crates/presentation/src/handlers/ — auth.rs, channels.rs, schedule.rs, library.rs, admin.rs, config.rs, files.rs, iptv.rs
  • Create: crates/presentation/src/mappers/ — mod.rs + per-module files
  • Create: crates/presentation/src/background/ — mod.rs, library_sync.rs, auto_scheduler.rs, broadcast_poller.rs, webhook_consumer.rs

Interfaces:

  • Consumes: Everything — domain, application, api-types, infra-wiring, all adapters
  • Produces: Running HTTP server

Read old code from k-tv-backend/api/src/. This is the largest task.

Handlers follow this pattern:

async fn create_channel(
    State(ctx): State<AppContext>,
    CurrentUser(user): CurrentUser,
    Json(req): Json<CreateChannelRequest>,
) -> Result<Json<ChannelResponse>, ApiError> {
    let cmd = CreateChannelCommand { owner_id: user.id().value(), name: req.name, ... };
    let channel = channels::create::execute(&ctx.channel_deps, cmd).await?;
    Ok(Json(ChannelResponse::from(channel)))
}

Feature flags on Cargo.toml:

[features]
default = ["sqlite", "auth-jwt", "jellyfin"]
sqlite = ["adapter-sqlite", "infra-wiring/sqlite"]
postgres = ["adapter-postgres", "infra-wiring/postgres"]
auth-jwt = ["adapter-auth/jwt"]
auth-oidc = ["adapter-auth/oidc"]
jellyfin = ["adapter-jellyfin"]
local-files = ["adapter-local-files"]
  • Step 1: Create Cargo.toml with feature flags
  • Step 2: Create state.rs, context.rs
  • Step 3: Create errors.rs — ApiError enum, IntoResponse
  • Step 4: Create extractors.rs — CurrentUser, AdminUser, OptionalCurrentUser
  • Step 5: Create mappers/ — domain → api-types From impls
  • Step 6: Create handlers/auth.rs
  • Step 7: Create handlers/channels.rs
  • Step 8: Create handlers/schedule.rs
  • Step 9: Create handlers/library.rs
  • Step 10: Create handlers/admin.rs, config.rs, iptv.rs, files.rs
  • Step 11: Create routes.rs — wire all handlers
  • Step 12: Create openapi/ — utoipa merge
  • Step 13: Create background/ — library_sync, auto_scheduler, broadcast_poller, webhook_consumer
  • Step 14: Create factory.rs — build all adapters
  • Step 15: Create main.rs — startup wiring
  • Step 16: Verify and commit

Run: cargo build -p presentation

git add crates/presentation/
git commit -m "presentation: HTTP server with handlers, routes, OpenAPI, background tasks"

Task 24: MCP crate

Files:

  • Create: crates/mcp/Cargo.toml
  • Create: crates/mcp/src/main.rs
  • Create: crates/mcp/src/server.rs
  • Create: crates/mcp/src/error.rs
  • Create: crates/mcp/src/tools/mod.rs, channels.rs, library.rs, schedule.rs

Interfaces:

  • Consumes: domain, application, infra-wiring, adapter crates
  • Produces: MCP stdio binary

Read from k-tv-backend/mcp/src/. Adapt tool implementations to call application use cases instead of domain services directly.

  • Step 1-5: Create MCP crate
  • Step 6: Verify and commit

Run: cargo build -p mcp

git add crates/mcp/
git commit -m "mcp: MCP server calling application use cases"

Task 25: Cleanup — deprecate old code, update Docker, CI

Files:

  • Modify: k-tv-backend/README.md — add DEPRECATED notice

  • Modify: compose.yml — point to new presentation binary

  • Modify: compose.traefik.yml — update if needed

  • Modify: .dockerignore if exists

  • Create or modify: Dockerfile for new structure

  • Step 1: Add DEPRECATED to k-tv-backend README

  • Step 2: Update Docker build to use crates/presentation

  • Step 3: Update compose files

  • Step 4: Final full build and test

Run: cargo build --release -p presentation Run: cargo test --workspace

git add .
git commit -m "deprecate k-tv-backend, update Docker for new crate structure"