Building Gocaster: A Terminal Podcast Client with Clean Architecture in Go Skip to content
← Back to Blog
Building Gocaster: A Terminal Podcast Client with Clean Architecture in Go

Building Gocaster: A Terminal Podcast Client with Clean Architecture in Go

I listen to a lot of podcasts. And like any self-respecting systems person, I wasn’t happy with the GUI clients. They’re heavy, they don’t fit into my terminal-first workflow, and - worst of all - they don’t compose with the rest of my tooling. So I did the only reasonable thing: I built my own.

I call it Gocaster - a terminal podcast client written in Go. It does what you’d expect: manage subscriptions, fetch RSS feeds, browse episodes, play them inline, and download for offline listening. But the part I’m proudest of isn’t the feature list - it’s how it’s put together.

Gocaster logo

This post is a walkthrough of the architecture: why I reached for Clean Architecture in what could have been a quick-and-dirty CLI app, and how each layer earns its keep. Grab your yerba mate. 🧉


🎯 The Feature Set

Before we dive into code, here’s what Gocaster actually does:

  • Podcast management - add feeds by URL, refresh, auto-sync on startup, periodic background sync
  • Episode browsing - j/k navigation, / to search, sort toggles, NEW/PLAYED status indicators
  • Internal playback - Playing with libmpv (via go-mpv bindings), not a shelled-out mpv subprocess
  • Download queue - background downloads with resume support and progress tracking
  • System integration - MPRIS controls (your media keys work) and Discord Rich Presence
  • Theming - 14 built-in themes plus custom themes loaded from TOML
  • Configuration - settings window, persisted config, autosync intervals

What started as a scaffold on an April afternoon turned into a surprisingly complete app over about six weeks of evenings. The reason it stayed manageable? The architecture was decided on day one.


🏛️ Why Clean Architecture for a TUI App?

Clean Architecture gets a bad rap sometimes - “enterprise Java in disguise,” circles-within-circles diagrams, abstract IWidgetFactoryFactory. Fair criticism when it’s cargo-culted. But the core idea is simple and genuinely useful:

Dependencies point inward. Your business logic knows nothing about the database, the network, or the UI.

For a TUI app, this pays off almost immediately:

  • I can test PodcastService without spinning up SQLite or hitting a real RSS feed.
  • I swapped the player from a shelled-out mpv command to an embedded libmpv instance - without touching a line of business logic.
  • I added Discord Rich Presence by dropping in a second broadcaster alongside MPRIS - the rest of the app never noticed.

Those last two aren’t hypothetical. Both are in the git history. That’s the whole pitch.

The Layer Cake

cmd/gocaster/              → entrypoint & dependency wiring
internal/
├── domain/                → entities + port interfaces (the core)
├── application/           → use cases / services
├── infrastructure/        → adapters: sqlite, rss, mpv, mpris, discord
└── interface/tui/         → presentation (Bubble Tea)

Let’s walk through each layer.


🧬 The Domain Layer: Pure Business Concepts

The innermost layer holds entities and the interfaces (ports) that define what the business logic needs. Crucially, no imports from outer layers - no database/sql, no HTTP, no TUI framework.

// internal/domain/entity.go
package domain

import "time"

type Podcast struct {
	ID          int64
	Title       string
	FeedURL     string
	Description string
	ImageURL    string
	LastUpdated time.Time
}

type Episode struct {
	ID               int64
	PodcastID        int64
	Title            string
	Description      string
	AudioURL         string
	PublishedAt      time.Time
	PlaybackDuration int // in seconds
	IsPlayed         bool
	IsDownloaded     bool
	LocalPath        string
}

Plain structs. No tags for a specific ORM, no framework annotations. Next to them live the interfaces the application layer depends on - a repository port and a feed parser port:

// internal/domain/repository.go
type PodcastRepository interface {
	Save(podcast *Podcast) error
	FindByID(id int64) (*Podcast, error)
	FindEpisodesByPodcastID(id int64) ([]Episode, error)
	SaveEpisode(episode *Episode) error
	UpdateEpisodePlaybackState(id int64, isPlayed bool) error
	// ...download jobs, etc.
}

