add restructure design spec + implementation plan
This commit is contained in:
1386
docs/superpowers/plans/2026-07-12-backend-restructure.md
Normal file
1386
docs/superpowers/plans/2026-07-12-backend-restructure.md
Normal file
File diff suppressed because it is too large
Load Diff
393
docs/superpowers/specs/2026-07-12-backend-restructure-design.md
Normal file
393
docs/superpowers/specs/2026-07-12-backend-restructure-design.md
Normal 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.
|
||||||
Reference in New Issue
Block a user