refactor: DDD/CQRS architecture, unified crate layout

- crates: common→application, api→presentation, infrastructure/*→adapters/*
- new crates: api-types, infra-wiring
- domain: errors/, models/, value_objects/, ports/, services/
- application: CQRS use cases (songs/, tabs/) w/ commands, queries, deps
- unified DomainError replaces RepositoryError
- workspace deps, unused dep cleanup
- fix: parse plain-text chord lines (UG drops spans mid-song)
- tests extracted to separate modules (tests/ dirs)
This commit is contained in:
2026-07-11 21:02:10 +02:00
parent a520251dab
commit d13df586dd
74 changed files with 1493 additions and 1076 deletions

1
crates/application/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
Cargo.lock

View File

@@ -0,0 +1,8 @@
[package]
name = "application"
version = "0.1.0"
edition = "2024"
[dependencies]
uuid = { workspace = true }
domain = { workspace = true }

View File

@@ -0,0 +1,2 @@
pub mod songs;
pub mod tabs;

View File

@@ -0,0 +1,17 @@
use domain::models::Song;
use uuid::Uuid;
pub struct SaveSongCommand {
pub song: Song,
}
pub struct DeleteSongCommand {
pub id: Uuid,
}
pub struct UpdateSongMetaCommand {
pub id: Uuid,
pub title: Option<String>,
pub artist: Option<String>,
pub original_key: Option<String>,
}

View File

@@ -0,0 +1,8 @@
use domain::errors::DomainError;
use super::commands::DeleteSongCommand;
use super::deps::SongCommandDeps;
pub async fn execute(deps: &SongCommandDeps, cmd: DeleteSongCommand) -> Result<(), DomainError> {
deps.repo.delete(cmd.id).await
}

View File

@@ -0,0 +1,11 @@
use domain::ports::{SongRepositoryPort, SongSearchPort};
use std::sync::Arc;
pub struct SongCommandDeps {
pub repo: Arc<dyn SongRepositoryPort>,
}
pub struct SongQueryDeps {
pub repo: Arc<dyn SongRepositoryPort>,
pub search: Arc<dyn SongSearchPort>,
}

View File

@@ -0,0 +1,12 @@
use domain::errors::DomainError;
use domain::models::Song;
use super::deps::SongQueryDeps;
use super::queries::GetSongQuery;
pub async fn execute(
deps: &SongQueryDeps,
query: GetSongQuery,
) -> Result<Option<Song>, DomainError> {
deps.repo.get(query.id).await
}

View File

@@ -0,0 +1,12 @@
use domain::errors::DomainError;
use domain::models::SongSummary;
use super::deps::SongQueryDeps;
use super::queries::ListSongsQuery;
pub async fn execute(
deps: &SongQueryDeps,
query: ListSongsQuery,
) -> Result<Vec<SongSummary>, DomainError> {
deps.repo.list(query.sort, query.order).await
}

View File

@@ -0,0 +1,9 @@
pub mod commands;
pub mod delete_song;
pub mod deps;
pub mod get_song;
pub mod list_songs;
pub mod queries;
pub mod save_song;
pub mod search_songs;
pub mod update_meta;

View File

@@ -0,0 +1,17 @@
use domain::value_objects::{SortField, SortOrder};
use uuid::Uuid;
pub struct ListSongsQuery {
pub sort: SortField,
pub order: SortOrder,
}
pub struct GetSongQuery {
pub id: Uuid,
}
pub struct SearchSongsQuery {
pub query: String,
pub sort: SortField,
pub order: SortOrder,
}

View File

@@ -0,0 +1,12 @@
use domain::errors::DomainError;
use domain::models::StoredSong;
use super::commands::SaveSongCommand;
use super::deps::SongCommandDeps;
pub async fn execute(
deps: &SongCommandDeps,
cmd: SaveSongCommand,
) -> Result<StoredSong, DomainError> {
deps.repo.save(&cmd.song).await
}

View File

@@ -0,0 +1,14 @@
use domain::errors::DomainError;
use domain::models::SongSummary;
use super::deps::SongQueryDeps;
use super::queries::SearchSongsQuery;
pub async fn execute(
deps: &SongQueryDeps,
query: SearchSongsQuery,
) -> Result<Vec<SongSummary>, DomainError> {
deps.search
.search(&query.query, query.sort, query.order)
.await
}

View File

@@ -0,0 +1,19 @@
use domain::errors::DomainError;
use domain::models::SongSummary;
use super::commands::UpdateSongMetaCommand;
use super::deps::SongCommandDeps;
pub async fn execute(
deps: &SongCommandDeps,
cmd: UpdateSongMetaCommand,
) -> Result<SongSummary, DomainError> {
deps.repo
.update_meta(
cmd.id,
cmd.title.as_deref(),
cmd.artist.as_deref(),
cmd.original_key.as_deref(),
)
.await
}

View File

@@ -0,0 +1,4 @@
pub struct ParseTabCommand {
pub source: Option<String>,
pub html: Option<String>,
}

View File

@@ -0,0 +1,7 @@
use domain::ports::{TabFetcherPort, TabParserPort};
use std::sync::Arc;
pub struct ParseTabDeps {
pub fetcher: Arc<dyn TabFetcherPort>,
pub parser: Arc<dyn TabParserPort>,
}

View File

@@ -0,0 +1,3 @@
pub mod commands;
pub mod deps;
pub mod parse_tab;

View File

@@ -0,0 +1,33 @@
use std::path::PathBuf;
use domain::errors::DomainError;
use domain::models::Song;
use domain::ports::TabSource;
use super::commands::ParseTabCommand;
use super::deps::ParseTabDeps;
pub async fn execute(deps: &ParseTabDeps, cmd: ParseTabCommand) -> Result<Song, DomainError> {
let html = if let Some(raw_html) = cmd.html {
Ok(raw_html)
} else if let Some(source) = cmd.source {
let tab_source = if source.starts_with("file://") {
let path = source.trim_start_matches("file://");
TabSource::File(PathBuf::from(path))
} else {
TabSource::Url(source)
};
deps.fetcher
.fetch(tab_source)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
} else {
Err(DomainError::ValidationError(
"Provide either 'source' or 'html'".into(),
))
}?;
deps.parser
.parse(&html)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
}