Notice what’s not here: sql.DB, table names, query strings. That’s the SQLite implementation’s problem, and it lives two layers out.


⚙️ The Application Layer: Use Cases

This is where business rules live. Services orchestrate the domain entities and the ports. Here’s PodcastService adding a podcast by fetching its RSS feed and persisting everything:

// internal/application/podcast_service.go
type FeedParser interface {
	Parse(url string) (*domain.Podcast, []domain.Episode, error)
}

type PodcastService struct {
	repo    domain.PodcastRepository
	fetcher FeedParser
}

func NewPodcastService(repo domain.PodcastRepository, fetcher FeedParser) *PodcastService {
	return &PodcastService{repo: repo, fetcher: fetcher}
}

func (s *PodcastService) AddPodcast(rssUrl string) (*domain.Podcast, error) {
	// fetch metadata from rss feed
	podcast, episodes, err := s.fetcher.Parse(rssUrl)
	if err != nil {
		return nil, err
	}

	if err := s.repo.Save(podcast); err != nil {
		return nil, err
	}

	for i := range episodes {
		episodes[i].PodcastID = podcast.ID
		if err := s.repo.SaveEpisode(&episodes[i]); err != nil {
			return nil, err
		}
	}

	return podcast, nil
}

See the trick? PodcastService depends on the FeedParser interface, not the concrete gofeed-backed implementation. That’s the dependency inversion principle doing its job.

🧪 Why This Matters for Testing

In production, FeedParser is backed by gofeed hitting real URLs. In tests, I pass a fake that returns canned data. The service logic - the part that actually matters - is exercised without any network calls:

// In a test:
fakeFetcher := &stubFeedParser{podcast: p, episodes: eps}
svc := application.NewPodcastService(inMemoryRepo, fakeFetcher)
// assert on svc.AddPodcast(...) behavior

No mocks library, no HTTP server, no flakiness. The interface made it trivial.


🏗️ The Infrastructure Layer: Adapters

This is where the rubber meets the road - concrete implementations of all those ports.

SQLite Repository

The repository port gets a database/sql-backed implementation. Migrations run automatically on init:

// internal/infrastructure/persistence/sqlite_repo.go
func NewSQLiteRepo(dsn string) (*SQLiteRepo, error) {
	db, err := sql.Open("sqlite3", dsn)
	if err != nil {
		return nil, err
	}

	// Run migrations
	if err := RunMigrations(db); err != nil {
		db.Close()
		return nil, err
	}

	return &SQLiteRepo{db: db}, nil
}

The schema lives in migrations.go - podcasts, episodes, and downloads tables with the right indexes (idx_episodes_podcast_id, idx_episodes_published_at). It’s idempotent, so it’s safe on every startup.

RSS Feed Fetcher

The FeedParser port gets a gofeed-backed adapter that maps the library’s structs into our clean domain entities:

// internal/infrastructure/rss/feed_fetcher.go
func (f *FeedFetcher) Parse(url string) (*domain.Podcast, []domain.Episode, error) {
	fp := gofeed.NewParser()
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	feed, err := fp.ParseURLWithContext(url, ctx)
	if err != nil {
		return nil, nil, err
	}

	podcast := &domain.Podcast{
		Title:       feed.Title,
		FeedURL:     url,
		Description: feed.Description,
	}

	episodes := make([]domain.Episode, 0, len(feed.Items))
	for _, item := range feed.Items {
		if len(item.Enclosures) == 0 {
			continue // skip non-audio items
		}
		episode := domain.Episode{
			Title:       item.Title,
			Description: item.Description,
			AudioURL:    item.Enclosures[0].URL,
		}
		if item.ITunesExt != nil && item.ITunesExt.Duration != "" {
			episode.PlaybackDuration = parseDuration(item.ITunesExt.Duration)
		}
		episodes = append(episodes, episode)
	}
	return podcast, episodes, nil
}

The messy reality of RSS - iTunes extensions, duration strings like "2:09:56", missing enclosures - is contained here. The domain layer never sees it.


