Architecture¶
How the server is put together: the hexagonal layering under internal/,
the bounded contexts, the events that connect them, and the static guards
that keep the code on those rails. The code lives in internal/domain,
internal/app, internal/adapters/{driving,driven} and is wired in
cmd/evey/wire*.go; the layout is decided by ADR-0001,
the repository split by ADR-0009 and the event
bus by ADR-0010. For building and running, read
DEV.md; for which files to touch for a given change, read the
Codebase map.
Backend is Go; the web, mobile and TV clients are a TypeScript/React monorepo
under frontend/ (React Native / Expo for mobile and TV).
Layering¶
flowchart LR
driving[Driving adapters<br/>internal/adapters/driving<br/>grpcapi, http, pluginhost]
app[Application services<br/>internal/app/context]
domain[Domain<br/>internal/domain/context<br/>models, value objects, ports]
driven[Driven adapters<br/>internal/adapters/driven<br/>postgres, ffmpeg, mkvgo, ...]
driving --> app --> domain
driven -. implement the ports .-> domain
| Directory | Holds | Rule |
|---|---|---|
internal/domain/<context>/ |
models, value objects and the ports (interfaces) | no framework import, no import of another context |
internal/app/<context>/ |
application services that orchestrate the ports | IO goes through a port; dlna (SSDP / UPnP AVTransport) and loadtest (its own HTTP client) are the two deliberate exceptions that reach the network directly |
internal/adapters/driven/ |
outbound adapters implementing the ports: postgres, meilisearch, ffmpeg, mkvgo, filesystem, prober, opensubtitles, email, webpush, webhooksender, pluginrunner, metadata, introproviders, search |
three more satisfy an app-layer interface rather than a domain port: itunes and musicbrainz (app/music.AlbumArtFetcher), ociregistry (app/updatecheck) |
internal/adapters/driving/ |
inbound adapters: grpcapi (+ grpc-gateway HTTP), http (static, auth middleware, SSE, HLS, plugin iframe assets), pluginhost (gRPC server on a Unix socket, authenticated by peer credentials, the host API sandboxed plugins call back into) |
|
internal/pkg/ |
the shared technical kernel: auth, logger, eventbus, i18n, metrics, tracing, ratelimit, procfork, ssrf, cache, ... | |
internal/config/ |
server configuration | |
gen/ |
generated proto code |
Dependency rule: adapters -> app -> domain. The domain never imports the
app or the adapters. Composition is hand-written (not google/wire) in
cmd/evey/wire.go and its siblings wire_infra.go, wire_services.go,
wire_adapters.go, wire_events.go, wire_helpers.go. Tests bypass the
adapters by injecting fakes that implement the same domain ports: 20 of the
21 domain packages ship a fakes/ subpackage.
Bounded contexts¶
Thirty-two context packages live under internal/domain/ and internal/app/:
19 exist in both layers, 2 are domain-only (ratings, shared), 11 are
app-only. They collaborate partly through domain events on a shared bus; the
app layer also reads a neighbouring context's domain package directly (see
Relations).
| Context | Responsibility |
|---|---|
user |
accounts, auth, sessions, preferences |
library |
libraries, folders, scan jobs, media index, people, collections, a profile's favorites and thumbs (thumbs have no app service: the rating RPC stores and reads UserRatingRepository as is), trickplay, intro/credits detection |
media (app only) |
share links and stream tokens |
mediarequest |
user-submitted media requests |
metadata |
TMDB / OMDb enrichment, artwork |
playback |
streams, progress, remote control, now-playing |
transcoding |
ffmpeg jobs, HLS segments |
subtitle |
embedded + external + opensubtitles |
mkv |
MKV container ops (via mkvgo) |
music |
audio-specific playback + tagging |
photo |
photo libraries |
favorite (app only) |
per-profile favorites; the model and FavoriteRepository live in domain/library |
playlist |
user playlists, smart playlists |
collection (app only) |
admin-curated collections; its ports live in domain/library |
ratings (domain only) |
maps country classifications (CSA, FSK, MPAA, BBFC, ACB) onto one minimum-age axis |
feed |
activity feed / discover |
activity |
user activity timeline |
chromecast |
cast session lifecycle |
syncplay |
multi-device sync sessions |
plugin |
plugin registry, sandbox tokens, lifecycle |
notification |
in-app + webpush notifications |
webhook |
outbound webhooks |
backup |
DB + config backup/restore |
config (app only) |
persisted server settings, written by both the REST and the gRPC entry points |
dlna (app only) |
SSDP discovery and UPnP AVTransport control |
loadtest (app only) |
simulates viewers against the server's own HTTP stack: HLS, or the raw file when a client's verdict is a direct play |
maintenance (app only) |
admin-triggered media maintenance: seek-index rebuild, repair journal, undo, audio integrity |
presence (app only) |
in-memory, non-persisted registry of who is connected right now |
qoe (app only) |
CMCD (CTA-5004) playback telemetry ingest and aggregation |
demo (app only) |
the public demo instance (EVEY_DEMO=1): seeds its accounts, libraries and freely licensed media through the product's own services, and resets itself |
updatecheck (app only) |
compares the running build with what the registry publishes and tells the administrator; it never updates anything |
shared (domain only) |
cross-cutting ports (EventBus, TransactionalUnit, ServerConfigRepository, ShareLinkRepository), typed ids, cursors, stream tokens |
Relations¶
| Emitter | Events | Consumed by |
|---|---|---|
library |
LibraryCreated / LibraryDeleted, LibraryScanStarted / LibraryScanCompleted, MediaItemAdded, MediaItemRemoved, TrickplayGenerated |
media.item.added: notification, metadata (auto-enrich), library itself (trickplay worker + intro detection), feed (genre-cache invalidation) |
playback |
PlaybackStarted / PlaybackStopped |
no dedicated subscriber today; only webhook's "*" catch-all receives them |
user |
UserCreated, user.password.reset_requested |
the first has no subscriber; the second is consumed by the email driven adapter |
pluginrunner |
plugin.error / plugin.started |
app/plugin for its security dashboard, notification for the administrator |
shared |
ServerConfigChanged |
the services that apply a setting saved in the admin panel without a restart (SMTP, timezone, CPU budget, ...) |
- Search indexing is not event-driven:
library.ScanServicecalls thelibrary.SearchIndexport directly. - There is no bridge feeding domain events into plugins: plugins reach the API with scoped JWTs (ADR-0003). The bus itself is ADR-0010.
shared.TransactionalUnitwraps multi-aggregate writes (ADR-0002).
No package under internal/domain imports another context's domain package,
a rule enforced by internal/contractguard/domain_imports_test.go (see
Architecture guards). The app layer is not bound by
it: 15 app packages import a neighbouring context's domain package today.
Typical data flow - "user plays a media"¶
- Mobile/web client calls
POST /api/v1/playback/start(grpc-gateway route). internal/adapters/driving/httpruns its auth middleware (auth_middleware.go,httpAuthMiddleware), extracts session.internal/adapters/driving/grpcapi(playback_handler.go) validates proto, callsapp/playback.CommandService.StartStream(command/query split, see ADR-0009).app/playback.CommandServicecalls domain ports:user.UserRepository,playback.SessionQueryRepo(idempotency + device cap),playback.SessionCommandRepo, thenshared.EventBus.Publish(PlaybackStarted).- Driven adapters execute:
postgrespersists the session inside ashared.TransactionalUnit.Doscope; the in-process bus (internal/pkg/eventbus) dispatches asynchronously. PlaybackStartedhas no dedicated subscriber today: only thewebhook"*"catch-all receives it.- Response streams back through grpc-gateway as JSON; progress updates flow over SSE / gRPC streams.
What happens once the player asks for the manifest and the segments is the subject of Playback pipeline.
Architecture guards¶
The rules above are not documentation-only. internal/contractguard/ holds
58 static tests across 30 files that fail the build when the codebase drifts
from them.
The hexagonal rule itself is TestDomainContextsDoNotImportEachOther
(ADR-0001), in domain_imports_test.go. It walks internal/domain and
enforces exactly this: a context may import internal/domain/shared and its
own package tree, nothing else from internal/domain, and never
internal/app or internal/adapters. Its scope stops at internal/domain:
it says nothing about the app layer.
The rest guard contracts that cross a language or a process boundary, among them:
| Guard | What it refuses |
|---|---|
TestNoClientReadOfAFieldTheServerNeverWrites |
a proto response field a client reads that no server code writes |
TestRegisteredHandlersGetEveryPortTheyDeclare, TestBootInstallsItsSilentWirings |
wiring that would otherwise fail silently |
TestPostgresRepositoriesBoundTheirQueries |
a Postgres repository query without a timeout |
TestProcfork_EveryForkGoesThroughCommand |
a subprocess fork that bypasses internal/pkg/procfork |
TestEverySortFieldTheAPIAcceptsHasASQLColumn, ...CanBuildACursorValue |
a sort field missing from the SQL column map or the cursor encoder |
TestEveryWebSurfaceReachingAnAdminRPCIsClassified, plus its native counterpart for mobile and TV |
a UI surface calling an admin-only RPC without deciding who sees it |
TestEveryAllowedSlotIsMountedOnWebAndMobile, TestPluginBridgeParityWebMobile |
a plugin UI slot or bridge message present on one client only |
TestImageSubtitleCodecsAgreeAcrossServerAndClients |
a second list of bitmap subtitle codecs on a player |
TestHandWrittenClientTypesMatchTheEndpointTheyCall |
a hand-written mobile/TV response type that drifts from its endpoint |
TestOnlyThePlanDecidesPlayback, TestNoCallerDerivesDownstreamOfThePlaybackPlan |
a playback decision taken outside the plan resolver (ADR-0030) |
TestRestartExitCodeAgreesWithTheEntrypoint |
a Go exit code and docker/entrypoint.sh disagreeing on what a restart means |
TestServerEmittedI18nKeysExistInCatalogue, TestI18nPlaceholdersAgreeAcrossLocales |
a server-emitted key missing from the catalogue, placeholders that differ between locales |
C4 diagrams¶
Four diagrams document Evey at decreasing zoom levels following the
C4 model. Node names are the actual Go package names
under internal/. Container names come from docker-compose.yml for
evey-server, evey-postgres and evey-caddy; evey-web,
evey-meilisearch, evey-mobile and evey-mailcatcher are declared in
docker-compose.override.yml and exist in the dev topology only.
Level 1 - System Context¶
Who talks to Evey, and which third parties Evey reaches out to.
flowchart LR
user[User<br/>web / mobile / TV client]
admin[Administrator<br/>web /admin UI]
oidc[OIDC Identity Provider<br/>optional SSO]
tmdb[TMDB API<br/>movie/series metadata]
omdb[OMDb API<br/>fallback metadata + ratings]
imdb[IMDbAPI / TVmaze<br/>fallback series metadata]
art[MusicBrainz / iTunes Search<br/>album artwork]
introdb[IntroDB / TIDB<br/>intro + credits timestamps]
opensubs[OpenSubtitles<br/>subtitle search]
webpush[Web Push gateway<br/>FCM / APNs / VAPID]
registry[OCI registry<br/>published image tags]
subgraph evey[Evey Media Server]
core((Self-hosted media platform<br/>library, playback, transcoding,<br/>music, photo, plugins))
end
user -->|HTTPS<br/>HLS / SSE / gRPC-Web| evey
admin -->|HTTPS<br/>/admin| evey
evey -.->|OIDC code flow| oidc
evey -.->|HTTPS metadata fetch| tmdb
evey -.->|HTTPS metadata fetch| omdb
evey -.->|HTTPS metadata fetch| imdb
evey -.->|HTTPS artwork fetch| art
evey -.->|HTTPS intro lookup| introdb
evey -.->|HTTPS subtitle fetch| opensubs
evey -.->|HTTPS push delivery| webpush
evey -.->|HTTPS update check| registry
Level 2 - Container¶
The deployable units brought up by docker compose --env-file .env.dev up.
Solid arrows are runtime traffic; dashed arrows are outbound to third parties.
flowchart TB
user([User browser / mobile])
admin([Admin browser])
subgraph compose[Docker Compose network]
caddy[evey-caddy<br/>Caddy 2.9<br/>:80 / :443<br/>TLS, reverse proxy]
web[evey-web<br/>Vite dev / nginx prod<br/>:5173 dev<br/>React SPA + RN web]
server[evey-server<br/>Go binary<br/>:9091 HTTP+gRPC-gateway<br/>internal :9090 gRPC]
pg[(evey-postgres<br/>PostgreSQL 16<br/>:5432)]
meili[(evey-meilisearch<br/>Meilisearch 1.12<br/>:7700)]
mobile[evey-mobile<br/>dev only<br/>:8081 Expo / Metro bundler]
mail[evey-mailcatcher<br/>dev only<br/>:1025 SMTP / :1080 UI]
end
tmdb[[TMDB / OMDb / OpenSubtitles]]
fs[(/media volumes<br/>read-only bind mounts)]
cache[(evey-cache volume<br/>transcode + trickplay)]
data[(evey-data volume<br/>keys, logs, backups,<br/>plugins, avatars)]
user -->|HTTPS| caddy
admin -->|HTTPS /admin| caddy
caddy -->|HTTP /| web
caddy -->|HTTP /v1, /api, SSE, HLS| server
web -->|gRPC-Web via /v1| server
user -.->|Metro dev bundle| mobile
server -->|SQL/TCP 5432| pg
server -->|HTTP 7700<br/>indexing + search| meili
server -.->|SMTP 1025 dev| mail
server -.->|HTTPS| tmdb
server -->|read FS| fs
server -->|read/write FS| cache
server -->|read/write FS| data
Production differs from this dev topology
The install compose declares one service, evey. There is no separate
evey-web container (nor the dev-only mailcatcher and Metro bundler):
the binary serves the built web UI from /srv, so a reverse proxy
forwards everything, UI and API, to it on :9091. Postgres and
Meilisearch run inside the same container, supervised by
docker/entrypoint.sh: PostgreSQL 17 with its data under
/data/postgres, and the Meilisearch binary (Dockerfile,
meili-download stage, pinned to 1.12.8) with its index under
/data/meili. The repository's own docker-compose.yml keeps separate
postgres and caddy services, described in
Advanced deployments. See
Install and Reverse proxy.
Level 3 - Component (zoom on evey-server)¶
Hexagonal layering inside the Go binary. Driving adapters call application services through their interfaces; application services depend only on domain ports; driven adapters implement those ports.
flowchart LR
subgraph driving[Driving adapters - inbound]
grpc[grpcapi<br/>internal/adapters/driving/grpcapi<br/>handlers per context]
gw[grpc-gateway<br/>JSON/HTTP <-> gRPC<br/>proto-generated]
http[http<br/>internal/adapters/driving/http<br/>static, SSE, HLS,<br/>auth_middleware.go]
plug[pluginhost<br/>internal/adapters/driving/pluginhost<br/>gRPC over Unix socket<br/>peer-credential auth]
end
subgraph app[Application layer - internal/app]
direction TB
a_lib[library]
a_med[media]
a_meta[metadata]
a_play[playback]
a_trans[transcoding]
a_sub[subtitle]
a_mkv[mkv]
a_music[music]
a_photo[photo]
a_user[user]
a_upd[updatecheck]
a_misc[favorite, playlist, collection,<br/>feed, activity,<br/>chromecast, syncplay,<br/>plugin, notification, webhook,<br/>backup, dlna, config, mediarequest,<br/>maintenance, presence, qoe, loadtest,<br/>demo]
end
subgraph domain[Domain core - internal/domain]
d_models[Aggregates + Value Objects<br/>per bounded context]
d_ports[Outbound Ports<br/>Repos - command/query split,<br/>EventBus, TransactionalUnit,<br/>ServerConfigRepository]
end
subgraph driven[Driven adapters - outbound]
pgad[postgres<br/>repos + migrations<br/>shared.TransactionalUnit]
meiad[meilisearch<br/>search index]
fsad[filesystem<br/>scanner, parser, watcher]
ffad[ffmpeg<br/>transcoder, trickplay]
mkvad[mkvgo<br/>MKV ops]
metaad[metadata<br/>TMDB, OMDb, IMDbAPI,<br/>TVmaze clients]
introad[introproviders<br/>IntroDB + TIDB chain]
itunad[itunes<br/>album artwork]
mbad[musicbrainz<br/>album artwork]
ociad[ociregistry<br/>published tags]
probead[prober<br/>mkvgo fast path,<br/>ffprobe fallback]
searchad[search<br/>resilient facade over meili]
osad[opensubtitles]
emad[email]
wpad[webpush]
whad[webhooksender]
prad[pluginrunner]
end
grpc --> app
gw --> grpc
http --> app
plug --> app
app --> d_ports
app --> d_models
d_ports -.implements.- pgad
d_ports -.implements.- meiad
d_ports -.implements.- fsad
d_ports -.implements.- ffad
d_ports -.implements.- mkvad
d_ports -.implements.- metaad
d_ports -.implements.- introad
d_ports -.implements.- probead
a_music -.app-layer iface.- itunad
a_music -.app-layer iface.- mbad
a_upd -.app-layer iface.- ociad
d_ports -.implements.- searchad
d_ports -.implements.- osad
d_ports -.implements.- emad
d_ports -.implements.- wpad
d_ports -.implements.- whad
d_ports -.implements.- prad
pgad --> pg2[(PostgreSQL)]
meiad --> me2[(Meilisearch)]
fsad --> fs2[(/media bind mounts)]
ffad --> cc2[(evey-cache + ffmpeg binary)]
metaad -.-> ext2[[TMDB / OMDb / IMDbAPI / TVmaze]]
introad -.-> ext3[[IntroDB / TIDB]]
itunad -.-> ext4[[iTunes Search / MusicBrainz]]
mbad -.-> ext4
ociad -.-> ext5[[OCI registry]]
Goroutine notes:
- gRPC server (
grpc.Server.Serve) and HTTP server (gateway + static) each run in their own goroutines off the main wire-up incmd/evey. app/libraryscan jobs run on a worker pool (one goroutine per active library scan, seelibrary.ScanService).- ffmpeg jobs are forked through
internal/pkg/procforkby the drivenffmpegadapter (ffmpeg.Transcoder.Start), one supervising goroutine per job draining the process stderr. shared.EventBusdispatches subscribers asynchronously: each subscriber runs in its own goroutine so a slow consumer never blocks publishers. The bus opens no transaction of its own; that is the handler's job.pluginrunnerspawns one subprocess per plugin; iframe plugins live inside the user browser and talk back via signed tokens issued byapp/plugin.
Level 3 (bonus) - Component zoom on the scan pipeline¶
The hot path that turns files on disk into searchable, enriched media. Solid
arrows are direct calls; dashed arrows are domain events on the bus. Search
indexing is a direct port call from ScanService, not an event.
flowchart LR
user([Admin triggers<br/>POST /api/v1/libraries/:id/scan])
subgraph driving2[driving]
h[grpcapi<br/>library_handler.go]
end
subgraph appLayer[internal/app/library]
svc[ScanService<br/>worker pool,<br/>1 goroutine per library]
end
subgraph drivenScan[driven]
scanner[filesystem.Scanner<br/>walk + parser]
watcher[filesystem.FSWatcher<br/>fsnotify, incremental]
end
subgraph domainScan[domain/library]
repo[(MediaItemCommandRepo + MediaItemQueryRepo ports)]
bus[(EventBus port)]
end
subgraph persist[driven persistence]
pgrepo[postgres.MediaItemRepository<br/>inside TransactionalUnit]
searchidx[meilisearch.SearchAdapter<br/>implements library.SearchIndex]
end
subgraph consumers[Event consumers - each in its own goroutine]
meta[app/metadata<br/>TMDB/OMDb enrichment]
trick[app/library<br/>trickplay worker]
intro[app/library<br/>intro detection]
feed[app/feed<br/>genre cache invalidation]
notif[app/notification<br/>new media alerts]
wh[app/webhook<br/>catch-all, outbound POST]
end
user --> h --> svc
svc --> scanner
watcher --> svc
scanner --> svc
svc --> repo
repo --> pgrepo
svc --> searchidx
svc --> bus
bus -. MediaItemAdded .-> meta
bus -. MediaItemAdded .-> trick
bus -. MediaItemAdded .-> intro
bus -. MediaItemAdded .-> feed
bus -. MediaItemAdded .-> notif
bus -. MediaItemAdded .-> wh
trick -. TrickplayGenerated .-> intro
meta --> repo
trick --> ffstore[(evey-cache<br/>trickplay sprites)]
Read this with the "Typical data flow" section above: the playback path is the symmetric read path; the scan pipeline is the write path that primes the media index the read path serves from.
Further reading¶
- DEV.md - build, run, migrations, proto generation
- Codebase map - which files to touch for common changes
- Playback pipeline - the HLS video-serving read path, in depth
- API reference - generated API reference
- Decision records - architectural decision records