Skip to content

Backend System Design

This document describes the overall architecture of the Songloft backend, the initialization sequence, lifecycle management, the middleware stack, and the static file serving mechanism. The source code is located in internal/app/ and internal/config/.

Table of Contents

  1. Backend Architecture Overview
  2. App Initialization Sequence
  3. App Lifecycle
  4. Middleware Stack Ordering
  5. Static File Serving Architecture

1. Backend Architecture Overview

The Songloft backend adopts the classic three-tier layered architecture, using the App struct as the top-level container that wires up all dependencies and manages their lifecycle.

Diagram sources: internal/app/app.go (dependency injection), internal/app/routers.go (route registration)

Core design principles:

  • Dependency injection: the Service layer only receives Repository interfaces, never the DB instance itself
  • Dual-track fixed SQL + dynamic SQL: deterministic queries go through sqlc code generation, variable-length WHERE/SET clauses are built with squirrel
  • Cross-table writes: always performed through db.RunInTx(ctx, func(ctx, uow)) using a single *sql.Tx, avoiding SQLITE_BUSY

Section sources: internal/app/app.go, internal/app/routers.go


2. App Initialization Sequence

The App.Init() method is the startup entry point for the entire backend, creating and wiring all components in strict dependency order.

Diagram sources: internal/app/app.go Init() method

Key details of each initialization phase:

PhaseComponentKey points
Infrastructureslog + DBLevelVar supports dynamic log-level switching at runtime; goose migrations run automatically when the DB is opened
Config layerConfigServiceReads all persisted configuration from the configs table (log level, HTTP proxy, music path, etc.)
Authentication layerJWT + AuthServicejwt_secret is auto-generated and persisted on first startup; dual-token (access + refresh) mechanism
Business layerSong/Playlist/Cache, etc.Each Service is injected only with Repository interfaces; cross dependencies are lazily injected via Set* methods
Audio source chainFetcher → Resolver → OrchestratorDecoupled from jsplugin.Manager / MetadataExtractor through adapter interfaces
Plugin systemjsplugin.ManagerStarted asynchronously (go Manager.Start), including plugin loading + health checks + file-fingerprint hot reload
Background tasksAutoScannerRestores the auto-scan schedule from persisted configuration

Section sources: internal/app/app.go Init() method (L87-L391)


3. App Lifecycle

3.1 Startup Flow (Start)

App.Start() is called after Init() completes, and is responsible for mounting the Chi router onto the HTTP server and starting to listen.

go
func (a *App) Start() error {
    var handler http.Handler = a.router
    if a.config.BasePath != "" {
        mux := http.NewServeMux()
        mux.Handle(a.config.BasePath+"/", http.StripPrefix(a.config.BasePath, a.router))
        handler = mux
    }
    return http.ListenAndServe(":"+a.config.Port, handler)
}

When a BasePath is configured (such as /songloft), Start strips the prefix at the outermost layer using http.StripPrefix, ensuring that route matching inside the Chi router is unaffected by sub-path deployment. It also registers a 301 redirect that exactly matches BasePath (without a trailing slash), so that when a user visits /songloft they are automatically redirected to /songloft/.

3.2 Shutdown Flow (Close)

App.Close() shuts down each component in reverse dependency order:

Diagram sources: internal/app/app.go Close() method (L72-L85)

The shutdown order follows the principle of "stop producers first, then close consumers, and finally close storage": the JS plugin manager (which may issue DB writes) is closed first, the AutoScanner (which depends on SongService) is stopped next, and the database connection is closed last.

Section sources: internal/app/app.go Start() (L459-L483), Close() (L72-L85)


4. Middleware Stack Ordering

Middleware is registered onto the Chi router in order within setupBaseRouter(); requests pass through each middleware top to bottom, and responses return in reverse order.

Diagram sources: internal/app/routers.go setupBaseRouter() (L237-L349)

Responsibilities of each middleware:

MiddlewareSourceResponsibility
Compresschi middlewareEnables gzip level 5 compression for 9 MIME types
Access Logaccess_log.go slogLogFormatterBridges the chi access log to slog, with dynamic control via /settings/log-level
Tracely Panicinline defer/recoverCaptures panics before Recoverer and reports them to Tracely monitoring, then re-panics
Recovererchi middlewareCaptures panics and returns 500, preventing a single request from crashing the process
RequestIDchi middlewareGenerates a unique ID for each request, used for log correlation and debugging
CORSgo-chi/corsValidates cross-origin requests against an origin allowlist (localhost, LAN ranges, hanxi.cc domain)
Authmiddleware/auth.goJWT Bearer token validation, with access_token query fallback (audio/image scenarios); optional PublicPathChecker exemption for plugin-declared public paths

The Auth middleware is applied only to the /api/v1/* route group and JS plugin API routes; static file serving and public endpoints (/health, /version, /auth/login, /auth/refresh) do not go through authentication.

Section sources: internal/app/routers.go (L237-L349), internal/app/access_log.go, internal/middleware/auth.go


5. Static File Serving Architecture

Static file serving is implemented by embed.go, which embeds the Flutter Web frontend into the Go binary and provides SPA routing fallback at runtime.

Diagram sources: internal/app/embed.go (L17-L98), internal/app/compress.go (L32-L78)

5.1 Build Modes and Static Serving Behavior

Build modeembed.FS stateBehavior
Full build (default)Contains songloft-player-build/web-embedded/Full SPA serving with precompression support
Lite build (-tags lite)embed.FS is emptyThe root path returns a lightweight notice page; pure API mode

5.2 Precompressed File Serving (compress.go)

precompressedFS loads the build-time-generated .br (Brotli) and .gz (Gzip) precompressed files from embed.FS at startup. When a request arrives, it selects by Accept-Encoding priority:

  1. Brotli (br): highest compression ratio, supported by all modern browsers
  2. Gzip (gzip): the most broadly compatible fallback
  3. Raw file: reads the original file from embed.FS when no compression encoding is supported

For index.html, whose content changes after BasePath injection, addCustomEntry() re-runs Brotli/Gzip compression at runtime, ensuring the precompressed cache always stays consistent with the actual content.

ETags are generated from a CRC32 checksum, combined with If-None-Match to implement 304 Not Modified responses, reducing transfer overhead.

5.3 SPA Routing Fallback Rules

The SPA fallback logic distinguishes static asset requests from frontend routing requests:

  • Path with extension (such as .js, .css, .png): looked up in embed.FS; if not found, a 404 is returned directly, avoiding the frontend mistakenly parsing HTML as JS/JSON
  • Path without extension (such as /settings, /playlists/1): treated as a frontend route, returning index.html and handing routing off to the Flutter client

Section sources: internal/app/embed.go, internal/app/compress.go