🎮 The Interface Layer: Bubble Tea TUI

The outermost layer is the presentation. I used the Charm stack - bubbletea a powerful TUI framework based on the Elm-architecture, bubbles for components (lists, viewports, text inputs, spinners), and lipgloss for styling.

Bubble Tea follows the classic Model-Update-View loop:

  • Model - your full app state (current view, focus, selected podcast/episode, playback status, dimensions, settings…)
  • Update - handles messages (key presses, async results) and returns a new model
  • View - renders the model to a string

The Model struct is a beast, but that’s the nature of a real TUI - it holds everything the view needs:

// internal/interface/tui/app.go
type Model struct {
	podcastService  *application.PodcastService
	downloadService *application.DownloadService
	playerService   *application.PlayerService

	state  viewState
	keys   keyMap
	theme  styles.Theme
	list   list.Model       // library pane
	detail viewport.Model   // episode detail pane
	input  textinput.Model  // "add feed" modal
	// ...spinners, status, dimensions, settings, playback status
}

Here’s the important part: the TUI depends on the application services, never the other way around. The UI is a consumer. If I wanted to add a web frontend or an HTTP API tomorrow, it would sit next to the TUI, calling the same PodcastService.


🎛️ The Star of the Show: The Composite Broadcaster

This is my favorite part of the whole project. Once you have a real player, you want it to integrate with the desktop - media keys should work (MPRIS on Linux), and it’s fun to show “now playing” in Discord.

Both MPRIS and Discord consume the same signal (playback state + position). So I defined a port for it:

// in domain
type PlaybackBroadcaster interface {
	PublishState(state PlaybackState, metadata PlaybackMetadata) error
	PublishPosition(positionSec float64, durationSec float64) error
	SetController(controller PlaybackController)
	Close() error
}

Then a composite that fans events out to any number of broadcasters:

// internal/infrastructure/system/composite_broadcaster.go
func NewCompositeBroadcaster(broadcasters ...domain.PlaybackBroadcaster) domain.PlaybackBroadcaster {
	filtered := make([]domain.PlaybackBroadcaster, 0, len(broadcasters))
	for _, b := range broadcasters {
		if b != nil { // gracefully skip nil broadcasters (e.g. MPRIS unavailable)
			filtered = append(filtered, b)
		}
	}
	return &compositeBroadcaster{broadcasters: filtered}
}

func (b *compositeBroadcaster) PublishState(
	state domain.PlaybackState,
	metadata domain.PlaybackMetadata,
) error {
	var errs []error
	for _, broadcaster := range b.broadcasters {
		if err := broadcaster.PublishState(state, metadata); err != nil {
			errs = append(errs, err)
		}
	}
	return errors.Join(errs...) // one failure doesn't kill the others
}

The PlayerService depends only on a single PlaybackBroadcaster. It doesn’t know - or care - whether that’s MPRIS alone, MPRIS + Discord, or some third thing I add later. Adding Discord support was literally appending to a slice.

And that errors.Join at the end? It means a Discord hiccup doesn’t break your media keys. Resilient by construction.


🔌 Wiring It All Together: main.go

All the layers are independent. They get bolted together in exactly one place - cmd/gocaster/main.go. This is dependency injection the Go way: explicit, in main, no framework:

// cmd/gocaster/main.go
func main() {
	cfg, err := config.LoadOrCreate()
	if err != nil {
		log.Fatal("fatal: ", err)
	}

	// Infrastructure
	repo, err := persistence.NewSQLiteRepo(cfg.DatabasePath)
	if err != nil {
		log.Fatal("fatal: ", err)
	}
	fetcher := rss.NewFeedFetcher()

	// Application services
	podcastSvc := application.NewPodcastService(repo, fetcher)
	downloadSvc := application.NewDownloadService(repo, cfg.DownloadPath)

	// Player + broadcasters (composite pattern)
	mpvPlayer := player.NewMPVPlayer()
	mprisBroadcaster, err := system.NewMPRISBroadcaster()
	if err != nil {
		log.Printf("Warning: failed to create MPRIS broadcaster: %v", err)
	}

	broadcasters := []domain.PlaybackBroadcaster{mprisBroadcaster}
	if cfg.DiscordPresence {
		if discordBroadcaster, err := system.NewDiscordBroadcaster(cfg.DiscordClientID); err == nil {
			broadcasters = append(broadcasters, discordBroadcaster)
		}
	}
	broadcaster := system.NewCompositeBroadcaster(broadcasters...)
	playerSvc := application.NewPlayerService(repo, mpvPlayer, broadcaster)

	// TUI
	model := tui.NewModel(podcastSvc, downloadSvc, playerSvc, settings, saveSettings, customThemesDir)
	p := tea.NewProgram(model)
	p.Run()
}

