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