Back in college I wrote a Windows music player in C and the Win32 API and called it Tejash Player. It got published on Softpedia, which felt enormous at the time, and the code behind it was genuinely awful. Window procedures doing business logic, global state everywhere, nothing that could be tested without launching the whole thing and clicking on it. It played music though, so out it went.
Most of my work now is backend, where Clean Architecture and Domain-Driven Design are so routine I stopped thinking about them years ago. Which is roughly when I started wondering whether any of it actually holds up in a desktop application, because a GUI framework owns your event loop and a lot of your state in a way an HTTP handler never does, and I genuinely did not know whether the patterns would earn their keep there or just pile on ceremony.
So when I came across Fyne I rebuilt the old music player to find out.
The first attempt
The proof of concept came together over a few evenings and did everything I wanted, which is to say it played files and loaded playlists and had a window. Then I read it back and it was the college project again. Callbacks inside callbacks, UI widgets reaching straight into audio state, and no way to test any of it without running the application and using it. Different language, same shape, about ten years later.
Then life got busy and it sat there for months. I came back to it in January 2026 with the actual question in mind rather than just wanting a music player.
What I ended up with
After a lot of iterative refactoring, I landed on this structure:
1
2
3
4
main.go -> app -> services -> ports <- adapters
| |
v v
domain (Fyne, BASS, SQLite)
The domain layer is plain Go structs for tracks, playlists and events, with nothing imported into it, so it would compile and run anywhere. The ports layer sits next to it and is nothing but interfaces, which is where you have to decide what an audio engine is actually required to do and what the system is allowed to announce about itself. Services hold the logic and are allowed to see domain models and port interfaces and nothing else, which is a rule you can enforce by grepping the imports.
Everything real lives in the adapter layer: Fyne, the BASS audio library, SQLite. Those implement the port interfaces and do the translation, and that layer turned out much thicker than the equivalent in a backend service, which I will come back to.
How it worked out
Some patterns translated directly from backend work. Dependency injection works exactly as expected:
1
2
3
4
5
6
7
8
9
10
11
12
type PlaybackService struct {
engine ports.AudioEngine
bus ports.EventBus
mu sync.RWMutex
}
func NewPlaybackService(engine ports.AudioEngine, bus ports.EventBus) *PlaybackService {
return &PlaybackService{
engine: engine,
bus: bus,
}
}
That means I can test PlaybackService with mocks on a machine with no sound card and no display, which was the thing I most wanted out of this and the thing the college version could never do.
The callbacks got replaced with domain events:
1
2
3
4
5
type EventBus interface {
Publish(event domain.Event)
Subscribe(eventType domain.EventType, handler domain.EventHandler) domain.SubscriptionID
Unsubscribe(id domain.SubscriptionID)
}
1
2
3
4
5
6
7
8
9
const (
EventTrackLoaded EventType = "track.loaded"
EventTrackStarted EventType = "track.started"
EventTrackPaused EventType = "track.paused"
EventTrackStopped EventType = "track.stopped"
EventTrackCompleted EventType = "track.completed"
EventVolumeChanged EventType = "volume.changed"
// ... over 20 event types total
)
The playback service publishes TrackStartedEvent, the UI subscribes and flips the play button, the visualizer subscribes and starts animating, and neither of them has any idea the other is there. It is the same event-driven arrangement I would use between services, running inside one process.
Plenty of it needed adjusting, though. A backend service is mostly stateless and a music player is the opposite, carrying a current track, a playback position, a volume, a playlist order, all of which lives in the services and all of which has to be reflected in the UI without the two fighting. Fyne, like most GUI frameworks, also insists that UI updates happen on a particular thread, so thread safety stops being a background concern and becomes something you think about on most changes.
The adapter layer being fat is the other adjustment, and it is not really avoidable. GUI frameworks have strong opinions about how UI code is laid out, and arguing with them is a waste of time. I ended up using Model-View-Presenter inside the adapter layer just to keep the Fyne-specific parts organised, with presenters talking through the event bus instead of reaching into services directly.
CGO brought its own tax. BASS is a dynamic library, so there are build tags, platform-specific initialisation and resource cleanup you have to get right, none of which has any equivalent when the deliverable is a REST API.
The visualizer
The visualizer is where the architecture paid for itself. There are eleven of them, all driven by FFT spectrum analysis of whatever is playing.
Adding them stayed easy the whole way through, which was not luck. The audio adapter hands FFT data out through a port interface, the visualizer adapters take that and draw onto Fyne canvases, and the service layer has never heard of any of it. I can add one, delete one, or throw the whole visualization system away and rewrite it, and the playback code does not move.
What I learned
It works, with a couple of things I would tell anyone before they start.
Budget for the adapter layer being much bigger than you expect. A GUI framework is a substantial dependency with firm opinions, and your adapters end up doing far more than an HTTP handler ever does. Events are not optional either, or at least I could not find a version of this that worked without them, because the moment the UI and the services can call each other directly you have a circular dependency and a design meeting with yourself. And the threading is genuinely harder than backend work, where request isolation quietly does most of that thinking for you.
The testing is what made it worth doing. Being able to exercise playback logic with no window open and no audio device present is the thing I could not do in college and the thing I would not now give up.
The problem has not changed in a decade, it is still play music. What changed is that I can now open the code a year later and find things in it.
GoTune is open source: https://github.com/tejashwikalptaru/gotune
Discussion
Powered by Disqus