With the theory settled across the three previous articles, it's time to build. This article builds the skeleton of RockPlayerApi, with no music provider yet, just the structure that will receive it in article 6.
The structure, in layers
Same division as RockRecommender: Domain, Application, Infrastructure and Api. The difference is that here Domain is even leaner, because there is no entity at all to validate, only the ports (the interfaces that describe what the system needs) and a few simple records to carry data from one side to the other.
RockPlayerApi.Domain the ports (interfaces) and the records that flow through the application
RockPlayerApi.Application the services that orchestrate the ports
RockPlayerApi.Infrastructure the real implementations of the ports (RockRecommender, Redis, YouTube)
RockPlayerApi.Api the controllers
The ports
public interface IRecommenderClient
{
Task<Result<Guid>> CreateUserAsync(IEnumerable<string> likedBands);
Task<Result<NextSong>> GetNextSongAsync(Guid userId);
Task SubmitFeedbackAsync(Guid userId, Guid songId, bool liked);
}
public sealed record NextSong(Guid SongId, string Band, string Title, string Genre);
IRecommenderClient is the port that hides the fact that, on the other side, there is an HTTP call to RockRecommender. Whoever uses this interface doesn't even need to know that a separate API exists there, it just asks for the next song and gets back a NextSong.
The orchestrator
This is the piece that ties everything together: it asks RockRecommender for the next song, checks the Redis cache to see if it already knows where to play that song, and only calls the real adapter when the cache has no answer.
public sealed class PlaybackOrchestrator(
IRecommenderClient recommenderClient,
IProviderLookupCache providerLookupCache,
IMusicProviderAdapter musicProviderAdapter)
{
public async Task<Result<PlaybackSource>> GetNextSongAsync(Guid userId)
{
var nextSongResult = await recommenderClient.GetNextSongAsync(userId);
if (!nextSongResult.IsSuccess)
return Result<PlaybackSource>.NotFound(nextSongResult.Error!);
var nextSong = nextSongResult.Value!;
// we already know where to play this song, no need to ask the provider again
var cached = await providerLookupCache.GetAsync(nextSong.SongId);
if (cached is not null)
return Result<PlaybackSource>.Success(cached);
var playbackResult = await musicProviderAdapter.FindAsync(nextSong.Band, nextSong.Title);
if (!playbackResult.IsSuccess)
return Result<PlaybackSource>.Unavailable(playbackResult.Error!);
await providerLookupCache.SetAsync(nextSong.SongId, playbackResult.Value!);
return Result<PlaybackSource>.Success(playbackResult.Value!);
}
}
Two design points worth calling out. The controller will never know IRecommenderClient directly, only the Application, so there are two thin services between them:
public sealed class UserService(IRecommenderClient recommenderClient)
{
public Task<Result<Guid>> CreateUserAsync(CreateUserRequest request) =>
recommenderClient.CreateUserAsync(request.LikedBands);
}
public sealed class FeedbackService(IRecommenderClient recommenderClient)
{
public Task SubmitFeedbackAsync(Guid userId, FeedbackRequest request) =>
recommenderClient.SubmitFeedbackAsync(userId, request.SongId, request.Liked);
}
Exactly the same rule as RockRecommender: the Api layer only knows Application, never a Domain port directly.
The controller
[Route("users")]
public sealed class UsersController(
UserService userService,
FeedbackService feedbackService,
PlaybackOrchestrator orchestrator) : ApiControllerBase
{
[HttpPost]
public async Task<ActionResult<Guid>> CreateUser(CreateUserRequest request)
{
var result = await userService.CreateUserAsync(request);
return ToActionResult(result);
}
[HttpGet("{userId:guid}/next-song")]
public async Task<ActionResult<PlaybackSource>> GetNextSong(Guid userId)
{
var result = await orchestrator.GetNextSongAsync(userId);
return ToActionResult(result);
}
[HttpPost("{userId:guid}/feedback")]
public async Task<IActionResult> SubmitFeedback(Guid userId, FeedbackRequest request)
{
await feedbackService.SubmitFeedbackAsync(userId, request);
return NoContent();
}
}
The same ApiControllerBase from RockRecommender, reused here without changing a single line, because turning a Result into an HTTP response is the same rule in both projects.
What's still missing
This API already compiles and already orchestrates everything correctly, except without any real implementation of the three ports yet. The next article solves exactly that, with YouTube.