Read that top to bottom: config → persistence → RSS → services → player → broadcasters → TUI. Every dependency is satisfied in order. If you want to understand the app’s whole shape, this file is the map.


🔄 The Player Swap: A Real-World Win

Remember when I said the architecture earned its keep? Here’s the concrete example from the git history.

Gocaster’s first player implementation shelled out to the mpv command - spawning a subprocess per playback session. It worked, but it was clumsy: no clean status polling, no graceful control, awkward lifecycle management.

So I swapped it for an embedded libmpv instance via go-mpv. Direct API calls, Command([]string{"loadfile", source, "replace"}), GetProperty("time-pos", ...), the works.

Gocaster's dedicated player screen - play/pause, 15-second skip, a seek input, a live progress bar, and the episode notes.

The change touched one file - mpv_player.go - plus the wiring in main.go. The Player interface in the domain stayed put. PlayerService stayed put. The TUI stayed put. Because nothing depended on the concrete implementation, the entire playback backend was replaceable behind a type boundary.

That’s not theoretical “flexibility for someday.” That’s flexibility I used, three weeks in.


🧠 Lessons Learned

✅ What Worked

  • Clean Architecture isn’t overhead here - it’s the reason the app stayed sane. Six weeks of evenings, MPRIS and Discord, theme system, download queue, internal player swap, settings persistence - and the core never became spaghetti.
  • Interfaces at the domain boundary make testing free. No network, no DB, no mocks framework. Just stubs.
  • The composite pattern for broadcasters is a keeper. One slice, errors.Join, done. Adding output channels is trivial.
  • Bubble Tea + the Elm architecture is a genuinely good fit for a stateful TUI. Pure update functions are easy to reason about and test.

⚠️ What I’d Watch For

  • The TUI Model struct gets big. It’s the nature of a rich TUI, but it’s a smell worth watching. I’ve considered splitting playback state into its own sub-model.
  • CGO for go-sqlite3 and go-mpv. Gocaster now needs a C toolchain to build (CGO_ENABLED=1). That’s a tradeoff for native SQLite and embedded playback - worth it for a desktop app, but be aware when containerizing (I cover that exact gotcha in my Docker post).
  • gofeed’s RSS edge cases are endless. Weird duration formats, missing enclosures, namespaces. Containment at the adapter boundary saved the domain from that chaos - but the adapter itself is the place that needs the most defensive code.

🚀 Where It’s Going

Gocaster is alive and kicking on my machine every day. On the roadmap:

  • Sync playback position across episodes more granularly
  • More theme discovery and per-podcast settings
  • Smarter download scheduling

The architecture means none of these require untangling the core - they slot in as new use cases or adapters. That’s the real dividend of getting the layering right early.


🤓 Final Thoughts

If there’s one takeaway, it’s this: Clean Architecture in Go isn’t about ceremony - it’s about seams. Put a port interface at every external boundary (database, network, file system, UI, system integrations), let dependencies point inward, and wire everything explicitly in main. You get testability, replaceability, and a codebase that stays legible as it grows.

Gocaster started as “I want a better podcast client.” It turned into one of the cleanest codebases I’ve written, and honestly, one of the most fun. If you’re building a TUI - or any stateful Go app - give the layered approach a serious look. The upfront thinking pays you back every single week.

The code’s on GitHub. Fork it, open a pull request, tell me what’s wrong with it.

Till next time: happy hacking. 💻✨