18 KiB
Coding Conventions
Analysis Date: 2026-02-26
Go Code Style
Package Documentation
Every package begins with a doc comment ending with a period. Use // Package <name> <description>. format:
// Package player provides audio playback functionality.
package player
// Package queue manages the playback queue and auto-advance logic.
package queue
// Package events contains centralized event name constants for
// Wails frontend/backend communication. These names must match
// the corresponding event names in the TypeScript frontend.
package events
Enforced by godot linter. Multi-line doc comments are acceptable:
// Package profiling provides dev-only performance profiling via pprof and runtime/trace.
//
// In dev builds (build tag "dev"), Start launches an HTTP server on localhost:6060...
package profiling
Import Organization
Three groups separated by blank lines, enforced by gci formatter:
- Standard library (e.g.,
context,fmt,log/slog) - Third-party (e.g.,
github.com/...) - Internal (prefix
yellowjacket/...)
import (
"context"
"errors"
"fmt"
"log/slog"
"sync"
"github.com/gopxl/beep/v2"
"github.com/wailsapp/wails/v2/pkg/runtime"
"yellowjacket/backend/database"
"yellowjacket/backend/events"
"yellowjacket/backend/metadata"
)
Use import aliases sparingly and only when needed to resolve conflicts:
import (
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
goruntime "runtime"
)
Blank identifier imports for side effects include a comment:
import (
_ "modernc.org/sqlite" // Register sqlite driver.
)
Error Handling
Wrap errors with context using fmt.Errorf and %w:
return fmt.Errorf("failed to open file: %w", err)
return fmt.Errorf("could not connect to sqlite database: %w", err)
Define sentinel errors as package-level vars (enforced by err113). Never use errors.New() inline in return statements:
// Exported sentinels for external consumers:
var ErrUnsupportedFileType = errors.New("unsupported file type")
// Unexported sentinels for internal use:
var (
errNoControlStreamer = errors.New("no control streamer")
errNoAudioFileLoaded = errors.New("no audio file loaded")
errNoStreamerToPlay = errors.New("no streamer to play")
errLibraryDirNotConfigured = errors.New("library directory not configured")
)
Use errors.Join() for accumulating multiple non-fatal errors:
var batchErr error
for _, result := range batch {
if saveErr := l.saveAudioFile(...); saveErr != nil {
batchErr = errors.Join(batchErr, saveErr)
}
}
Return early on errors with a blank line after the early-return block (enforced by nlreturn):
if err != nil {
return fmt.Errorf("failed to open file: %w", err)
}
// continue with normal flow
Naming Conventions
Exported vs Unexported
- Structs/types:
PascalCasefor exported,camelCasefor unexported - Functions/methods:
PascalCasefor exported,camelCasefor unexported - Constants:
PascalCasefor exported,camelCasefor unexported - Variables:
PascalCasefor exported,camelCasefor unexported
Custom Domain Types
Use typed aliases for domain-specific values rather than raw primitives:
// backend/player/volume.go
type UserVolume int
type Volume float64
// backend/player/player.go
type State string
// backend/metadata/metadata.go
type AudioFileExtension string
// backend/queue/queue.go
type RepeatMode string
// backend/library/config.go
type Directory string
type ScanConcurrency string
No Stuttering (enforced by revive)
Exported types must not repeat the package name. Consumers write queue.Track, not queue.QueueTrack:
// Good — in package queue:
type Track struct { ... }
type State struct { ... }
// Bad — would stutter:
type QueueTrack struct { ... }
type QueueState struct { ... }
Constants
Group related constants with const (...):
const (
Playing State = "playing"
Paused State = "paused"
Stopped State = "stopped"
)
const (
MinUserVol UserVolume = 0
MaxUserVol UserVolume = 100
DefaultUserVol UserVolume = 50
)
JSON Tags
Use camelCase JSON tags on exported struct fields for frontend serialization:
type TrackInfo struct {
FileName string `json:"fileName"`
FilePath string `json:"filePath"`
State State `json:"state"`
TrackLength int `json:"trackLength"`
TrackChangeID uint64 `json:"trackChangeId"`
}
Constructor Pattern
Use New* constructors with dependency injection. Accept *slog.Logger and scope it with logger.WithGroup():
// backend/queue/queue.go
func NewQueue(logger *slog.Logger, db *database.DB) *Queue {
return &Queue{
logger: logger.WithGroup("queue"),
db: db,
repeatMode: RepeatOff,
}
}
// backend/player/player.go
func NewPlayer(logger *slog.Logger, db *database.DB) *Player {
return &Player{
logger: logger,
db: db,
state: Stopped,
baseStreamer: generators.Silence(-1),
format: beep.Format{
SampleRate: speakerSampleRate,
},
}
}
// backend/database/database.go
func NewDB(logger *slog.Logger) (*DB, error) {
// ...
return &DB{
db: db,
Ctx: dbCtx,
Queries: queries,
logger: logger,
}, err
}
Logger scoping with .WithGroup() or .With():
logger.WithGroup("queue")
logger.WithGroup("player")
logger.WithGroup("config").With("config", conf)
SetContext Pattern (Two-Phase Initialization)
Components needing the Wails runtime use two phases because the runtime is unavailable until OnStartup:
- Phase 1:
New*()constructor — created beforewails.Runfor binding registration - Phase 2:
SetContext(ctx context.Context)— called after runtime starts; registers event handlers, restores state
// Phase 1: in NewYellowJacketApp()
yjApp.player = player.NewPlayer(yjApp.logger.WithGroup("player"), yjApp.database)
yjApp.queue = queue.NewQueue(yjApp.logger, yjApp.database)
// Phase 2: in OnStartup()
yj.player.SetContext(ctx)
yj.queue.SetContext(ctx)
yj.library.SetContext(ctx)
yj.appConfig.SetContext(ctx)
SetContext implementations vary by component:
// backend/player/player.go — restores persisted state
func (p *Player) SetContext(ctx context.Context) {
p.mu.Lock()
p.ctx = ctx
p.mu.Unlock()
p.mu.Lock()
p.restoreStateLocked()
p.mu.Unlock()
}
// backend/queue/queue.go — simple context assignment
func (q *Queue) SetContext(ctx context.Context) {
q.ctx = ctx
}
// backend/library/library.go — registers event handlers
func (l *Library) SetContext(ctx context.Context) {
l.ctx = ctx
l.registerEventHandlers()
}
Logging Conventions
Use log/slog with structured key-value pairs. Logger injected via constructors and scoped with WithGroup:
// Info-level with structured data:
p.logger.Info("File loaded, state set to paused", "file", filePath)
p.logger.Info("Player state saved",
"volume", volume,
"muted", muted,
"trackPath", trackPath,
"positionSeconds", positionSeconds,
)
// Error-level:
p.logger.Error("Failed to decode", "path", filePath, "err", err)
// Warning-level:
p.logger.Warn("failed to close previous audio file", "err", closeErr)
// Debug-level:
p.logger.Debug("attempting to seek",
"target-seconds", targetSeconds,
"song-length", lengthSecs,
"samples", samples,
)
sloglint enforces: consistent key-value pair formatting. Always use string keys and structured values.
Operation Timing
Use profiling.TimeOp (dev-only, no-op in production) with defer:
defer profiling.TimeOp(p.logger, "player.LoadFile")()
defer profiling.TimeOp(logger, "database.NewDB")()
defer profiling.TimeOp(q.logger, "queue.SetQueue")()
Comment & Documentation Requirements
Doc Comments (enforced by godot)
All doc comments on exported types and functions must end with a period:
// Player handles audio playback and state management.
type Player struct { ... }
// NewPlayer creates a player. Call InitSpeaker separately to
// initialize the audio output device.
func NewPlayer(logger *slog.Logger, db *database.DB) *Player {
// SetVolume sets the playback volume (0-100), emits a
// VolumeChanged event, and persists the new level.
func (p *Player) SetVolume(desiredVolume UserVolume) {
Section Comments
Use separator comments to organize large files into logical sections:
// ---------------------------------------------------------------
// Emit helpers (must be called with p.mu held)
// ---------------------------------------------------------------
// ---------------------------------------------------------------
// Streamer management (must be called with p.mu held)
// ---------------------------------------------------------------
// ---------------------------------------------------------------
// LoadFile
// ---------------------------------------------------------------
Internal Implementation Comments
Unexported functions get concise comments explaining purpose and lock requirements:
// saveState is the internal helper that writes the current player
// state to the database. Must be called with p.mu held.
func (p *Player) saveState() {
Linting Rules
golangci-lint v2 Configuration
Config: .golangci.yml — version 2 format with default: standard.
Enabled linters:
gocritic— common Go pitfallserrorlint— proper error wrapping with%werr113— sentinel errors must be package-level varsgodot— doc comments end with periodsrevive— Go best practices (no stuttering, etc.)sloglint— consistent slog usagenlreturn— blank line after early returnswsl— whitespace linting (cuddled declarations)perfsprint— preferstrconvoverfmt.Sprintffor simple conversionsmisspell— spelling in commentsnakedret— no naked returns in long functionsdupword— duplicated words in commentswhitespace— trailing whitespaceusetesting— prefert.Context()andt.TempDir()
Enabled formatters:
gci— import ordering (stdlib → third-party →yellowjacket/)gofmt,gofumpt— standard formattinggoimports— import managementgolines— line length (keep under 100 characters)
Common Linting Pitfalls
Line length (golines) — Keep under 100 characters. Break long function calls:
// Bad — over 100 characters:
q.logger.Warn("Current index out of range", "index", q.currentIndex, "trackCount", len(q.tracks))
// Good — broken across lines:
q.logger.Warn(
"Current index out of range",
"index", q.currentIndex,
"trackCount", len(q.tracks),
)
Blank line after early returns (nlreturn) — An if block ending with return/continue/break must be followed by a blank line:
if err != nil {
return err
}
doNextThing()
Cuddled declarations (wsl) — var and const must be separated from preceding statements by a blank line:
// Good:
wasEmpty := len(q.tracks) == 0
var newTracks []Track
// Bad:
wasEmpty := len(q.tracks) == 0
var newTracks []Track
Sentinel errors (err113) — Never use errors.New(...) or fmt.Errorf("...") inline in returns. Define package-level sentinels:
var errNotFound = errors.New("not found")
Doc comments (godot) — End with a period:
// Track represents a track in the queue with its metadata.
type Track struct { ... }
Stuttering (revive) — Don't repeat the package name in type names.
Concurrency Patterns
Mutex Usage
Use sync.Mutex with Lock()/defer Unlock() for public methods. Internal *Locked suffix functions assume lock is held:
// Public method acquires lock:
func (p *Player) Play() error {
p.mu.Lock()
defer p.mu.Unlock()
// ...
}
// Internal helper — caller must hold p.mu:
func (p *Player) loadFileLocked(filePath string) error {
// no lock acquired here
}
Document lock ordering in struct comments:
// Player handles audio playback and state management.
//
// Lock ordering: always acquire p.mu BEFORE speaker.Lock().
type Player struct {
mu sync.Mutex
// ...
}
Atomic Counters
Use atomic.Int64 for cross-goroutine counters that don't need mutex protection:
var added, skipped, updated atomic.Int64
added.Add(1)
metrics.Added = added.Load()
Build Tags
Dev/prod detection via internal/dev/:
internal/dev/devbuild.go://go:build dev→IsDev = trueinternal/dev/nondevbuild.go://go:build !dev→IsDev = false
Package-level functions use this for conditional behavior (e.g., profiling.TimeOp is a no-op in prod builds).
TypeScript/Lit Conventions
Component Pattern
Use @customElement decorator with LitElement base class:
@customElement('now-playing')
export class NowPlaying extends LitElement {
// ReactiveControllers for store connection
private player = new PlayerController(this);
private favCtrl = new FavoritesController(this);
// Component-local reactive state
@state()
private isDragging = false;
// Static styles (override keyword required)
static override styles = css`
:host { display: block; }
`;
// Lifecycle (override keyword required)
override connectedCallback() {
super.connectedCallback();
// setup
}
override disconnectedCallback() {
super.disconnectedCallback();
// cleanup
}
override render() {
return html`...`;
}
// Private event handlers as arrow functions
private handleMouseDown = (e: MouseEvent) => {
e.preventDefault();
this.isDragging = true;
};
private handleCoverMouseEnter = () => {
// ...
};
}
// Register in global element map
declare global {
interface HTMLElementTagNameMap {
'now-playing': NowPlaying;
}
}
Key rules:
overridekeyword required on all lifecycle methods (noImplicitOverride: true)- Private event handlers as arrow functions (auto-bound
this) @state()decorator for component-local reactive statestatic override stylesfor CSS-in-JS withcsstag
Store Pattern (Singleton + ReactiveController)
Backend is source of truth. Frontend stores cache backend state via Wails events.
Store (frontend/src/store/player-store.ts):
class PlayerStore {
private state: PlayerState = { isPlaying: false, currentTrack: null, volume: 50 };
private subscribers = new Set<Subscriber>();
constructor() {
this.initializeEventListeners();
}
private initializeEventListeners(): void {
EventsOn(Events.PlaybackStateChanged, (data: { state: string }) => {
this.update({ isPlaying: data.state === 'playing' });
});
}
getState(): Readonly<PlayerState> { return this.state; }
subscribe(callback: Subscriber): () => void { ... }
private update(partial: Partial<PlayerState>): void { ... }
private notify(): void { ... }
}
// Singleton instance
export const playerStore = new PlayerStore();
Controller (frontend/src/store/controllers/player-controller.ts):
export class PlayerController implements ReactiveController {
private host: ReactiveControllerHost;
private unsubscribe?: () => void;
constructor(host: ReactiveControllerHost) {
this.host = host;
host.addController(this);
}
hostConnected(): void {
this.unsubscribe = playerStore.subscribe(() => {
this.host.requestUpdate();
});
}
hostDisconnected(): void {
this.unsubscribe?.();
}
// Convenience getters
get isPlaying(): boolean { return this.state.isPlaying; }
get currentTrack(): TrackInfo | null { return this.state.currentTrack; }
}
Import Organization
Use path aliases from frontend/tsconfig.json. Use import type for type-only imports (verbatimModuleSyntax):
// Third-party
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state } from 'lit/decorators.js';
// Runtime/generated bindings
import { EventsOn, EventsEmit } from '@runtime/runtime';
import * as Player from '@go/player/Player';
// Internal stores/controllers
import type { TrackInfo } from '@store/player-store';
import { PlayerController } from '@store/controllers/player-controller';
// Components
import '@components/audio-player/audio-player';
Available aliases:
@go/*→./wailsjs/go/*(Wails-generated Go bindings)@components/*→./src/components/*@store/*→./src/store/*@runtime/*→./wailsjs/runtime/*(Wails runtime)@utils/*→./src/utils/*@assets/*→./src/assets/*@pages/*→./src/pages/*
TypeScript Strictness
Configured in frontend/tsconfig.json:
strict: true— all strict checksnoUncheckedIndexedAccess: true— array/object index checksnoImplicitOverride: true— requireoverridekeywordverbatimModuleSyntax: true— requireimport typenoUnusedLocals: true,noUnusedParameters: truenoImplicitReturns: truenoFallthroughCasesInSwitch: trueexperimentalDecorators: true— for Lit decoratorsuseDefineForClassFields: false— for Lit property definitions- Plugins:
ts-lit-plugin,typescript-lit-html-plugin
Event System
Events bridge Go backend and TypeScript frontend. Names must match exactly in both files:
- Go:
backend/events/events.go - TypeScript:
frontend/src/events.ts
// Go constants
const (
PlaybackStateChanged = "PlaybackStateChanged"
TrackChanged = "TrackChanged"
QueueChanged = "QueueChanged"
)
// TypeScript constants (as const object)
export const Events = {
PlaybackStateChanged: "PlaybackStateChanged",
TrackChanged: "TrackChanged",
QueueChanged: "QueueChanged",
} as const;
export type EventName = (typeof Events)[keyof typeof Events];
Store Barrel File
frontend/src/store/index.ts re-exports stores and types:
export { playerStore } from './player-store';
export type { PlayerState, TrackInfo } from './player-store';
export { PlayerController } from './controllers/player-controller';
Convention analysis: 2026-02-26